Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd894f980 | ||
|
|
de045fb458 | ||
|
|
24a220ae4a | ||
|
|
ae899926fe | ||
|
|
534cab061c | ||
|
|
7854824f01 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Purpur Inventory Helper
|
||||
|
||||
A Purpur 26.2 plugin that sorts inventories with a craftable Sorting Stick and automatically replaces broken tools with equivalent tools from the player's inventory.
|
||||
A Purpur 26.2 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, and risky over-level enchantment combining in ordinary anvils.
|
||||
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
|
||||
|
||||
@@ -10,6 +10,7 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25 or newer
|
||||
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
|
||||
|
||||
## Build
|
||||
|
||||
@@ -19,6 +20,18 @@ 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,
|
||||
verifies SHA-256
|
||||
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, 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.
|
||||
The narrowly scoped anvil-generation adapter also uses this checksum-pinned native runtime as a
|
||||
compile-only dependency. Server/API and ProtocolLib classes are not bundled.
|
||||
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
|
||||
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
|
||||
non-blocking live checks.
|
||||
|
||||
## Sorting Stick recipe
|
||||
|
||||
Place these ingredients vertically in the crafting grid's center column:
|
||||
@@ -29,7 +42,15 @@ Redstone Dust
|
||||
Stick
|
||||
```
|
||||
|
||||
Either storage-block variant produces one Sorting Stick.
|
||||
Either storage-block variant produces one Sorting Stick. Both variants are universally discovered in
|
||||
the recipe book's Equipment category for online and joining players. Discovery is persistent and
|
||||
idempotent; reloading the plugin retains player recipe-book knowledge and ordinary vanilla state.
|
||||
If the two recipes cannot register as one complete group, recipe-book discovery is disabled while
|
||||
existing manual crafting and inventory behavior remain available.
|
||||
|
||||
## Alternative Dispenser recipe
|
||||
|
||||
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.
|
||||
|
||||
## Sorting inventories
|
||||
|
||||
@@ -45,6 +66,48 @@ Player hotbar, armor, and off-hand slots remain unchanged during player sorting.
|
||||
|
||||
When a main-hand tool breaks, an item with the exact same material is moved into the broken tool's selected hotbar slot on the next server tick. Main-storage candidates are preferred before tools in other hotbar slots. The complete replacement item is moved unchanged, preserving durability, enchantments, custom names, and other metadata. Off-hand breaks are ignored, and no unrelated item is moved when a match is unavailable.
|
||||
|
||||
## Advanced anvil combining
|
||||
|
||||
All players can combine two **same-material, non-book items** in an ordinary anvil. Equal enchantment levels become one higher, even above the normal maximum; unequal levels keep the higher. Compatible applicable enchantments carry over. Conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
|
||||
|
||||
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
|
||||
|
||||
Enhanced merges charge **no XP** and have no "Too Expensive" restriction. The private action bar displays the result and exact success/failure chances; private chat explains that taking the result commits the attempt. Each accepted attempt consumes **one item from each input**. Success delivers one result; failure destroys both consumed items and causes an **explosion** that can hurt players and damage blocks. Native protections remain authoritative, but a protected/refused explosion does not refund the inputs.
|
||||
|
||||
Default risk uses the **largest resulting enchantment excess above its normal maximum**:
|
||||
|
||||
| Excess | Failure chance |
|
||||
| --- | --- |
|
||||
| 0 | 0% |
|
||||
| 1 | 20% |
|
||||
| 2 | 25% |
|
||||
| 3 | 35% |
|
||||
| 4 | 50% |
|
||||
| 5 | 70% |
|
||||
| 6 | 95% |
|
||||
| 7 or more | 99% |
|
||||
|
||||
ProtocolLib binds taking to the displayed menu generation before payment. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
|
||||
|
||||
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
|
||||
|
||||
Administrators with `spigotinventoryhelper.admin` (default OP) and authorized console can read/set durable balance values:
|
||||
|
||||
```text
|
||||
/inventoryhelperadmin config <key>
|
||||
/inventoryhelperadmin config <key> <value>
|
||||
```
|
||||
|
||||
| Key in `plugins/SpigotInventoryHelper/config.yml` | Default |
|
||||
| --- | --- |
|
||||
| `advanced-anvil-base-failure-percent` | 20 |
|
||||
| `advanced-anvil-risk-step-percent` | 5 |
|
||||
| `advanced-anvil-max-failure-percent` | 99 |
|
||||
|
||||
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 always has zero risk. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
|
||||
|
||||
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, item serialization, packet/result-taking, stale client generations, and protected explosion boundaries. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
|
||||
|
||||
## Releases
|
||||
|
||||
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
@@ -24,6 +27,8 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
@@ -40,6 +45,65 @@ tasks.test {
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
// Exercise the declared runtime's native recipe-book and crafting engine without starting a server.
|
||||
// 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") }
|
||||
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")
|
||||
.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"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
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") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(inventoryRuntime)
|
||||
outputs.dir(inventoryRuntime.map { it.dir("versions") })
|
||||
outputs.dir(inventoryRuntime.map { it.dir("libraries") })
|
||||
}
|
||||
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")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
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") {
|
||||
description = "Runs pinned Purpur recipe-book and crafting regressions without starting a server"
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(inventoryRuntime)
|
||||
}
|
||||
tasks.check { dependsOn(nativeInventoryTest) }
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
tasks.processResources {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Main-thread destination reservation: no overflow drops, no overwrite of unrelated items. */
|
||||
final class AdvancedAnvilDelivery {
|
||||
private final Player player;
|
||||
private final ItemStack cursorBefore;
|
||||
private final ItemStack cursorAfter;
|
||||
private final ItemStack[] storageBefore;
|
||||
private final ItemStack[] storageAfter;
|
||||
private final ItemStack offhandBefore;
|
||||
private final ItemStack offhandAfter;
|
||||
private final boolean cursorDelivery;
|
||||
private final boolean offhandDelivery;
|
||||
|
||||
private AdvancedAnvilDelivery(Player player, ItemStack cursorBefore, ItemStack cursorAfter,
|
||||
ItemStack[] storageBefore, ItemStack[] storageAfter, ItemStack offhandBefore,
|
||||
ItemStack offhandAfter, boolean cursorDelivery, boolean offhandDelivery) {
|
||||
this.player = player;
|
||||
this.cursorBefore = cursorBefore;
|
||||
this.cursorAfter = cursorAfter;
|
||||
this.storageBefore = storageBefore;
|
||||
this.storageAfter = storageAfter;
|
||||
this.offhandBefore = offhandBefore;
|
||||
this.offhandAfter = offhandAfter;
|
||||
this.cursorDelivery = cursorDelivery;
|
||||
this.offhandDelivery = offhandDelivery;
|
||||
}
|
||||
|
||||
static AdvancedAnvilDelivery reserve(InventoryClickEvent event, ItemStack output) {
|
||||
if (!(event.getWhoClicked() instanceof Player player) || event.getRawSlot() != 2) return null;
|
||||
ItemStack cursor = copy(player.getItemOnCursor());
|
||||
switch (event.getAction()) {
|
||||
case PICKUP_ALL, PICKUP_HALF, PICKUP_ONE, PICKUP_SOME -> {
|
||||
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) return null;
|
||||
ItemStack next = copy(output);
|
||||
if (cursor != null) {
|
||||
if (!cursor.isSimilar(output) || cursor.getAmount() >= cursor.getMaxStackSize()) return null;
|
||||
next = cursor.clone();
|
||||
next.setAmount(next.getAmount() + 1);
|
||||
}
|
||||
return new AdvancedAnvilDelivery(player, cursor, next, null, null, null, null, true, false);
|
||||
}
|
||||
case MOVE_TO_OTHER_INVENTORY -> {
|
||||
ItemStack[] before = copies(player.getInventory().getStorageContents());
|
||||
ItemStack[] after = copies(before);
|
||||
if (!allocate(after, output)) return null;
|
||||
return new AdvancedAnvilDelivery(player, cursor, null, before, after, null, null, false, false);
|
||||
}
|
||||
case HOTBAR_SWAP, HOTBAR_MOVE_AND_READD -> {
|
||||
int target = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
|
||||
if ((target < 0 || target > 8) && target != 40) return null;
|
||||
ItemStack[] before = copies(player.getInventory().getStorageContents());
|
||||
ItemStack[] after = copies(before);
|
||||
boolean offhand = target == 40;
|
||||
ItemStack old = offhand ? copy(player.getInventory().getItemInOffHand()) : copy(before[target]);
|
||||
if (!offhand) after[target] = output.clone();
|
||||
if (old != null && !allocate(after, old)) return null;
|
||||
return new AdvancedAnvilDelivery(player, cursor, null, before, after,
|
||||
offhand ? old : null, offhand ? output.clone() : null, false, offhand);
|
||||
}
|
||||
default -> { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
boolean stillValid() {
|
||||
return Objects.equals(cursorBefore, copy(player.getItemOnCursor()))
|
||||
&& (storageBefore == null || Arrays.equals(storageBefore,
|
||||
copies(player.getInventory().getStorageContents())))
|
||||
&& (!offhandDelivery || Objects.equals(offhandBefore,
|
||||
copy(player.getInventory().getItemInOffHand())));
|
||||
}
|
||||
|
||||
void deliver() {
|
||||
if (cursorDelivery) player.setItemOnCursor(copy(cursorAfter));
|
||||
if (storageAfter != null) {
|
||||
for (int slot = 0; slot < storageAfter.length; slot++) {
|
||||
if (!Objects.equals(storageBefore[slot], storageAfter[slot])) {
|
||||
player.getInventory().setItem(slot, copy(storageAfter[slot]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offhandDelivery) player.getInventory().setItemInOffHand(copy(offhandAfter));
|
||||
}
|
||||
|
||||
/** Native anvil quick-move searches hotbar8..0 then storage35..9, merge pass before empty pass. */
|
||||
private static boolean allocate(ItemStack[] storage, ItemStack source) {
|
||||
ItemStack remaining = source.clone();
|
||||
int[] order = new int[36];
|
||||
for (int i = 0; i < 9; i++) order[i] = 8 - i;
|
||||
for (int i = 9; i < 36; i++) order[i] = 44 - i;
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
for (int slot : order) {
|
||||
ItemStack present = storage[slot];
|
||||
if (pass == 0 && present != null && present.isSimilar(remaining)) {
|
||||
int moved = Math.min(remaining.getAmount(), present.getMaxStackSize() - present.getAmount());
|
||||
if (moved > 0) {
|
||||
present.setAmount(present.getAmount() + moved);
|
||||
remaining.setAmount(remaining.getAmount() - moved);
|
||||
}
|
||||
} else if (pass == 1 && present == null) {
|
||||
int moved = Math.min(remaining.getAmount(), remaining.getMaxStackSize());
|
||||
storage[slot] = remaining.asQuantity(moved);
|
||||
remaining.setAmount(remaining.getAmount() - moved);
|
||||
}
|
||||
if (remaining.getAmount() == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ItemStack copy(ItemStack item) {
|
||||
return item == null || item.isEmpty() ? null : item.clone();
|
||||
}
|
||||
|
||||
private static ItemStack[] copies(ItemStack[] items) {
|
||||
return Arrays.stream(items).map(AdvancedAnvilDelivery::copy).toArray(ItemStack[]::new);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import com.destroystokyo.paper.event.block.AnvilDamagedEvent;
|
||||
import java.util.function.DoubleSupplier;
|
||||
import org.bukkit.Effect;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.Directional;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
|
||||
/** Native protection-aware side effects, invoked only after a committed inventory transaction. */
|
||||
final class AdvancedAnvilEffects {
|
||||
private AdvancedAnvilEffects() {}
|
||||
|
||||
static void explode(Player player, Location origin) {
|
||||
// Purpur's final flag is essential: the combining player must remain damage-eligible.
|
||||
origin.getWorld().createExplosion(player, origin.clone().add(.5, .5, .5),
|
||||
6F, false, true, false);
|
||||
}
|
||||
|
||||
static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear) {
|
||||
var block = origin.getBlock();
|
||||
Material type = block.getType();
|
||||
if (player.getGameMode() != GameMode.CREATIVE && isAnvil(type) && wear.getAsDouble() < .12) {
|
||||
Material next = switch (type) {
|
||||
case ANVIL -> Material.CHIPPED_ANVIL;
|
||||
case CHIPPED_ANVIL -> Material.DAMAGED_ANVIL;
|
||||
default -> null;
|
||||
};
|
||||
BlockData proposal = next == null ? null : next.createBlockData();
|
||||
var event = new AnvilDamagedEvent(view, proposal);
|
||||
if (!event.callEvent()) return; // Native cancellation also suppresses the use effect.
|
||||
if (event.getDamageState() == AnvilDamagedEvent.DamageState.BROKEN) {
|
||||
block.setType(Material.AIR, false);
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 0);
|
||||
return;
|
||||
}
|
||||
BlockData chosen = event.getDamageState().getMaterial().createBlockData();
|
||||
if (chosen instanceof Directional facing && block.getBlockData() instanceof Directional old) {
|
||||
facing.setFacing(old.getFacing());
|
||||
}
|
||||
block.setBlockData(chosen, false);
|
||||
}
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0);
|
||||
}
|
||||
|
||||
static boolean isAnvil(Material material) {
|
||||
return material == Material.ANVIL || material == Material.CHIPPED_ANVIL
|
||||
|| material == Material.DAMAGED_ANVIL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.PrepareAnvilEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Owns native preview and deferred, final-cancellation-aware one-item transactions. */
|
||||
final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
private static final Component WARNING = Component.text(
|
||||
"Taking this result commits the attempt. Failure destroys both items and causes an explosion.");
|
||||
private final Plugin plugin;
|
||||
private final Supplier<AdvancedAnvilPolicy> policy;
|
||||
private final java.util.function.IntSupplier risk;
|
||||
private final java.util.function.DoubleSupplier wear;
|
||||
private final Map<AnvilView, Preview> previews = new IdentityHashMap<>();
|
||||
private final Map<AnvilView, Attempt> pending = new IdentityHashMap<>();
|
||||
private final Map<org.bukkit.event.inventory.InventoryClickEvent, Boolean> seen = new java.util.WeakHashMap<>();
|
||||
|
||||
private static final class Attempt {
|
||||
final Preview preview;
|
||||
final org.bukkit.event.inventory.InventoryClickEvent event;
|
||||
final AdvancedAnvilDelivery delivery;
|
||||
final AdvancedAnvilProtocolGuard.Admission admission;
|
||||
boolean invalid;
|
||||
Attempt(Preview preview, org.bukkit.event.inventory.InventoryClickEvent event,
|
||||
AdvancedAnvilDelivery delivery, AdvancedAnvilProtocolGuard.Admission admission, boolean invalid) {
|
||||
this.preview = preview;
|
||||
this.event = event;
|
||||
this.delivery = delivery;
|
||||
this.admission = admission;
|
||||
this.invalid = invalid;
|
||||
}
|
||||
}
|
||||
private final AdvancedAnvilProtocolGuard protocol;
|
||||
private final BukkitTask refresh;
|
||||
private boolean closed;
|
||||
|
||||
// Main-thread-only, exact snapshots for preparation/invalidation, not transaction authority.
|
||||
private record Preview(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy,
|
||||
AdvancedAnvilMerge.Proposal proposal, boolean originalBypass, Component message) {}
|
||||
|
||||
AdvancedAnvilListener(Plugin plugin, Supplier<AdvancedAnvilPolicy> policy,
|
||||
java.util.function.IntSupplier risk, java.util.function.DoubleSupplier wear,
|
||||
com.comphenix.protocol.ProtocolManager manager, String version) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.policy = Objects.requireNonNull(policy);
|
||||
this.risk = Objects.requireNonNull(risk);
|
||||
this.wear = Objects.requireNonNull(wear);
|
||||
protocol = new AdvancedAnvilProtocolGuard(plugin, manager, version, this);
|
||||
try {
|
||||
refresh = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskTimer(
|
||||
plugin, this::refreshActionBars, 40L, 40L));
|
||||
} catch (RuntimeException | LinkageError unavailable) {
|
||||
protocol.close();
|
||||
throw unavailable;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void prepare(PrepareAnvilEvent event) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
AnvilView view = event.getView();
|
||||
Attempt attempt = pending.get(view);
|
||||
if (attempt != null) {
|
||||
// Any intervening preparation invalidates the captured generation, even an ABA edit.
|
||||
attempt.invalid = true;
|
||||
protocol.invalidate(view);
|
||||
event.setResult(null);
|
||||
return;
|
||||
}
|
||||
Preview previous = previews.get(view);
|
||||
ItemStack left = view.getTopInventory().getItem(0);
|
||||
ItemStack right = view.getTopInventory().getItem(1);
|
||||
AdvancedAnvilPolicy effective = policy.get();
|
||||
AdvancedAnvilMerge.Proposal proposal = AdvancedAnvilMerge.prepare(left, right, effective);
|
||||
if (proposal == null) {
|
||||
if (previous != null) {
|
||||
release(view);
|
||||
// Prior bypass can alter vanilla createResult's Too Expensive decision before this
|
||||
// event runs. Recompute once with restored flags instead of leaking an exemption.
|
||||
recompute(view);
|
||||
event.setResult(view.getTopInventory().getItem(2));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!protocol.watch(view)) {
|
||||
release(view);
|
||||
event.setResult(null); // Withhold enhanced output BEFORE any unsafe bypass/install.
|
||||
return;
|
||||
}
|
||||
boolean originalBypass = previous == null ? view.canBypassCost() : previous.originalBypass();
|
||||
Component message = message(proposal);
|
||||
boolean generationChanged = previous == null || !matches(view, previous) || changed(previous, proposal);
|
||||
if (generationChanged) {
|
||||
NativeAnvilGeneration.menu(view).incrementStateId();
|
||||
NativeAnvilGeneration.requireResultFrame(view, proposal.output() == null);
|
||||
}
|
||||
previews.put(view, generationChanged ? new Preview(left.clone(), right.clone(), effective, proposal,
|
||||
originalBypass, message) : previous);
|
||||
if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(message);
|
||||
if (changed(previous, proposal)) {
|
||||
player.sendMessage(proposal.output() == null ? message : WARNING);
|
||||
}
|
||||
}
|
||||
// Exact private risk is published before CraftEventFactory installs/broadcasts the output.
|
||||
event.setResult(proposal.output());
|
||||
if (proposal.output() != null) {
|
||||
view.setRepairCost(0);
|
||||
view.setBypassCost(true);
|
||||
} else view.setBypassCost(originalBypass);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void intercept(org.bukkit.event.inventory.InventoryClickEvent event) {
|
||||
if (closed || !(event.getView() instanceof AnvilView view)) return;
|
||||
Preview preview = previews.get(view);
|
||||
boolean collection = event.getAction() == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR;
|
||||
if (event.getRawSlot() != 2 && !collection) return;
|
||||
if (pending.containsKey(view)) {
|
||||
if (pending.get(view).event != event) event.setCancelled(true);
|
||||
view.getTopInventory().setItem(2, null);
|
||||
return;
|
||||
}
|
||||
if (preview == null && !protocol.watched(view)) return; // Never-owned vanilla stays native.
|
||||
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
|
||||
var admission = protocol.consume(view, preview, event);
|
||||
if (duplicate || admission == null) {
|
||||
event.setCancelled(true);
|
||||
if (preview != null) rerender(view);
|
||||
return;
|
||||
}
|
||||
if (preview == null) return; // Fresh normal book/repair after enhanced context stays native.
|
||||
boolean stale = !matches(view, preview)
|
||||
|| !Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2));
|
||||
AdvancedAnvilDelivery delivery = preview.proposal().output() == null ? null
|
||||
: AdvancedAnvilDelivery.reserve(event, preview.proposal().output());
|
||||
Attempt attempt = new Attempt(preview, event, delivery, admission,
|
||||
stale || event.isCancelled() || delivery == null);
|
||||
pending.put(view, attempt);
|
||||
// Do not self-cancel accepted intents: continuation can observe the final native denial.
|
||||
// Empty output prevents native onTake, bulk loops and collection from producing/payment.
|
||||
view.getTopInventory().setItem(2, null);
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt));
|
||||
} catch (RuntimeException unavailable) {
|
||||
pending.remove(view);
|
||||
event.setCancelled(true);
|
||||
rerender(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(AnvilView view, Attempt attempt) {
|
||||
if (closed || pending.get(view) != attempt) return;
|
||||
pending.remove(view);
|
||||
if (attempt.invalid || attempt.event.isCancelled() || !attempt.admission.valid()
|
||||
|| previews.get(view) != attempt.preview
|
||||
|| !current(view) || !matches(view, attempt.preview)
|
||||
|| !attempt.delivery.stillValid()) {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
org.bukkit.Location origin = view.getTopInventory().getLocation();
|
||||
if (origin == null || !isAnvil(origin.getBlock().getType())) {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
int roll = risk.getAsInt();
|
||||
if (roll < 0 || roll >= 100) throw new IllegalStateException("Invalid anvil risk roll");
|
||||
boolean failed = roll < attempt.preview.proposal().failurePercent();
|
||||
ItemStack left = view.getTopInventory().getItem(0);
|
||||
ItemStack right = view.getTopInventory().getItem(1);
|
||||
// Craft anvil getItem returns live native mirrors. Change both counts without invoking
|
||||
// slotsChanged between debits, so close/death/prepare callbacks cannot return a paid item.
|
||||
left.setAmount(left.getAmount() - 1);
|
||||
right.setAmount(right.getAmount() - 1);
|
||||
view.setBypassCost(attempt.preview.originalBypass());
|
||||
try {
|
||||
if (!failed) attempt.delivery.deliver();
|
||||
try {
|
||||
AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear);
|
||||
} finally {
|
||||
if (failed) AdvancedAnvilEffects.explode((Player) view.getPlayer(), origin);
|
||||
}
|
||||
} finally {
|
||||
rerender(view);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matches(AnvilView view, Preview preview) {
|
||||
return Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|
||||
&& Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|
||||
&& preview.policy().equals(policy.get());
|
||||
}
|
||||
|
||||
private void rerender(AnvilView view) {
|
||||
org.bukkit.Location origin = view.getTopInventory().getLocation();
|
||||
if (current(view) && origin != null && isAnvil(origin.getBlock().getType())) {
|
||||
recompute(view);
|
||||
} else release(view);
|
||||
}
|
||||
|
||||
private static boolean isAnvil(org.bukkit.Material material) {
|
||||
return AdvancedAnvilEffects.isAnvil(material);
|
||||
}
|
||||
|
||||
/** Durable settings acknowledgment invalidates snapshots through real input preparation. */
|
||||
void policyChanged() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
for (AnvilView view : List.copyOf(previews.keySet())) {
|
||||
if (current(view)) {
|
||||
recompute(view);
|
||||
} else {
|
||||
release(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void closeView(InventoryCloseEvent event) {
|
||||
if (event.getView() instanceof AnvilView view) {
|
||||
pending.remove(view);
|
||||
release(view);
|
||||
protocol.forget(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshActionBars() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
protocol.prune();
|
||||
for (AnvilView view : List.copyOf(previews.keySet())) {
|
||||
if (!current(view)) {
|
||||
release(view);
|
||||
continue;
|
||||
}
|
||||
Preview preview = previews.get(view);
|
||||
// A changed input/policy cannot keep displaying a stale risk between preparations.
|
||||
if (!Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|
||||
|| !Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|
||||
|| !preview.policy().equals(policy.get())) {
|
||||
recompute(view);
|
||||
} else if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(preview.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object protocolPreview(AnvilView view) { return previews.get(view); }
|
||||
|
||||
boolean protocolReady(AnvilView view) {
|
||||
Preview preview = previews.get(view);
|
||||
return !closed && current(view) && !pending.containsKey(view)
|
||||
&& (preview == null || (matches(view, preview)
|
||||
&& Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2))));
|
||||
}
|
||||
|
||||
private static boolean current(AnvilView view) {
|
||||
return view.getPlayer().getOpenInventory() == view
|
||||
&& (!(view.getPlayer() instanceof Player player) || (player.isOnline() && !player.isDead()));
|
||||
}
|
||||
|
||||
private static void recompute(AnvilView view) {
|
||||
ItemStack left = view.getTopInventory().getItem(0);
|
||||
view.getTopInventory().setItem(0, left == null ? null : left.clone());
|
||||
// Preparation has now returned through CraftEventFactory's final result installation.
|
||||
// Identical-output policy changes still need a real header/frame, not merely a counter.
|
||||
NativeAnvilGeneration.publish(view);
|
||||
}
|
||||
|
||||
private void release(AnvilView view) {
|
||||
protocol.invalidate(view);
|
||||
Preview preview = previews.remove(view);
|
||||
if (preview != null) {
|
||||
view.setBypassCost(preview.originalBypass());
|
||||
if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(Component.empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean changed(Preview previous, AdvancedAnvilMerge.Proposal next) {
|
||||
return previous == null || !Objects.equals(previous.proposal().output(), next.output())
|
||||
|| previous.proposal().failurePercent() != next.failurePercent()
|
||||
|| !Objects.equals(previous.proposal().refusal(), next.refusal());
|
||||
}
|
||||
|
||||
private static Component message(AdvancedAnvilMerge.Proposal proposal) {
|
||||
if (proposal.output() == null) {
|
||||
return Component.text("Advanced Anvil: " + proposal.refusal());
|
||||
}
|
||||
ItemStack output = proposal.output();
|
||||
Component result = Component.text(title(output.getType().name()));
|
||||
Component customName = output.getItemMeta().customName();
|
||||
if (customName != null) {
|
||||
result = result.append(Component.text(" (" )).append(customName).append(Component.text(")"));
|
||||
}
|
||||
String enchantments = output.getEnchantments().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(java.util.Comparator.comparing(
|
||||
enchantment -> enchantment.getKey().toString())))
|
||||
.map(entry -> title(entry.getKey().getKey().getKey()) + " " + roman(entry.getValue()))
|
||||
.collect(Collectors.joining(" · "));
|
||||
return result.append(Component.text(" · " + enchantments + " · Success "
|
||||
+ (100 - proposal.failurePercent()) + "% · Failure " + proposal.failurePercent()
|
||||
+ "% — explosion"));
|
||||
}
|
||||
|
||||
private static String title(String identifier) {
|
||||
return java.util.Arrays.stream(identifier.toLowerCase(Locale.ROOT).split("_"))
|
||||
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private static String roman(int level) {
|
||||
int[] values = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
|
||||
String[] digits = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int index = 0; index < values.length; index++) {
|
||||
while (level >= values[index]) {
|
||||
result.append(digits[index]);
|
||||
level -= values[index];
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
pending.clear();
|
||||
seen.clear();
|
||||
refresh.cancel();
|
||||
HandlerList.unregisterAll(this);
|
||||
for (AnvilView view : List.copyOf(previews.keySet())) {
|
||||
release(view);
|
||||
if (current(view)) {
|
||||
recompute(view);
|
||||
}
|
||||
}
|
||||
protocol.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.Damageable;
|
||||
|
||||
/** Native item proposal, independent of payment and risk rolling. Never mutates either input. */
|
||||
final class AdvancedAnvilMerge {
|
||||
private AdvancedAnvilMerge() {}
|
||||
|
||||
/** Null prepare = unrelated vanilla operation; null output = explained enhanced refusal. */
|
||||
record Proposal(ItemStack output, int failurePercent, String refusal) {}
|
||||
|
||||
static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy) {
|
||||
Objects.requireNonNull(policy);
|
||||
if (left == null || right == null || left.isEmpty() || right.isEmpty()
|
||||
|| left.getType() != right.getType() || book(left.getType())) {
|
||||
return null;
|
||||
}
|
||||
Map<Enchantment, Integer> levels = new LinkedHashMap<>(left.getEnchantments());
|
||||
Map<Enchantment, Integer> rightLevels = right.getEnchantments();
|
||||
if (levels.isEmpty() && rightLevels.isEmpty()) {
|
||||
return null; // Repair/rename-only belongs exclusively to native handling.
|
||||
}
|
||||
try {
|
||||
for (var entry : rightLevels.entrySet()) {
|
||||
levels.merge(entry.getKey(), entry.getValue(), (first, second) ->
|
||||
first.equals(second) ? Math.addExact(first, 1) : Math.max(first, second));
|
||||
}
|
||||
} catch (ArithmeticException overflow) {
|
||||
return refused("Enchantment level cannot be represented by this server.");
|
||||
}
|
||||
ItemStack output = left.clone();
|
||||
output.setAmount(1);
|
||||
var enchantments = new ArrayList<>(levels.keySet());
|
||||
for (int index = 0; index < enchantments.size(); index++) {
|
||||
Enchantment enchantment = enchantments.get(index);
|
||||
if (levels.get(enchantment) <= 0 || !enchantment.canEnchantItem(output)) {
|
||||
return refused("An enchantment is not applicable to this item.");
|
||||
}
|
||||
for (int other = index + 1; other < enchantments.size(); other++) {
|
||||
Enchantment candidate = enchantments.get(other);
|
||||
if (enchantment.conflictsWith(candidate) || candidate.conflictsWith(enchantment)) {
|
||||
return refused("The resulting enchantments conflict.");
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
output.addUnsafeEnchantments(levels);
|
||||
repair(output, right);
|
||||
// Unsafe API setters may clamp silently; persistence can independently reject a result.
|
||||
// Inspect the actual native representation instead of imposing a lower gameplay cap.
|
||||
if (!output.getEnchantments().equals(levels)) {
|
||||
return refused("Enchantment level cannot be represented by this server.");
|
||||
}
|
||||
ItemStack restored = ItemStack.deserializeBytes(output.serializeAsBytes());
|
||||
if (!restored.equals(output) || !restored.getEnchantments().equals(levels)) {
|
||||
return refused("Result cannot be represented exactly by this server.");
|
||||
}
|
||||
} catch (RuntimeException unsupported) {
|
||||
return refused("Result cannot be represented exactly by this server.");
|
||||
}
|
||||
int excess = levels.entrySet().stream()
|
||||
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
|
||||
.max().orElse(0);
|
||||
return new Proposal(output, policy.failurePercent(excess), null);
|
||||
}
|
||||
|
||||
private static Proposal refused(String reason) {
|
||||
return new Proposal(null, 0, reason);
|
||||
}
|
||||
|
||||
private static boolean book(Material material) {
|
||||
return switch (material) {
|
||||
case BOOK, ENCHANTED_BOOK, WRITABLE_BOOK, WRITTEN_BOOK, KNOWLEDGE_BOOK -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static void repair(ItemStack output, ItemStack right) {
|
||||
if (!(output.getItemMeta() instanceof Damageable first) || first.isUnbreakable()) {
|
||||
return;
|
||||
}
|
||||
int maximum = maxDamage(output, first);
|
||||
if (maximum <= 0 || !first.hasDamageValue()) {
|
||||
return;
|
||||
}
|
||||
if (!(right.getItemMeta() instanceof Damageable second)) {
|
||||
return;
|
||||
}
|
||||
// Native same-item formula uses each input's effective MAX_DAMAGE component, not material
|
||||
// durability for both. Use long intermediates so custom component values cannot overflow.
|
||||
long remaining = (long) maximum - first.getDamage()
|
||||
+ maxDamage(right, second) - second.getDamage() + (long) maximum * 12 / 100;
|
||||
long damage = Math.max(0, maximum - remaining);
|
||||
if (damage < first.getDamage()) {
|
||||
first.setDamage((int) damage);
|
||||
output.setItemMeta(first);
|
||||
}
|
||||
}
|
||||
|
||||
private static int maxDamage(ItemStack item, Damageable meta) {
|
||||
Integer maximum = item.getData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
return maximum == null ? 0 : maximum;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
/** Immutable balance captured by an attempt; excess is not an enchantment-level cap. */
|
||||
record AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent) {
|
||||
AdvancedAnvilPolicy {
|
||||
validate(basePercent);
|
||||
validate(stepPercent);
|
||||
validate(maxFailurePercent);
|
||||
}
|
||||
|
||||
static AdvancedAnvilPolicy defaults() {
|
||||
return new AdvancedAnvilPolicy(20, 5, 99);
|
||||
}
|
||||
|
||||
int failurePercent(int greatestExcess) {
|
||||
if (greatestExcess < 0) {
|
||||
throw new IllegalArgumentException("Excess must be nonnegative");
|
||||
}
|
||||
if (greatestExcess == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (basePercent >= maxFailurePercent) {
|
||||
return maxFailurePercent;
|
||||
}
|
||||
if (stepPercent == 0) {
|
||||
return basePercent;
|
||||
}
|
||||
// int-sized excess has a representable long triangular number. Compare before
|
||||
// multiplying by step: that multiplication could overflow even a long.
|
||||
long triangular = (long) greatestExcess * (greatestExcess - 1) / 2;
|
||||
if (triangular > (maxFailurePercent - basePercent) / stepPercent) {
|
||||
return maxFailurePercent;
|
||||
}
|
||||
return basePercent + (int) triangular * stepPercent;
|
||||
}
|
||||
|
||||
private static void validate(int percent) {
|
||||
if (percent < 0 || percent > 100) {
|
||||
throw new IllegalArgumentException("Percent must be an integer from 0 through 100");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Main-thread, one-use protocol-to-Bukkit admission for exact watched native anvil views. */
|
||||
final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
static final class Admission {
|
||||
final PacketEvent event;
|
||||
final ServerboundContainerClickPacket packet;
|
||||
final Object preview;
|
||||
final java.util.function.BooleanSupplier available;
|
||||
BukkitTask expiry;
|
||||
boolean sealed;
|
||||
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
|
||||
java.util.function.BooleanSupplier available) {
|
||||
this.event = event;
|
||||
this.packet = packet;
|
||||
this.preview = preview;
|
||||
this.available = available;
|
||||
}
|
||||
boolean valid() {
|
||||
// Consumption removes the map ticket, but bridge loss must still revoke deferred payment.
|
||||
return available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
|
||||
}
|
||||
}
|
||||
|
||||
private final Plugin plugin;
|
||||
private final ProtocolManager manager;
|
||||
private final AdvancedAnvilListener owner;
|
||||
private final Set<AnvilView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
// Only this immutable actor membership is read on an unexpectedly off-thread library callback.
|
||||
private final Set<Player> actors = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
private final Map<AnvilView, Admission> admissions = new IdentityHashMap<>();
|
||||
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
|
||||
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
|
||||
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
|
||||
}
|
||||
private final Map<AnvilView, Frame> frames = new IdentityHashMap<>();
|
||||
// Guard-wide, not reset on forget: even a reused window ID cannot reuse a displayed version.
|
||||
private final AnvilFrameVersions versions = new AnvilFrameVersions();
|
||||
private final PacketAdapter outbound;
|
||||
private final PacketAdapter gate;
|
||||
private final PacketAdapter seal;
|
||||
private volatile boolean closed;
|
||||
|
||||
AdvancedAnvilProtocolGuard(Plugin plugin, ProtocolManager manager, String version, AdvancedAnvilListener owner) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.manager = Objects.requireNonNull(manager);
|
||||
this.owner = Objects.requireNonNull(owner);
|
||||
if (version == null || !version.contains("Purpur") || !version.contains("26.2.build.2618-stable")
|
||||
|| manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge");
|
||||
gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
|
||||
.listenerPriority(ListenerPriority.LOWEST).optionSync()) {
|
||||
@Override public void onPacketReceiving(PacketEvent event) { receive(event); }
|
||||
};
|
||||
seal = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
|
||||
.listenerPriority(ListenerPriority.MONITOR).optionSync()) {
|
||||
@Override public void onPacketReceiving(PacketEvent event) { seal(event); }
|
||||
};
|
||||
outbound = new PacketAdapter(PacketAdapter.params(plugin,
|
||||
PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT)
|
||||
.listenerPriority(ListenerPriority.HIGHEST).optionSync()) {
|
||||
@Override public void onPacketSending(PacketEvent event) { send(event); }
|
||||
};
|
||||
try {
|
||||
manager.addPacketListener(outbound);
|
||||
manager.addPacketListener(gate);
|
||||
manager.addPacketListener(seal);
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
manager.removePacketListener(outbound);
|
||||
manager.removePacketListener(gate);
|
||||
manager.removePacketListener(seal);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
boolean watch(AnvilView view) {
|
||||
if (closed || manager.isClosed() || versions.exhausted() || !plugin.getServer().isPrimaryThread()) return false;
|
||||
try {
|
||||
NativeAnvilGeneration.menu(view);
|
||||
if (!(view.getPlayer() instanceof Player player)) return false;
|
||||
watched.add(view);
|
||||
actors.add(player);
|
||||
invalidate(view);
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
|
||||
boolean watched(AnvilView view) { return watched.contains(view); }
|
||||
|
||||
void invalidate(AnvilView view) {
|
||||
Admission admission = admissions.remove(view);
|
||||
if (admission != null && admission.expiry != null) admission.expiry.cancel();
|
||||
}
|
||||
|
||||
private void send(PacketEvent event) {
|
||||
if (closed) return;
|
||||
if (!plugin.getServer().isPrimaryThread()) {
|
||||
if (actors.contains(event.getPlayer())) event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
|
||||
try {
|
||||
Object raw = event.getPacket().getHandle();
|
||||
int containerId;
|
||||
int nativeState;
|
||||
if (raw instanceof ClientboundContainerSetContentPacket content) {
|
||||
containerId = content.containerId();
|
||||
nativeState = content.stateId();
|
||||
} else if (raw instanceof ClientboundContainerSetSlotPacket slot) {
|
||||
containerId = slot.getContainerId();
|
||||
nativeState = slot.getStateId();
|
||||
} else throw new IllegalStateException("Unsupported native anvil frame");
|
||||
if (containerId != NativeAnvilGeneration.menu(view).containerId) return;
|
||||
frames.remove(view);
|
||||
if (event.isCancelled()) return;
|
||||
int version = versions.next();
|
||||
if (version < 0 || manager.isClosed()) {
|
||||
event.setCancelled(true);
|
||||
invalidate(view);
|
||||
return;
|
||||
}
|
||||
Object translated = raw instanceof ClientboundContainerSetContentPacket content
|
||||
? new ClientboundContainerSetContentPacket(containerId, version, content.items(), content.carriedItem())
|
||||
: new ClientboundContainerSetSlotPacket(containerId, version,
|
||||
((ClientboundContainerSetSlotPacket) raw).getSlot(),
|
||||
((ClientboundContainerSetSlotPacket) raw).getItem());
|
||||
event.setPacket(new PacketContainer(event.getPacketType(), translated));
|
||||
frames.put(view, new Frame(event, translated, version, nativeState, owner.protocolPreview(view)));
|
||||
} catch (RuntimeException | LinkageError unavailable) {
|
||||
frames.remove(view);
|
||||
invalidate(view);
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void receive(PacketEvent event) {
|
||||
if (closed) return;
|
||||
if (!plugin.getServer().isPrimaryThread()) {
|
||||
if (actors.contains(event.getPlayer())) event.setCancelled(true);
|
||||
return; // Never inspect or mutate a Bukkit view off the tick thread.
|
||||
}
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
|
||||
invalidate(view); // A new raw intent can never reuse an earlier ticket.
|
||||
try {
|
||||
Object raw = event.getPacket().getHandle();
|
||||
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|
||||
|| !(raw instanceof ServerboundContainerClickPacket packet)
|
||||
|| packet.containerInput() == null || !currentFrame(view, packet)
|
||||
|| (NativeAnvilGeneration.take(packet) && !NativeAnvilGeneration.supportedTake(packet))
|
||||
|| !owner.protocolReady(view)) {
|
||||
refuse(event, view);
|
||||
return;
|
||||
}
|
||||
// Typed reconstruction changes ONLY stateId. Native classification and predictions
|
||||
// receive every original action/input/hash field, with the menu's untouched state.
|
||||
var translated = new ServerboundContainerClickPacket(packet.containerId(), frames.get(view).nativeState(),
|
||||
packet.slotNum(), packet.buttonNum(), packet.containerInput(), packet.changedSlots(), packet.carriedItem());
|
||||
event.setPacket(new PacketContainer(event.getPacketType(), translated));
|
||||
if (!NativeAnvilGeneration.take(translated)) return;
|
||||
Admission admission = new Admission(event, translated, owner.protocolPreview(view),
|
||||
() -> !closed && !manager.isClosed() && !versions.exhausted());
|
||||
admissions.put(view, admission);
|
||||
admission.expiry = plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (admissions.get(view) == admission) admissions.remove(view);
|
||||
});
|
||||
} catch (RuntimeException | LinkageError unavailable) { refuse(event, view); }
|
||||
}
|
||||
|
||||
private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) {
|
||||
Frame frame = frames.get(view);
|
||||
var menu = NativeAnvilGeneration.menu(view);
|
||||
return !manager.isClosed() && !versions.exhausted() && frame != null && frame.valid()
|
||||
&& packet.containerId() == menu.containerId && packet.stateId() == frame.version()
|
||||
&& frame.nativeState() == menu.getStateId() && frame.preview() == owner.protocolPreview(view);
|
||||
}
|
||||
|
||||
private void seal(PacketEvent event) {
|
||||
if (closed || !plugin.getServer().isPrimaryThread()) return;
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view)) return;
|
||||
Admission admission = admissions.get(view);
|
||||
if (admission == null || admission.event != event) return;
|
||||
// MONITOR is observational. Retain the actual event for final cancellation checks both
|
||||
// at Bukkit dispatch and deferred commit, including later MONITOR listeners.
|
||||
admission.sealed = !event.isCancelled() && event.getPacket().getHandle() == admission.packet;
|
||||
if (!admission.sealed) invalidate(view);
|
||||
}
|
||||
|
||||
Admission consume(AnvilView view, Object preview, InventoryClickEvent event) {
|
||||
Admission admission = admissions.get(view);
|
||||
invalidate(view);
|
||||
if (closed || admission == null || !admission.valid() || admission.preview != preview
|
||||
|| !NativeAnvilGeneration.current(view, admission.packet)
|
||||
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
|
||||
return admission;
|
||||
}
|
||||
|
||||
private static void refuse(PacketEvent event, AnvilView view) {
|
||||
event.setCancelled(true);
|
||||
if (view.getPlayer() instanceof Player player) player.updateInventory();
|
||||
}
|
||||
|
||||
void forget(AnvilView view) {
|
||||
invalidate(view);
|
||||
watched.remove(view);
|
||||
frames.remove(view);
|
||||
if (view.getPlayer() instanceof Player player
|
||||
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) actors.remove(player);
|
||||
}
|
||||
|
||||
void prune() {
|
||||
for (AnvilView view : List.copyOf(watched)) {
|
||||
if (view.getPlayer().getOpenInventory() != view) forget(view);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (AnvilView view : List.copyOf(watched)) {
|
||||
forget(view);
|
||||
// Retiring the exact owned window prevents an old enhanced packet becoming an
|
||||
// unrelated vanilla XP take after the protocol listeners have been removed.
|
||||
if (view.getPlayer().getOpenInventory() == view) view.getPlayer().closeInventory();
|
||||
}
|
||||
actors.clear();
|
||||
seen.clear();
|
||||
manager.removePacketListener(outbound);
|
||||
manager.removePacketListener(gate);
|
||||
manager.removePacketListener(seal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
|
||||
/** Owns startup loading and durable admin updates, not plugin registration or gameplay. */
|
||||
final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
private static final String PERMISSION = "spigotinventoryhelper.admin";
|
||||
private static final List<String> KEYS = List.of(
|
||||
"advanced-anvil-base-failure-percent",
|
||||
"advanced-anvil-risk-step-percent",
|
||||
"advanced-anvil-max-failure-percent");
|
||||
private final Plugin plugin;
|
||||
private final Path file;
|
||||
private final Consumer<AdvancedAnvilPolicy> onChange;
|
||||
private final ThreadPoolExecutor writer;
|
||||
private final Object lifecycle = new Object();
|
||||
private volatile AdvancedAnvilPolicy effective;
|
||||
private volatile boolean closed;
|
||||
// Writer-only state, independent of delayed scheduler acknowledgments.
|
||||
private AdvancedAnvilPolicy persisted;
|
||||
private long savedRevision;
|
||||
// Accessed only under lifecycle, including main-thread acknowledgments.
|
||||
private long publishedRevision;
|
||||
|
||||
static AdvancedAnvilSettings open(
|
||||
Plugin plugin, Consumer<AdvancedAnvilPolicy> onAcknowledgedChange) throws IOException {
|
||||
Objects.requireNonNull(plugin);
|
||||
Objects.requireNonNull(onAcknowledgedChange);
|
||||
Path file = plugin.getDataFolder().toPath().resolve("config.yml");
|
||||
AdvancedAnvilPolicy initial = parsePolicy(read(file));
|
||||
return new AdvancedAnvilSettings(plugin, file, onAcknowledgedChange, initial);
|
||||
}
|
||||
|
||||
private AdvancedAnvilSettings(Plugin plugin, Path file,
|
||||
Consumer<AdvancedAnvilPolicy> onChange, AdvancedAnvilPolicy initial) {
|
||||
this.plugin = plugin;
|
||||
this.file = file;
|
||||
this.onChange = onChange;
|
||||
effective = initial;
|
||||
persisted = initial;
|
||||
writer = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(128), runnable -> {
|
||||
Thread thread = new Thread(runnable, "inventory-helper-config");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}, new ThreadPoolExecutor.AbortPolicy());
|
||||
}
|
||||
|
||||
AdvancedAnvilPolicy policy() {
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** Call on enable with this plugin's actual inventoryhelperadmin command. */
|
||||
void bind(PluginCommand command) {
|
||||
Objects.requireNonNull(command);
|
||||
if (command.getPlugin() != plugin || !command.getName().equals("inventoryhelperadmin")) {
|
||||
throw new IllegalArgumentException("Expected this plugin's inventoryhelperadmin command");
|
||||
}
|
||||
synchronized (lifecycle) {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Settings closed");
|
||||
}
|
||||
command.setPermission(PERMISSION);
|
||||
command.permissionMessage(Component.text("Permission denied."));
|
||||
command.setExecutor((sender, ignored, label, args) -> execute(sender, args));
|
||||
command.setTabCompleter((sender, ignored, label, args) -> complete(sender, args));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean execute(CommandSender sender, String[] args) {
|
||||
// Revalidate even if the caller bypasses Command.testPermission.
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("Permission denied.");
|
||||
return true;
|
||||
}
|
||||
if (closed) {
|
||||
sender.sendMessage("Configuration unavailable.");
|
||||
return true;
|
||||
}
|
||||
if (args.length < 2 || args.length > 3 || !args[0].equals("config")
|
||||
|| !KEYS.contains(args[1])) {
|
||||
sender.sendMessage("Usage: /inventoryhelperadmin config <key> [value]");
|
||||
return true;
|
||||
}
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + " = " + value(effective, key));
|
||||
return true;
|
||||
}
|
||||
int percent;
|
||||
try {
|
||||
// Parse integers rather than restricting users to suggested spellings.
|
||||
// No trimming or floating-point/YAML coercion; range validation remains mandatory.
|
||||
percent = Integer.parseInt(args[2]);
|
||||
if (percent < 0 || percent > 100) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Value must be an integer from 0 through 100.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
writer.execute(() -> save(sender, key, percent));
|
||||
} catch (RejectedExecutionException unavailable) {
|
||||
sender.sendMessage("Configuration unavailable; no update accepted.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private List<String> complete(CommandSender sender, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION) || args.length > 3) {
|
||||
return List.of();
|
||||
}
|
||||
if (args.length <= 1) {
|
||||
return prefix(List.of("config"), args.length == 0 ? "" : args[0]);
|
||||
}
|
||||
if (!args[0].equals("config")) {
|
||||
return List.of();
|
||||
}
|
||||
if (args.length == 2) {
|
||||
return prefix(KEYS, args[1]);
|
||||
}
|
||||
if (!KEYS.contains(args[1])) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<String> candidates = new LinkedHashSet<>();
|
||||
candidates.add(Integer.toString(value(effective, args[1])));
|
||||
candidates.add(Integer.toString(value(AdvancedAnvilPolicy.defaults(), args[1])));
|
||||
candidates.add("0");
|
||||
candidates.add("100");
|
||||
return prefix(List.copyOf(candidates), args[2]);
|
||||
}
|
||||
|
||||
private static List<String> prefix(List<String> candidates, String prefix) {
|
||||
String lower = prefix.toLowerCase(Locale.ROOT);
|
||||
return candidates.stream().filter(value -> value.startsWith(lower)).toList();
|
||||
}
|
||||
|
||||
private void save(CommandSender sender, String key, int percent) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<Object, Object> document = read(file);
|
||||
parsePolicy(document); // Refuse a malformed intervening file, without clobbering it.
|
||||
AdvancedAnvilPolicy next = replace(persisted, key, percent);
|
||||
for (String setting : KEYS) {
|
||||
document.put(setting, value(next, setting));
|
||||
}
|
||||
writeAtomically(document);
|
||||
persisted = next;
|
||||
long revision = ++savedRevision;
|
||||
schedule(() -> acknowledge(sender, key, next, revision));
|
||||
} catch (IOException | YAMLException failure) {
|
||||
schedule(() -> {
|
||||
synchronized (lifecycle) {
|
||||
if (!closed && sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("Configuration save failed; effective settings unchanged.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Runnable acknowledgment) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, acknowledgment);
|
||||
} catch (RuntimeException unavailable) {
|
||||
// A disabled/rejected scheduler cannot safely publish gameplay state off-thread.
|
||||
plugin.getLogger().warning("Configuration acknowledgment unavailable; restart reloads disk settings.");
|
||||
}
|
||||
}
|
||||
|
||||
private void acknowledge(CommandSender sender, String key, AdvancedAnvilPolicy next, long revision) {
|
||||
synchronized (lifecycle) {
|
||||
if (closed || revision <= publishedRevision) {
|
||||
return;
|
||||
}
|
||||
publishedRevision = revision;
|
||||
effective = next;
|
||||
try {
|
||||
onChange.accept(next);
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.getLogger().warning("Configuration saved but policy-change callback failed.");
|
||||
}
|
||||
if (!closed && sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage(key + " = " + value(next, key) + " (saved)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeAtomically(Map<Object, Object> document) throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Path temporary = Files.createTempFile(file.getParent(), ".config-", ".tmp");
|
||||
try {
|
||||
DumperOptions options = new DumperOptions();
|
||||
options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
byte[] bytes = new Yaml(options).dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
if (closed) {
|
||||
throw new IOException("Settings closed");
|
||||
}
|
||||
// No non-atomic fallback: an unsupported atomic replacement is a failed save.
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
// Publication itself must be durable, not just the temporary file's contents.
|
||||
try (FileChannel directory = FileChannel.open(file.getParent(), StandardOpenOption.READ)) {
|
||||
directory.force(true);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupFailure) {
|
||||
plugin.getLogger().warning("Could not remove configuration temporary file.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<Object, Object> read(Path file) throws IOException {
|
||||
if (Files.notExists(file)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
LoaderOptions options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
|
||||
Object loaded = new Yaml(new SafeConstructor(options)).load(reader);
|
||||
if (loaded == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
if (!(loaded instanceof Map<?, ?> values)) {
|
||||
throw new IOException("Configuration must be a YAML mapping");
|
||||
}
|
||||
return new LinkedHashMap<>(values);
|
||||
} catch (YAMLException invalid) {
|
||||
throw new IOException("Malformed configuration", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvancedAnvilPolicy parsePolicy(Map<Object, Object> document) throws IOException {
|
||||
AdvancedAnvilPolicy defaults = AdvancedAnvilPolicy.defaults();
|
||||
int[] values = new int[3];
|
||||
for (int index = 0; index < KEYS.size(); index++) {
|
||||
String key = KEYS.get(index);
|
||||
Object raw = document.get(key);
|
||||
if (!document.containsKey(key)) {
|
||||
values[index] = value(defaults, key);
|
||||
} else if ((raw instanceof Integer || raw instanceof Long)
|
||||
&& ((Number) raw).longValue() >= 0 && ((Number) raw).longValue() <= 100) {
|
||||
values[index] = ((Number) raw).intValue();
|
||||
} else {
|
||||
throw new IOException("Invalid integer percent for " + key);
|
||||
}
|
||||
}
|
||||
return new AdvancedAnvilPolicy(values[0], values[1], values[2]);
|
||||
}
|
||||
|
||||
private static int value(AdvancedAnvilPolicy policy, String key) {
|
||||
return switch (KEYS.indexOf(key)) {
|
||||
case 0 -> policy.basePercent();
|
||||
case 1 -> policy.stepPercent();
|
||||
case 2 -> policy.maxFailurePercent();
|
||||
default -> throw new IllegalArgumentException("Unsupported setting");
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvancedAnvilPolicy replace(AdvancedAnvilPolicy policy, String key, int percent) {
|
||||
return new AdvancedAnvilPolicy(
|
||||
key.equals(KEYS.get(0)) ? percent : policy.basePercent(),
|
||||
key.equals(KEYS.get(1)) ? percent : policy.stepPercent(),
|
||||
key.equals(KEYS.get(2)) ? percent : policy.maxFailurePercent());
|
||||
}
|
||||
|
||||
/** Nonblocking close: drop queued work, interrupt the daemon writer, suppress every late ack. */
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lifecycle) {
|
||||
closed = true;
|
||||
writer.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
/** Positive VarInt frame IDs outside the native 15-bit range; never wrap or recycle. */
|
||||
final class AnvilFrameVersions {
|
||||
private long next = 32768;
|
||||
private boolean exhausted;
|
||||
|
||||
int next() {
|
||||
if (next > Integer.MAX_VALUE) {
|
||||
exhausted = true;
|
||||
return -1;
|
||||
}
|
||||
return (int) next++;
|
||||
}
|
||||
|
||||
boolean exhausted() { return exhausted; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** Independently owns the fixed, native String-to-Cobweb crafting recipe. */
|
||||
public final class CobwebRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public CobwebRecipeRegistrar(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;
|
||||
}
|
||||
ShapedRecipe recipe = new ShapedRecipe(recipeKey, new ItemStack(Material.COBWEB));
|
||||
recipe.shape("S S", " S ", "S S");
|
||||
recipe.setIngredient('S', Material.STRING);
|
||||
recipe.setCategory(CraftingBookCategory.BUILDING);
|
||||
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;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Cobweb crafting recipe";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
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;
|
||||
|
||||
public final class DispenserRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private final Supplier<ItemStack> resultFactory;
|
||||
private final RecipeFactory recipeFactory;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public DispenserRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this(recipeKey, () -> new ItemStack(Material.DISPENSER), ShapelessRecipe::new);
|
||||
}
|
||||
|
||||
DispenserRecipeRegistrar(
|
||||
NamespacedKey recipeKey,
|
||||
Supplier<ItemStack> resultFactory,
|
||||
RecipeFactory recipeFactory
|
||||
) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
this.resultFactory = Objects.requireNonNull(resultFactory, "resultFactory");
|
||||
this.recipeFactory = Objects.requireNonNull(recipeFactory, "recipeFactory");
|
||||
}
|
||||
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ItemStack result = resultFactory.get();
|
||||
ShapelessRecipe recipe = recipeFactory.create(recipeKey, result);
|
||||
recipe.addIngredient(Material.DROPPER);
|
||||
recipe.addIngredient(Material.TRIPWIRE_HOOK);
|
||||
recipe.setCategory(CraftingBookCategory.REDSTONE);
|
||||
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);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
if (!registeredServer.removeRecipe(recipeKey)) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "alternative Dispenser recipe";
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface RecipeFactory {
|
||||
ShapelessRecipe create(NamespacedKey key, ItemStack result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
import net.minecraft.world.inventory.AnvilMenu;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.craftbukkit.inventory.view.CraftAnvilView;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
|
||||
/** Compile-only linkage to the checksum-pinned Purpur 26.2 build 2618 menu/packet API. */
|
||||
final class NativeAnvilGeneration {
|
||||
private NativeAnvilGeneration() {}
|
||||
|
||||
static AnvilMenu menu(AnvilView view) {
|
||||
if (!(view instanceof CraftAnvilView craft)) throw new IllegalStateException("Unsupported native anvil view");
|
||||
if (!(craft.getHandle() instanceof AnvilMenu menu)) throw new IllegalStateException("Unsupported native anvil menu");
|
||||
return menu;
|
||||
}
|
||||
|
||||
/** Ensure CraftEventFactory's post-event broadcast emits the final installed result.
|
||||
* Quantity/risk-only changes may otherwise be skipped by native result diffing.
|
||||
* Invalidate only the owned result's remote prediction, never an actual item or input.
|
||||
*/
|
||||
static void requireResultFrame(AnvilView view, boolean emptyOutput) {
|
||||
var menu = menu(view);
|
||||
menu.setRemoteSlot(2, emptyOutput ? menu.getSlot(0).getItem().copy()
|
||||
: net.minecraft.world.item.ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
/** Publish after native preparation has installed the final result, even when diffing skips it. */
|
||||
static void publish(AnvilView view) {
|
||||
menu(view).sendAllDataToRemote();
|
||||
}
|
||||
|
||||
static boolean current(AnvilView view, ServerboundContainerClickPacket packet) {
|
||||
var menu = menu(view);
|
||||
return packet.containerId() == menu.containerId && packet.stateId() == menu.getStateId();
|
||||
}
|
||||
|
||||
static boolean take(ServerboundContainerClickPacket packet) {
|
||||
return packet.slotNum() == 2 || packet.containerInput() == ContainerInput.PICKUP_ALL;
|
||||
}
|
||||
|
||||
static boolean supportedTake(ServerboundContainerClickPacket packet) {
|
||||
return click(packet) != ClickType.UNKNOWN;
|
||||
}
|
||||
|
||||
private static ClickType click(ServerboundContainerClickPacket packet) {
|
||||
int button = packet.buttonNum();
|
||||
return switch (packet.containerInput()) {
|
||||
case PICKUP -> button == 0 ? ClickType.LEFT : button == 1 ? ClickType.RIGHT : ClickType.UNKNOWN;
|
||||
case QUICK_MOVE -> button == 0 ? ClickType.SHIFT_LEFT : button == 1 ? ClickType.SHIFT_RIGHT : ClickType.UNKNOWN;
|
||||
case SWAP -> button == 40 ? ClickType.SWAP_OFFHAND
|
||||
: button >= 0 && button <= 8 ? ClickType.NUMBER_KEY : ClickType.UNKNOWN;
|
||||
case CLONE -> button == 2 ? ClickType.MIDDLE : ClickType.UNKNOWN;
|
||||
case THROW -> button == 0 ? ClickType.DROP : button == 1 ? ClickType.CONTROL_DROP : ClickType.UNKNOWN;
|
||||
case PICKUP_ALL -> button == 0 ? ClickType.DOUBLE_CLICK : ClickType.UNKNOWN;
|
||||
case QUICK_CRAFT -> ClickType.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
static boolean matches(ServerboundContainerClickPacket packet, InventoryClickEvent event) {
|
||||
if (packet.slotNum() != event.getRawSlot()) return false;
|
||||
ClickType click = click(packet);
|
||||
return click != ClickType.UNKNOWN && event.getClick() == click
|
||||
&& (click != ClickType.NUMBER_KEY || event.getHotbarButton() == packet.buttonNum());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final List<NamespacedKey> recipeKeys;
|
||||
private final String description;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, Logger logger) {
|
||||
this(recipeKeys, "recipes", logger);
|
||||
}
|
||||
|
||||
public RecipeDiscovery(List<NamespacedKey> recipeKeys, String description, Logger logger) {
|
||||
this.recipeKeys = List.copyOf(recipeKeys);
|
||||
this.description = Objects.requireNonNull(description, "description");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
players.forEach(this::discover);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
try {
|
||||
List<NamespacedKey> unknown = recipeKeys.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (unknown.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
player.discoverRecipes(unknown);
|
||||
List<NamespacedKey> stillUnknown = unknown.stream()
|
||||
.filter(key -> !player.hasDiscoveredRecipe(key))
|
||||
.toList();
|
||||
if (!stillUnknown.isEmpty()) {
|
||||
logger.warning("Could not discover " + description + " for "
|
||||
+ player.getName() + ": " + stillUnknown);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
logger.log(Level.WARNING,
|
||||
"Could not discover " + description + " for " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class RecipeLifecycle {
|
||||
private final Server server;
|
||||
private final Plugin plugin;
|
||||
private final RecipeRegistrar recipes;
|
||||
private final Logger logger;
|
||||
private RecipeDiscovery discovery;
|
||||
|
||||
public RecipeLifecycle(
|
||||
Server server,
|
||||
Plugin plugin,
|
||||
RecipeRegistrar recipes,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
this.recipes = Objects.requireNonNull(recipes, "recipes");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public boolean enable() {
|
||||
if (discovery != null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (!recipes.register(server)) {
|
||||
logger.severe(recipes.description()
|
||||
+ " were not registered; discovery is disabled.");
|
||||
return false;
|
||||
}
|
||||
List<? extends Player> onlinePlayers = List.copyOf(server.getOnlinePlayers());
|
||||
discovery = new RecipeDiscovery(
|
||||
recipes.recipeKeys(), recipes.description(), logger);
|
||||
server.getPluginManager().registerEvents(discovery, plugin);
|
||||
discovery.discoverAll(onlinePlayers);
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
unregisterDiscovery();
|
||||
try {
|
||||
recipes.unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
logger.log(Level.SEVERE,
|
||||
recipes.description() + " were not registered; discovery is disabled.",
|
||||
exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
unregisterDiscovery();
|
||||
recipes.unregister();
|
||||
}
|
||||
|
||||
private void unregisterDiscovery() {
|
||||
if (discovery != null) {
|
||||
HandlerList.unregisterAll(discovery);
|
||||
discovery = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class RecipeLifecycleGroup {
|
||||
private final List<RecipeLifecycle> lifecycles;
|
||||
|
||||
public RecipeLifecycleGroup(List<RecipeLifecycle> lifecycles) {
|
||||
this.lifecycles = List.copyOf(lifecycles);
|
||||
}
|
||||
|
||||
public void enableAll() {
|
||||
lifecycles.forEach(RecipeLifecycle::enable);
|
||||
}
|
||||
|
||||
public void disableAll() {
|
||||
RuntimeException failure = null;
|
||||
for (int index = lifecycles.size() - 1; index >= 0; index--) {
|
||||
try {
|
||||
lifecycles.get(index).disable();
|
||||
} catch (RuntimeException exception) {
|
||||
if (failure == null) {
|
||||
failure = exception;
|
||||
} else {
|
||||
failure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
|
||||
public interface RecipeRegistrar {
|
||||
boolean register(Server server);
|
||||
|
||||
List<NamespacedKey> recipeKeys();
|
||||
|
||||
void unregister();
|
||||
|
||||
String description();
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
public final class SortingStickRecipeRegistrar {
|
||||
public final class SortingStickRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private final SortingStickItemFactory itemFactory;
|
||||
private final SortingStickRecipeSpec recipeSpec;
|
||||
private final RecipeFactory recipeFactory;
|
||||
private final List<NamespacedKey> ownedRecipeKeys = new ArrayList<>();
|
||||
private Server registeredServer;
|
||||
|
||||
public SortingStickRecipeRegistrar(
|
||||
NamespacedKey recipeKey,
|
||||
@@ -36,29 +41,100 @@ public final class SortingStickRecipeRegistrar {
|
||||
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
boolean registered = true;
|
||||
if (!ownedRecipeKeys.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
for (Material storage : List.of(Material.CHEST, Material.BARREL)) {
|
||||
ShapedRecipe recipe = createRecipe(storage);
|
||||
registered = server.addRecipe(recipe) && registered;
|
||||
NamespacedKey key = variantKey(storage);
|
||||
if (server.getRecipe(key) != null) {
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!server.addRecipe(recipe)) {
|
||||
rollback();
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
if (server.getRecipe(key) != null) {
|
||||
ownedRecipeKeys.add(key);
|
||||
}
|
||||
rollback();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
ownedRecipeKeys.add(key);
|
||||
}
|
||||
return registered;
|
||||
return true;
|
||||
}
|
||||
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(variantKey(Material.CHEST), variantKey(Material.BARREL));
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
rollback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Sorting Stick recipes";
|
||||
}
|
||||
|
||||
private void rollback() {
|
||||
if (registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
RuntimeException failure = null;
|
||||
for (NamespacedKey key : List.copyOf(ownedRecipeKeys)) {
|
||||
try {
|
||||
if (registeredServer.removeRecipe(key)) {
|
||||
ownedRecipeKeys.remove(key);
|
||||
} else {
|
||||
failure = addFailure(failure,
|
||||
new IllegalStateException("Could not remove owned recipe " + key));
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
failure = addFailure(failure, exception);
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeException addFailure(RuntimeException current, RuntimeException next) {
|
||||
if (current == null) {
|
||||
return next;
|
||||
}
|
||||
current.addSuppressed(next);
|
||||
return current;
|
||||
}
|
||||
|
||||
private ShapedRecipe createRecipe(Material storage) {
|
||||
ItemStack result = itemFactory.create();
|
||||
result.setAmount(recipeSpec.outputAmount());
|
||||
NamespacedKey variantKey = new NamespacedKey(
|
||||
recipeKey.getNamespace(),
|
||||
recipeKey.getKey() + "_" + storage.name().toLowerCase(java.util.Locale.ROOT)
|
||||
);
|
||||
ShapedRecipe recipe = recipeFactory.create(variantKey, result);
|
||||
ShapedRecipe recipe = recipeFactory.create(variantKey(storage), result);
|
||||
recipe.shape(recipeSpec.shape().toArray(String[]::new));
|
||||
recipe.setIngredient('C', storage);
|
||||
recipe.setIngredient('R', Material.REDSTONE);
|
||||
recipe.setIngredient('S', Material.STICK);
|
||||
recipe.setCategory(CraftingBookCategory.EQUIPMENT);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private NamespacedKey variantKey(Material storage) {
|
||||
return new NamespacedKey(
|
||||
recipeKey.getNamespace(),
|
||||
recipeKey.getKey() + "_" + storage.name().toLowerCase(Locale.ROOT)
|
||||
);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface RecipeFactory {
|
||||
ShapedRecipe create(NamespacedKey key, ItemStack result);
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
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;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
|
||||
public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private SortingStickItemFactory sortingSticks;
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
private AdvancedAnvilSettings advancedAnvilSettings;
|
||||
private AdvancedAnvilListener advancedAnvil;
|
||||
private java.util.function.IntSupplier anvilRisk = () -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100);
|
||||
private java.util.function.DoubleSupplier anvilWear = () -> java.util.concurrent.ThreadLocalRandom.current().nextDouble();
|
||||
|
||||
private java.util.function.Supplier<com.comphenix.protocol.ProtocolManager> anvilProtocol =
|
||||
com.comphenix.protocol.ProtocolLibrary::getProtocolManager;
|
||||
private java.util.function.Supplier<String> anvilVersion =
|
||||
() -> getServer().getName() + " " + getServer().getBukkitVersion();
|
||||
|
||||
public SpigotInventoryHelperPlugin() {}
|
||||
|
||||
// Bukkit's explicit initialization path keeps native tests on the real enable callback.
|
||||
SpigotInventoryHelperPlugin(
|
||||
JavaPluginLoader loader, PluginDescriptionFile description, File dataFolder, File file
|
||||
) {
|
||||
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() {
|
||||
@@ -14,9 +55,23 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
sortingSticks,
|
||||
SortingStickRecipeSpec.defaultRecipe()
|
||||
);
|
||||
if (!recipes.register(getServer())) {
|
||||
getLogger().warning("One or more Sorting Stick recipes could not be registered.");
|
||||
}
|
||||
RecipeLifecycle sortingRecipes = new RecipeLifecycle(
|
||||
getServer(), this, recipes, getLogger());
|
||||
RecipeLifecycle dispenserRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(
|
||||
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new SortingStickListener(sortingSticks, sorting),
|
||||
@@ -26,9 +81,45 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
new BrokenToolListener(this, new ToolReplacementService()),
|
||||
this
|
||||
);
|
||||
try {
|
||||
advancedAnvilSettings = AdvancedAnvilSettings.open(this, ignored -> {
|
||||
if (advancedAnvil != null) {
|
||||
advancedAnvil.policyChanged();
|
||||
}
|
||||
});
|
||||
advancedAnvilSettings.bind(java.util.Objects.requireNonNull(
|
||||
getCommand("inventoryhelperadmin"), "Missing inventoryhelperadmin command"));
|
||||
advancedAnvil = new AdvancedAnvilListener(this, advancedAnvilSettings::policy, anvilRisk, anvilWear,
|
||||
anvilProtocol.get(), anvilVersion.get());
|
||||
getServer().getPluginManager().registerEvents(advancedAnvil, this);
|
||||
} catch (IOException | RuntimeException | LinkageError failure) {
|
||||
closeAdvancedAnvil();
|
||||
getLogger().log(java.util.logging.Level.SEVERE,
|
||||
"Advanced Anvil unavailable; configuration or initialization failed.", failure);
|
||||
}
|
||||
getLogger().info("Spigot Inventory Helper enabled.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
closeAdvancedAnvil();
|
||||
if (recipeLifecycles != null) {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void closeAdvancedAnvil() {
|
||||
if (advancedAnvil != null) {
|
||||
advancedAnvil.close();
|
||||
advancedAnvil = null;
|
||||
}
|
||||
if (advancedAnvilSettings != null) {
|
||||
advancedAnvilSettings.close();
|
||||
advancedAnvilSettings = null;
|
||||
}
|
||||
}
|
||||
|
||||
SortingStickItemFactory sortingSticks() {
|
||||
if (sortingSticks == null) {
|
||||
throw new IllegalStateException("Sorting Stick service is unavailable");
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Advanced Anvil: integer percentages, inclusive 0–100.
|
||||
# E is the greatest resulting enchantment excess above its native maximum.
|
||||
# E=0: no failure; otherwise min(cap, base + step * E * (E - 1) / 2).
|
||||
advanced-anvil-base-failure-percent: 20
|
||||
advanced-anvil-risk-step-percent: 5
|
||||
advanced-anvil-max-failure-percent: 99
|
||||
@@ -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
|
||||
+423
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Packet -> native classification -> registered plugin -> untouched native phase -> deferred commit. */
|
||||
class NativeAdvancedAnvilPacketTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void zeroXpSurvivalPickupReachesNativeEventAndPaysExactlyOnceOnTick() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
|
||||
f.inputs(left, left.clone());
|
||||
assertEquals(0, f.menu.getCost());
|
||||
assertTrue(f.menu.getSlot(2).mayPickup(f.nativePlayer), "Real zero-XP survival result must be takeable");
|
||||
ItemStack expected = f.output().clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size(), "Handler must dispatch a registered Bukkit event");
|
||||
assertEquals(InventoryAction.PICKUP_ALL, packet.clicks.getFirst().getAction());
|
||||
assertEquals(ClickType.LEFT, packet.clicks.getFirst().getClick());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled(), "Gate empties output, not fabricated cancellation");
|
||||
assertTrue(f.menu.getSlot(2).getItem().isEmpty());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get(), "No native-phase roll/payment");
|
||||
assertEquals(1, f.nextTick.size());
|
||||
f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
|
||||
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(0, f.nativePlayer.experienceLevel);
|
||||
f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(6, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shiftQuickMoveAndRepeatBeforeTickCommitOnlyOneOutput() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
ItemStack marker = new ItemStack(Material.EMERALD, 17);
|
||||
f.player.getInventory().setItem(4, marker);
|
||||
ItemStack[] before = storage(f);
|
||||
packet.click(2, 0, ContainerInput.QUICK_MOVE);
|
||||
packet.click(2, 0, ContainerInput.QUICK_MOVE);
|
||||
assertEquals(1, packet.clicks.size(), "Pending repeat is refused before native Bukkit dispatch");
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertEquals(InventoryAction.MOVE_TO_OTHER_INVENTORY, packet.clicks.getFirst().getAction());
|
||||
assertEquals(ClickType.SHIFT_LEFT, packet.clicks.getFirst().getClick());
|
||||
assertGated(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(1, f.nextTick.size(), "Repeat packet cannot reserve a second payment");
|
||||
f.tick();
|
||||
assertPaid(f, rolls);
|
||||
assertEquals(expected, f.player.getInventory().getItem(8), "Native reverse hotbar destination");
|
||||
assertEquals(marker, f.player.getInventory().getItem(4));
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(1, countOutput(f, expected));
|
||||
f.tick();
|
||||
assertEquals(1, countOutput(f, expected));
|
||||
assertEquals(1, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void numberKeyDeliversOnceToEmptyHotbarSlotWithoutChangingUnrelatedItem() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
ItemStack marker = new ItemStack(Material.EMERALD, 17);
|
||||
f.player.getInventory().setItem(5, marker);
|
||||
ItemStack[] before = storage(f);
|
||||
packet.click(2, 4, ContainerInput.SWAP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertEquals(InventoryAction.HOTBAR_SWAP, packet.clicks.getFirst().getAction());
|
||||
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
|
||||
assertEquals(4, packet.clicks.getFirst().getHotbarButton());
|
||||
assertGated(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
f.tick();
|
||||
assertPaid(f, rolls);
|
||||
assertEquals(expected, f.player.getInventory().getItem(4));
|
||||
assertEquals(marker, f.player.getInventory().getItem(5));
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(1, countOutput(f, expected));
|
||||
f.tick();
|
||||
assertEquals(1, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void occupiedHotbarNumberKeyRetainsNativeNothingClassificationAndConsumesNothing() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
ItemStack marker = new ItemStack(Material.EMERALD, 17);
|
||||
f.player.getInventory().setItem(4, marker);
|
||||
ItemStack[] before = storage(f);
|
||||
packet.click(2, 4, ContainerInput.SWAP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertEquals(InventoryAction.NOTHING, packet.clicks.getFirst().getAction(),
|
||||
"Native result slot may not accept the existing hotbar item");
|
||||
assertEquals(ClickType.NUMBER_KEY, packet.clicks.getFirst().getClick());
|
||||
assertGated(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(expected, f.output());
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void finalHighestNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
|
||||
finalCancellation(EventPriority.HIGHEST);
|
||||
}
|
||||
|
||||
@Test void finalMonitorNativeCancellationConsumesNothingAndDoesNotRoll() throws Exception {
|
||||
finalCancellation(EventPriority.MONITOR);
|
||||
}
|
||||
|
||||
private void finalCancellation(EventPriority priority) throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
packet.listen(priority, event -> {
|
||||
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Cancellation runs after plugin's gate");
|
||||
assertEquals(InventoryAction.PICKUP_ALL, event.getAction());
|
||||
event.setCancelled(true);
|
||||
});
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertTrue(packet.clicks.getFirst().isCancelled(), "Final native event, not substituted event");
|
||||
assertGated(f, rolls, null);
|
||||
assertEquals(1, f.nextTick.size());
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertEquals(expected, f.output(), "Denied native intent restores preview without payment");
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void creativeCloneCannotCopyUnpaidEnhancedResult() throws Exception {
|
||||
unsupportedResult(ContainerInput.CLONE, 2, ClickType.MIDDLE, InventoryAction.CLONE_STACK, true);
|
||||
}
|
||||
|
||||
@Test void dropOneCannotEmitOrPayForEnhancedResult() throws Exception {
|
||||
unsupportedResult(ContainerInput.THROW, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT, false);
|
||||
}
|
||||
|
||||
@Test void dropStackCannotEmitOrPayForEnhancedResult() throws Exception {
|
||||
unsupportedResult(ContainerInput.THROW, 1, ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT, false);
|
||||
}
|
||||
|
||||
private void unsupportedResult(ContainerInput input, int button, ClickType click,
|
||||
InventoryAction action, boolean creative) throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
if (creative) packet.creative();
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
ItemStack[] before = storage(f);
|
||||
packet.click(2, button, input);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertEquals(click, packet.clicks.getFirst().getClick());
|
||||
assertEquals(action, packet.clicks.getFirst().getAction());
|
||||
assertGated(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(1, f.nextTick.size());
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(expected, f.output());
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never())
|
||||
.drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class),
|
||||
org.mockito.ArgumentMatchers.anyBoolean());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void doubleClickOutsideResultCannotCollectUnpaidPreviewIntoMatchingCursor() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack carried = f.output().clone();
|
||||
f.player.setItemOnCursor(carried.clone());
|
||||
ItemStack[] before = storage(f);
|
||||
packet.click(3, 0, ContainerInput.PICKUP_ALL); // empty native inventory slot, not result
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertEquals(3, packet.clicks.getFirst().getRawSlot());
|
||||
assertEquals(InventoryAction.COLLECT_TO_CURSOR, packet.clicks.getFirst().getAction());
|
||||
assertEquals(ClickType.DOUBLE_CLICK, packet.clicks.getFirst().getClick());
|
||||
assertGated(f, rolls, carried);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(1, f.nextTick.size(), "Outside-result collection still passes through enhanced gate");
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, carried);
|
||||
assertArrayEquals(before, storage(f));
|
||||
assertEquals(carried, f.output());
|
||||
assertEquals(1, countOutput(f, carried), "Only the pre-existing cursor item remains");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void mismatchedContainerIdIsRefusedByNativeHandlerBeforeEventAndGate() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack preview = f.output().clone();
|
||||
packet.click(f.menu.containerId + 1, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertTrue(f.nextTick.isEmpty());
|
||||
assertEquals(preview, f.output(), "Native container-ID guard precedes the registered gate");
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertEquals(preview, f.output());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void stateIdMismatchRefusesBeforeDispatchOrPayment() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
packet.packets.clear();
|
||||
packet.click(f.menu.containerId, -1, 2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(0, packet.clicks.size(), "Approved A refuses the old native resync-but-accept path");
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertTrue(packet.packets.stream().anyMatch(
|
||||
net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance),
|
||||
"Real native synchronizer emits a full resync through the connection sink");
|
||||
assertEquals(expected, f.output());
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertEquals(expected, f.output());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inactiveViewBeforeDeferredCommitCannotPayOldMenu() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertGated(f, rolls, null);
|
||||
packet.inactiveView();
|
||||
assertSame(packet.inventoryMenu.getBukkitView(), f.player.getOpenInventory());
|
||||
packet.click(f.menu.containerId, f.menu.getStateId(), 2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size(), "Old container packet cannot reach anvil after native menu replacement");
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, null);
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
assertFalse(f.menu.bypassCost, "Inactive view releases owned exemption");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void destinationChangeAfterNativeGateInvalidatesPaymentWithoutOverwritingCursor() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = fixture(rolls); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
ItemStack expected = f.output().clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertGated(f, rolls, null);
|
||||
ItemStack marker = new ItemStack(Material.EMERALD, 17);
|
||||
f.player.setItemOnCursor(marker);
|
||||
f.tick();
|
||||
assertUnpaid(f, rolls, marker);
|
||||
assertEquals(expected, f.output());
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
}
|
||||
}
|
||||
|
||||
private NativeAnvilFixture fixture(AtomicInteger rolls) throws Exception {
|
||||
return new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
}
|
||||
|
||||
private static void seed(NativeAnvilFixture f) {
|
||||
ItemStack input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
|
||||
f.inputs(input, input.clone());
|
||||
}
|
||||
|
||||
private static void assertGated(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
|
||||
assertTrue(f.menu.getSlot(2).getItem().isEmpty(), "Real native result is gated");
|
||||
assertUnpaid(f, rolls, cursor);
|
||||
}
|
||||
|
||||
private static void assertUnpaid(NativeAnvilFixture f, AtomicInteger rolls, ItemStack cursor) {
|
||||
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
|
||||
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
if (cursor == null) assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
else assertEquals(cursor, f.player.getItemOnCursor());
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(0, f.nativePlayer.experienceLevel);
|
||||
}
|
||||
|
||||
private static void assertPaid(NativeAnvilFixture f, AtomicInteger rolls) {
|
||||
ItemStack expected = NativeAdvancedAnvilTransactionTest.stackedSword(5, 2);
|
||||
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(expected, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(0, f.nativePlayer.experienceLevel);
|
||||
}
|
||||
|
||||
private static ItemStack[] storage(NativeAnvilFixture f) {
|
||||
return java.util.Arrays.stream(f.player.getInventory().getStorageContents())
|
||||
.map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
private static int countOutput(NativeAnvilFixture f, ItemStack output) {
|
||||
int count = java.util.Arrays.stream(f.player.getInventory().getContents())
|
||||
.filter(item -> item != null && item.isSimilar(output)).mapToInt(ItemStack::getAmount).sum();
|
||||
ItemStack cursor = f.player.getItemOnCursor();
|
||||
return count + (cursor.isSimilar(output) ? cursor.getAmount() : 0);
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.ListenerOptions;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NativeAdvancedAnvilProtocolTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void enableRegistersSynchronousDeclaredLibraryListenerAndRemovesOnlyOwnedListeners() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory)) {
|
||||
assertFalse(f.protocolListeners.isEmpty(), "Real enable must register ProtocolLib adapter");
|
||||
for (var listener : f.protocolListeners) {
|
||||
assertSame(f.plugin, listener.getPlugin());
|
||||
if (!listener.getReceivingWhitelist().getTypes().isEmpty()) {
|
||||
assertEquals(java.util.Set.of(PacketType.Play.Client.WINDOW_CLICK), listener.getReceivingWhitelist().getTypes());
|
||||
assertTrue(listener.getReceivingWhitelist().getOptions().contains(ListenerOptions.SYNC));
|
||||
} else {
|
||||
assertEquals(java.util.Set.of(PacketType.Play.Server.WINDOW_ITEMS, PacketType.Play.Server.SET_SLOT),
|
||||
listener.getSendingWhitelist().getTypes());
|
||||
assertTrue(listener.getSendingWhitelist().getOptions().contains(ListenerOptions.SYNC));
|
||||
}
|
||||
}
|
||||
var foreign = org.mockito.Mockito.mock(com.comphenix.protocol.events.PacketListener.class);
|
||||
f.protocolListeners.add(foreign);
|
||||
f.plugin.setEnabled(false);
|
||||
assertEquals(java.util.List.of(foreign), f.protocolListeners);
|
||||
org.mockito.Mockito.verify(f.protocol, org.mockito.Mockito.never()).removePacketListeners(org.mockito.ArgumentMatchers.any());
|
||||
f.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsupportedBukkitTakeWithoutRawAdmissionRefusesAndConserves() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
|
||||
seed(f);
|
||||
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
var event = new InventoryClickEvent(f.menu.getBukkitView(), InventoryType.SlotType.RESULT,
|
||||
2, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
f.events.callEvent(event);
|
||||
assertTrue(event.isCancelled(), "Missing client generation must fail closed");
|
||||
f.tick();
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownPayloadCancelsAndNativeResynchronizesBeforeAnyBukkitClick() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
packet.packets.clear();
|
||||
var event = f.receive(new Object());
|
||||
assertTrue(event.isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void policyAndInputsChangedBetweenLibraryAdmissionAndNativeBukkitDispatchCannotRepurposeTicket() throws Exception {
|
||||
for (boolean policyOnly : new boolean[] {true, false}) {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory.resolve(Boolean.toString(policyOnly)),
|
||||
() -> { rolls.incrementAndGet(); return 50; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
|
||||
var admitted = f.receive(raw);
|
||||
assertFalse(admitted.isCancelled());
|
||||
if (policyOnly) {
|
||||
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
|
||||
// The admission expiry is deliberately pending: wait for the ACTUAL save
|
||||
// acknowledgment, not accidentally treating ticket expiry as policy proof.
|
||||
Runnable expiry = f.nextTick.remove();
|
||||
f.acknowledgeSave();
|
||||
expiry.run();
|
||||
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
|
||||
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
var right = f.menu.getBukkitView().getTopInventory().getItem(1).clone();
|
||||
packet.handler.handleContainerClick((net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
|
||||
admitted.getPacket().getHandle());
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertTrue(packet.clicks.getFirst().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(right, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ticketCannotAuthorizeDifferentSlotButtonOrInputAndExpiresIfNativeDispatchIsLost() throws Exception {
|
||||
for (String mode : java.util.List.of("slot", "button", "input", "lost")) {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var input = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
assertFalse(f.receive(raw(f, mode.equals("slot") ? 3 : 2, 0,
|
||||
mode.equals("slot") ? ContainerInput.PICKUP_ALL : ContainerInput.PICKUP)).isCancelled());
|
||||
if (mode.equals("lost")) f.tick();
|
||||
packet.handler.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) (mode.equals("button") ? 1 : 0),
|
||||
mode.equals("input") ? ContainerInput.QUICK_MOVE : ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
assertTrue(packet.clicks.getFirst().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void earlierAndFinalProtocolCancellationNeverAuthorizesPaymentEvenIfLaterListenerUncancels() throws Exception {
|
||||
for (String mode : java.util.List.of("earlier", "late", "uncancel", "after-bukkit")) {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
|
||||
var event = f.clientEvent(raw);
|
||||
if (mode.equals("earlier") || mode.equals("uncancel")) event.setCancelled(true);
|
||||
if (mode.equals("late") || mode.equals("uncancel")) f.protocolListeners.add(
|
||||
new com.comphenix.protocol.events.PacketAdapter(f.plugin,
|
||||
com.comphenix.protocol.events.ListenerPriority.HIGHEST, PacketType.Play.Client.WINDOW_CLICK) {
|
||||
@Override public void onPacketReceiving(com.comphenix.protocol.events.PacketEvent callback) {
|
||||
callback.setCancelled(!mode.equals("uncancel"));
|
||||
}
|
||||
});
|
||||
f.dispatchProtocol(event);
|
||||
if (!event.isCancelled()) packet.handler.handleContainerClick(
|
||||
(net.minecraft.network.protocol.game.ServerboundContainerClickPacket) event.getPacket().getHandle());
|
||||
if (mode.equals("after-bukkit")) event.setCancelled(true);
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
|
||||
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void offThreadCallbackCancelsOnlyWatchedActorWithoutMutatingBukkit() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var event = f.clientEvent(raw(f, 2, 0, ContainerInput.PICKUP));
|
||||
org.mockito.Mockito.clearInvocations(f.player);
|
||||
org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(false);
|
||||
try {
|
||||
var thread = new Thread(() -> f.dispatchProtocol(event));
|
||||
thread.start();
|
||||
thread.join();
|
||||
assertTrue(event.isCancelled());
|
||||
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).getOpenInventory();
|
||||
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).updateInventory();
|
||||
assertTrue(f.nextTick.isEmpty());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
} finally { org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(true); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableManagerWithholdsNewEnhancedOutputAndRestoresOwnedBypass() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
org.mockito.Mockito.when(f.protocol.isClosed()).thenReturn(true);
|
||||
f.prepareAgain();
|
||||
assertNull(f.output());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
f.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void watchedEnhancedToVanillaRejectsOldTakeButFreshNormalBookOperationStaysNative() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
int oldState = f.clientState(f.menu);
|
||||
var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 1);
|
||||
var book = new org.bukkit.inventory.ItemStack(org.bukkit.Material.ENCHANTED_BOOK);
|
||||
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
|
||||
meta.addStoredEnchant(org.bukkit.enchantments.Enchantment.UNBREAKING, 3, false);
|
||||
book.setItemMeta(meta);
|
||||
f.inputs(left, book);
|
||||
var expected = f.output().clone();
|
||||
int cost = f.menu.getCost();
|
||||
assertTrue(cost > 0);
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertNotEquals(oldState, f.clientState(f.menu));
|
||||
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(book, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
f.nativePlayer.experienceLevel = 100;
|
||||
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
|
||||
org.mockito.Mockito.when(random.nextFloat()).thenReturn(1F);
|
||||
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.received.getLast().isCancelled());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled());
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
org.mockito.Mockito.verify(f.nativePlayer).giveExperienceLevels(-cost);
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableRetiresOwnedNativeWindowReturnsInputsAndOldPacketCannotBecomeVanillaTake() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(f.receive(raw).isCancelled()); // A held, admitted raw packet has not reached native.
|
||||
f.plugin.setEnabled(false);
|
||||
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
|
||||
assertTrue(f.protocolListeners.isEmpty());
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
packet.handler.handleContainerClick(raw);
|
||||
f.tick();
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f,
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1,
|
||||
"Purpur 26.2.build.2619-stable")) {
|
||||
assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure");
|
||||
assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst());
|
||||
assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage());
|
||||
f.eventErrors.clear(); // The exact expected enable refusal is asserted above, never suppressed.
|
||||
// The quantity fixture removes MAX_DAMAGE to make custom swords stackable; vanilla
|
||||
// same-material combining requires damageable items. Use genuinely ordinary swords.
|
||||
var input = new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND_SWORD);
|
||||
input.addUnsafeEnchantment(org.bukkit.enchantments.Enchantment.SHARPNESS, 5);
|
||||
f.inputs(input, input.clone());
|
||||
assertTrue(f.protocolListeners.isEmpty());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertEquals(5, f.output().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
|
||||
assertTrue(f.menu.getCost() > 0);
|
||||
assertTrue(f.actionBars.isEmpty());
|
||||
assertEquals(0, rolls.get());
|
||||
f.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void initialAndIdenticalOutputRiskOnlyFramesPublishExactPrivateRiskBeforeDelivery() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
int initial = f.clientState(f.menu);
|
||||
packet.packets.clear();
|
||||
seed(f);
|
||||
assertNotEquals(initial, f.clientState(f.menu));
|
||||
assertRiskFrames(packet, f.output(), initial, 20);
|
||||
int oldState = f.clientState(f.menu);
|
||||
var oldOutput = f.output().clone();
|
||||
packet.packets.clear();
|
||||
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
|
||||
f.acknowledgeSave();
|
||||
assertEquals(oldOutput, f.output());
|
||||
assertNotEquals(oldState, f.clientState(f.menu));
|
||||
assertRiskFrames(packet, oldOutput, oldState, 80);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedTakingHeadersCancelAndResyncBeforeNativeEventsAndOtherExactViewsStayUnwatched() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
for (ContainerInput input : java.util.List.of(ContainerInput.PICKUP, ContainerInput.SWAP, ContainerInput.QUICK_CRAFT)) {
|
||||
packet.packets.clear();
|
||||
packet.click(2, -1, input);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertTrue(packet.packets.stream().anyMatch(net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket.class::isInstance));
|
||||
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
}
|
||||
var unrelated = new net.minecraft.world.inventory.AnvilMenu(19, f.realInventory,
|
||||
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
|
||||
unrelated.setTitle(net.minecraft.network.chat.Component.literal("Other ordinary anvil"));
|
||||
f.nativePlayer.containerMenu = unrelated;
|
||||
assertFalse(f.receive(new Object()).isCancelled(), "Same actor/menu kind is not exact owned-view scope");
|
||||
assertTrue(f.nextTick.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertRiskFrames(NativeAnvilPacketFixture packet, org.bukkit.inventory.ItemStack expected,
|
||||
int oldState, int failure) {
|
||||
int frames = 0;
|
||||
for (var frame : packet.packets) {
|
||||
net.minecraft.world.item.ItemStack output = null;
|
||||
int state = -1;
|
||||
if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content
|
||||
&& content.containerId() == packet.anvil.menu.containerId) {
|
||||
output = content.items().get(2);
|
||||
state = content.stateId();
|
||||
} else if (frame instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot
|
||||
&& slot.getContainerId() == packet.anvil.menu.containerId && slot.getSlot() == 2) {
|
||||
output = slot.getItem();
|
||||
state = slot.getStateId();
|
||||
}
|
||||
if (output != null && org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(output).equals(expected)) {
|
||||
frames++;
|
||||
assertNotEquals(oldState, state);
|
||||
assertTrue(packet.privateRiskAtSend.get(frame).contains("Failure " + failure + "%"));
|
||||
}
|
||||
}
|
||||
assertTrue(frames > 0, "Must prove actual native output-frame delivery, not only server-side state");
|
||||
}
|
||||
|
||||
private static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(
|
||||
NativeAnvilFixture f, int slot, int button, ContainerInput input) {
|
||||
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
|
||||
f.clientState(f.menu), (short) slot, (byte) button, input,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
}
|
||||
|
||||
static void seed(NativeAnvilFixture f) {
|
||||
var input = NativeAdvancedAnvilTransactionTest.stackedSword(5, 3);
|
||||
f.inputs(input, input.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/** Native displayed risk and committed outcomes for every controlled roll, not domain-only math. */
|
||||
class NativeAdvancedAnvilRiskTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@ParameterizedTest @CsvSource({"0,0", "1,20", "2,25", "3,35", "4,50", "5,70", "6,95", "7,99", "20,99"})
|
||||
void largestResultExcessDefaultTableControlsEveryRollFromZeroThroughNinetyNine(int excess, int failure)
|
||||
throws Exception {
|
||||
AtomicInteger selectedRoll = new AtomicInteger();
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
var effects = new NativeAnvilEffectsFixture(f);
|
||||
for (int roll = 0; roll < 100; roll++) {
|
||||
selectedRoll.set(roll);
|
||||
f.player.setItemOnCursor(null);
|
||||
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(4 + excess, 3);
|
||||
// A second compatible enchantment has a smaller excess: SUM would give a
|
||||
// different risk at E>=2. Native maxima differ (Sharpness5, Unbreaking3).
|
||||
left.addUnsafeEnchantment(Enchantment.UNBREAKING, 2 + Math.max(0, excess - 1));
|
||||
f.inputs(left, left.clone());
|
||||
NativeAdvancedAnvilBoundaryTest.assertRisk(f, failure);
|
||||
ItemStack expected = f.output().clone();
|
||||
assertEquals(5 + excess, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(3 + Math.max(0, excess - 1), expected.getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
assertEquals(roll, rolls.get(), "Preparation never rolls");
|
||||
int explosionsBefore = effects.explosions.size();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
f.tick();
|
||||
String context = "E=" + excess + ", cutoff=" + failure + ", roll=" + roll;
|
||||
assertEquals(roll + 1, rolls.get(), context);
|
||||
assertEquals(2, f.menu.getSlot(0).getItem().getCount(), context);
|
||||
assertEquals(2, f.menu.getSlot(1).getItem().getCount(), context);
|
||||
assertEquals(0, f.nativePlayer.experienceLevel, context);
|
||||
if (roll < failure) {
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty(), context);
|
||||
assertEquals(0, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
|
||||
assertEquals(explosionsBefore + 1, effects.explosions.size(), context);
|
||||
} else {
|
||||
assertEquals(expected, f.player.getItemOnCursor(), context);
|
||||
assertEquals(1, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
|
||||
assertEquals(explosionsBefore, effects.explosions.size(), context);
|
||||
}
|
||||
}
|
||||
assertEquals(failure, effects.explosions.size(), "100 rolls: precisely the displayed failure count");
|
||||
assertEquals(100, rolls.get());
|
||||
assertEquals(1, f.player.getItemOnCursor().getAmount(), "Roll99 succeeds even at the 99% ceiling");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.PrepareAnvilEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.Damageable;
|
||||
import org.bukkit.inventory.meta.Repairable;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered plugin -> native slotsChanged/result event -> Craft result and private preview. */
|
||||
class NativeAdvancedAnvilTest {
|
||||
@TempDir Path directory;
|
||||
NativeAnvilFixture fixture;
|
||||
|
||||
@BeforeEach void enable() throws Exception { fixture = new NativeAnvilFixture(directory); }
|
||||
@AfterEach void disable() { if (fixture != null) fixture.close(); }
|
||||
|
||||
@Test void registeredHighestListenerMergesEqualAboveNormalMaximumAtZeroXpAndTooExpensive() {
|
||||
assertTrue(java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
|
||||
.anyMatch(listener -> listener.getPlugin() == fixture.plugin
|
||||
&& listener.getPriority() == EventPriority.HIGHEST));
|
||||
var left = sword(5);
|
||||
var meta = left.getItemMeta();
|
||||
((Repairable) meta).setRepairCost(80);
|
||||
left.setItemMeta(meta);
|
||||
fixture.nativePlayer.experienceLevel = 0;
|
||||
fixture.inputs(left, sword(5));
|
||||
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(0, fixture.menu.getCost());
|
||||
assertTrue(fixture.menu.getBukkitView().canBypassCost());
|
||||
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
|
||||
assertFalse(fixture.menu.canDoUnsafeEnchants);
|
||||
assertFalse(fixture.menu.bypassEnchantmentLevelRestriction);
|
||||
assertEquals(80, ((Repairable) fixture.output().getItemMeta()).getRepairCost());
|
||||
assertInputs(left, sword(5));
|
||||
}
|
||||
|
||||
@Test void equalUnequalAndSingleSourceUnionUseEntireEnchantments() {
|
||||
fixture.inputs(sword(6), sword(5));
|
||||
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
fixture.inputs(sword(5), sword(7));
|
||||
assertEquals(7, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.UNBREAKING, 3);
|
||||
fixture.inputs(sword(5), right);
|
||||
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), right);
|
||||
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
assertEquals(0, fixture.menu.getCost());
|
||||
fixture.inputs(right, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
}
|
||||
|
||||
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
|
||||
var left = sword(5);
|
||||
var meta = (Damageable) left.getItemMeta();
|
||||
meta.setDamage(1200);
|
||||
left.setItemMeta(meta);
|
||||
var right = sword(5);
|
||||
right.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
fixture.inputs(left, right);
|
||||
assertEquals(1013, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
}
|
||||
|
||||
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
|
||||
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
|
||||
fixture.inputs(sword(5), right);
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("conflict"));
|
||||
assertInputs(sword(5), right);
|
||||
var alreadyConflicting = sword(5);
|
||||
alreadyConflicting.addUnsafeEnchantment(Enchantment.SMITE, 4);
|
||||
fixture.inputs(alreadyConflicting, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertNull(fixture.output(), "Conflicts already present on left cannot be silently retained");
|
||||
}
|
||||
|
||||
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
|
||||
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
|
||||
fixture.inputs(sword(5), invalid);
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("applicable"));
|
||||
fixture.inputs(invalid, sword(5));
|
||||
assertNull(fixture.output());
|
||||
assertInputs(invalid, sword(5));
|
||||
}
|
||||
|
||||
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
|
||||
fixture.inputs(sword(254), sword(254));
|
||||
assertNotNull(fixture.output());
|
||||
assertEquals(255, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(fixture.output(), ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
|
||||
fixture.inputs(sword(255), sword(255));
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("represent"));
|
||||
assertInputs(sword(255), sword(255));
|
||||
assertNotNull(AdvancedAnvilMerge.prepare(sword(255), sword(255), AdvancedAnvilPolicy.defaults()).refusal());
|
||||
}
|
||||
|
||||
@Test void exactStyledUnicodeNameLorePdcAndLeftOnlyMetadataSurviveCraftRoundTrip() {
|
||||
var left = sword(5);
|
||||
left.setAmount(3);
|
||||
var meta = left.getItemMeta();
|
||||
meta.customName(Component.text("Épée 雪 🗡", NamedTextColor.AQUA).decorate(TextDecoration.BOLD));
|
||||
meta.lore(List.of(Component.text("Lore λ", NamedTextColor.GOLD), Component.empty()));
|
||||
meta.itemName(Component.text("Separate native item-name"));
|
||||
meta.setUnbreakable(true);
|
||||
meta.setMaxStackSize(3);
|
||||
meta.getPersistentDataContainer().set(key("left"), PersistentDataType.STRING, "雪");
|
||||
left.setItemMeta(meta);
|
||||
// Native slots clamp ordinary swords to one; a real stackable sword must also remove
|
||||
// MAX_DAMAGE, since the native codec rejects damageable + stackable components.
|
||||
left.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
assertEquals(left, ItemStack.deserializeBytes(left.serializeAsBytes()));
|
||||
var right = sword(5);
|
||||
var rightMeta = right.getItemMeta();
|
||||
rightMeta.customName(Component.text("DO NOT IMPORT"));
|
||||
rightMeta.lore(List.of(Component.text("foreign")));
|
||||
rightMeta.getPersistentDataContainer().set(key("right"), PersistentDataType.STRING, "foreign");
|
||||
right.setItemMeta(rightMeta);
|
||||
fixture.menu.itemName = "rename field ignored";
|
||||
fixture.inputs(left, right);
|
||||
var expected = left.clone();
|
||||
expected.setAmount(1);
|
||||
expected.addUnsafeEnchantment(Enchantment.SHARPNESS, 6);
|
||||
assertEquals(expected, fixture.output());
|
||||
assertEquals(expected, ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
|
||||
assertFalse(fixture.output().getItemMeta().getPersistentDataContainer().has(key("right")));
|
||||
assertEquals(0, fixture.menu.getBukkitView().getRepairCost());
|
||||
assertTrue(fixture.menu.getBukkitView().canBypassCost());
|
||||
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
|
||||
assertInputs(left, right);
|
||||
}
|
||||
|
||||
@Test void absentNameAndLoreStayAbsentAndEnhancedRenameDoesNotChangeThem() {
|
||||
fixture.menu.itemName = "Injected rename";
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
assertFalse(fixture.output().getItemMeta().hasCustomName());
|
||||
assertFalse(fixture.output().getItemMeta().hasLore());
|
||||
fixture.menu.setItemName("second rename");
|
||||
fixture.prepareAgain();
|
||||
assertFalse(fixture.output().getItemMeta().hasCustomName());
|
||||
assertFalse(fixture.output().getItemMeta().hasLore());
|
||||
}
|
||||
|
||||
@Test void nativeNormalDurabilityRepairUsesTwelvePercentAndOnlyImprovesDamage() {
|
||||
var left = damaged(sword(5), 1500);
|
||||
var right = damaged(sword(5), 1500);
|
||||
fixture.inputs(left, right);
|
||||
int maximum = left.getType().getMaxDurability();
|
||||
int expected = Math.max(0, maximum - ((maximum - 1500) * 2 + maximum * 12 / 100));
|
||||
assertEquals(expected, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
assertInputs(left, right);
|
||||
fixture.inputs(damaged(sword(5), 2), damaged(sword(4), maximum - 1));
|
||||
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
}
|
||||
|
||||
@Test void customNativeMaximumDurabilityAndNonDamageableMaterialsRemainRepresentable() {
|
||||
var left = sword(5);
|
||||
var meta = (Damageable) left.getItemMeta();
|
||||
meta.setMaxDamage(1000);
|
||||
meta.setDamage(900);
|
||||
left.setItemMeta(meta);
|
||||
var right = damaged(sword(5), 1500);
|
||||
fixture.inputs(left, right);
|
||||
assertEquals(719, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
assertEquals(1000, ((Damageable) fixture.output().getItemMeta()).getMaxDamage());
|
||||
var helmet = enchanted(Material.DIAMOND_HELMET, Enchantment.PROTECTION, 4);
|
||||
fixture.inputs(helmet, helmet);
|
||||
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.PROTECTION));
|
||||
}
|
||||
|
||||
@Test void booksAndMismatchedMaterialsRemainNativeAndDoNotSendEnhancedWarnings() {
|
||||
var book = new ItemStack(Material.ENCHANTED_BOOK);
|
||||
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
|
||||
meta.addStoredEnchant(Enchantment.SHARPNESS, 5, true);
|
||||
book.setItemMeta(meta);
|
||||
fixture.inputs(sword(5), book);
|
||||
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
assertTrue(fixture.chats.isEmpty());
|
||||
assertTrue(fixture.actionBars.isEmpty());
|
||||
fixture.inputs(book, book);
|
||||
assertNotNull(fixture.output());
|
||||
assertEquals(5, ((org.bukkit.inventory.meta.EnchantmentStorageMeta) fixture.output().getItemMeta())
|
||||
.getStoredEnchantLevel(Enchantment.SHARPNESS));
|
||||
fixture.inputs(sword(5), enchanted(Material.NETHERITE_SWORD, Enchantment.SHARPNESS, 5));
|
||||
assertNull(fixture.output());
|
||||
assertTrue(fixture.chats.isEmpty());
|
||||
assertNull(AdvancedAnvilMerge.prepare(book, book, AdvancedAnvilPolicy.defaults()));
|
||||
}
|
||||
|
||||
@Test void zeroEnchantmentRepairAndRenameRemainNativeAfterEnhancedPreview() {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
int notices = fixture.chats.size();
|
||||
// Remove the enchanted right input before replacing the left, as a player can do.
|
||||
// Left-first replacement creates a genuine intermediate enhanced merge with the old right.
|
||||
fixture.inputs(sword(5), null);
|
||||
fixture.inputs(damaged(new ItemStack(Material.DIAMOND_SWORD), 1200),
|
||||
damaged(new ItemStack(Material.DIAMOND_SWORD), 1200));
|
||||
assertNotNull(fixture.output());
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
assertEquals(notices, fixture.chats.size());
|
||||
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), null);
|
||||
fixture.menu.setItemName("native rename");
|
||||
fixture.prepareAgain();
|
||||
assertEquals(Component.text("native rename"), fixture.output().getItemMeta().customName());
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
}
|
||||
|
||||
@Test void exactPrivateRomanPreviewAndChangedOnlyWarningDoNotAlterItemsOrSpam() {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
assertEquals("Diamond Sword · Sharpness VI · Success 80% · Failure 20% — explosion", lastAction());
|
||||
assertEquals(1, fixture.chats.size());
|
||||
assertTrue(plain(fixture.chats.getFirst()).contains("Failure destroys both items and causes an explosion"));
|
||||
for (int i = 0; i < 5; i++) { fixture.prepareAgain(); fixture.tick(); }
|
||||
assertEquals(1, fixture.chats.size());
|
||||
fixture.inputs(sword(6), sword(6));
|
||||
assertEquals(2, fixture.chats.size());
|
||||
assertTrue(lastAction().contains("Sharpness VII · Success 75% · Failure 25%"));
|
||||
fixture.inputs(sword(10), sword(10));
|
||||
assertTrue(lastAction().contains("Sharpness XI · Success 5% · Failure 95%"));
|
||||
verify(fixture.server, never()).broadcast(any(Component.class));
|
||||
}
|
||||
|
||||
@Test void policyChangeRerendersCurrentViewAndCloseDisableReleasePreviewLifecycle() throws Exception {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
var admin = mock(org.bukkit.command.CommandSender.class);
|
||||
when(admin.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
|
||||
assertTrue(fixture.commands.dispatch(admin,
|
||||
"inventoryhelperadmin config advanced-anvil-base-failure-percent 45"));
|
||||
var acknowledgment = fixture.nextTick.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(acknowledgment, "Real durable save must schedule acknowledgment");
|
||||
assertTrue(lastAction().contains("Success 80% · Failure 20%"), "No off-thread publication");
|
||||
acknowledgment.run();
|
||||
assertTrue(lastAction().contains("Success 55% · Failure 45%"));
|
||||
var preview = java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
|
||||
.map(org.bukkit.plugin.RegisteredListener::getListener)
|
||||
.filter(AdvancedAnvilListener.class::isInstance).map(AdvancedAnvilListener.class::cast)
|
||||
.findFirst().orElseThrow();
|
||||
int chats = fixture.chats.size();
|
||||
preview.policyChanged();
|
||||
assertEquals(chats, fixture.chats.size());
|
||||
fixture.events.callEvent(new InventoryCloseEvent(fixture.menu.getBukkitView()));
|
||||
int bars = fixture.actionBars.size();
|
||||
fixture.tick();
|
||||
assertEquals(bars, fixture.actionBars.size());
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertTrue(fixture.repeating.isEmpty());
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
}
|
||||
|
||||
private void assertInputs(ItemStack left, ItemStack right) {
|
||||
assertEquals(left, fixture.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(right, fixture.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, fixture.nativePlayer.experienceLevel);
|
||||
fixture.assertNoEventErrors();
|
||||
}
|
||||
|
||||
private String lastAction() { assertFalse(fixture.actionBars.isEmpty()); return plain(fixture.actionBars.getLast()); }
|
||||
private static String plain(Component message) { return PlainTextComponentSerializer.plainText().serialize(message); }
|
||||
private static NamespacedKey key(String key) { return new NamespacedKey("test", key); }
|
||||
private static ItemStack sword(int level) { return enchanted(Material.DIAMOND_SWORD, Enchantment.SHARPNESS, level); }
|
||||
private static ItemStack enchanted(Material material, Enchantment enchantment, int level) {
|
||||
var item = new ItemStack(material);
|
||||
item.addUnsafeEnchantment(enchantment, level);
|
||||
return item;
|
||||
}
|
||||
private static ItemStack damaged(ItemStack item, int damage) {
|
||||
var meta = (Damageable) item.getItemMeta();
|
||||
meta.setDamage(damage);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered click gate plus native clicked/slots/cursor and deterministic next-tick commit. */
|
||||
class NativeAdvancedAnvilTransactionTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void nativeTakeConsumesOneOfEachStackAndRollsOnceWithoutXpOrFreeRepeat() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1)) {
|
||||
f.inputs(stackedSword(5, 3), stackedSword(5, 3));
|
||||
f.click(2, ClickType.LEFT, InventoryAction.PICKUP_ALL, 0, ContainerInput.PICKUP);
|
||||
f.tick();
|
||||
assertNotNull(f.menu.getBukkitView().getTopInventory().getItem(0), "Native payment must leave the two unpaid items");
|
||||
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
|
||||
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
|
||||
assertEquals(1, f.player.getItemOnCursor().getAmount());
|
||||
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(0, f.nativePlayer.experienceLevel);
|
||||
}
|
||||
}
|
||||
|
||||
static ItemStack stackedSword(int level, int count) {
|
||||
var item = new ItemStack(Material.DIAMOND_SWORD);
|
||||
item.addUnsafeEnchantment(Enchantment.SHARPNESS, level);
|
||||
var meta = item.getItemMeta();
|
||||
meta.setMaxStackSize(3);
|
||||
item.setItemMeta(meta);
|
||||
item.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
item.setAmount(count);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.world.level.ServerExplosion;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.craftbukkit.block.data.CraftBlockData;
|
||||
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||
import org.bukkit.ExplosionResult;
|
||||
|
||||
/** Real Craft explosion overload and ServerExplosion; world/protection execution is the external port. */
|
||||
final class NativeAnvilEffectsFixture {
|
||||
final NativeAnvilFixture anvil;
|
||||
final Block block;
|
||||
final AtomicReference<BlockData> data = new AtomicReference<>(
|
||||
CraftBlockData.createData(net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState()));
|
||||
final List<ServerExplosion> explosions = new ArrayList<>();
|
||||
final List<EntityExplodeEvent> protectionEvents = new ArrayList<>();
|
||||
final List<net.minecraft.world.level.Level.ExplosionInteraction> requestedInteractions = new ArrayList<>();
|
||||
final List<Boolean> requestedFire = new ArrayList<>();
|
||||
Runnable duringExplosion = () -> {};
|
||||
boolean cancelExplosion;
|
||||
|
||||
NativeAnvilEffectsFixture(NativeAnvilFixture anvil) throws Exception {
|
||||
this.anvil = anvil;
|
||||
block = anvil.world.getBlockAt(10, 65, -9);
|
||||
when(block.getType()).thenAnswer(ignored -> data.get().getMaterial());
|
||||
when(block.getBlockData()).thenAnswer(ignored -> data.get().clone());
|
||||
when(block.getLocation()).thenAnswer(ignored -> anvil.menu.getBukkitView().getTopInventory().getLocation());
|
||||
when(anvil.server.createBlockData(any(Material.class)))
|
||||
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null));
|
||||
when(anvil.server.createBlockData(any(Material.class), nullable(String.class)))
|
||||
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), inv.getArgument(1)));
|
||||
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class));
|
||||
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class), anyBoolean());
|
||||
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
|
||||
.when(block).setType(any(Material.class));
|
||||
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
|
||||
.when(block).setType(any(Material.class), anyBoolean());
|
||||
when(anvil.level.getBlockState(anvil.position)).thenAnswer(ignored -> ((CraftBlockData) data.get()).getState());
|
||||
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
|
||||
worldField.setAccessible(true);
|
||||
worldField.set(anvil.world, anvil.level);
|
||||
var sources = mock(net.minecraft.world.damagesource.DamageSources.class);
|
||||
var damage = mock(net.minecraft.world.damagesource.DamageSource.class);
|
||||
when(anvil.level.damageSources()).thenReturn(sources);
|
||||
when(sources.explosion(anvil.nativePlayer, anvil.nativePlayer)).thenReturn(damage);
|
||||
when(anvil.level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
|
||||
anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
|
||||
// Capture requested native flags before the external world adapter maps block behavior.
|
||||
requestedInteractions.add(inv.getArgument(8));
|
||||
requestedFire.add(inv.getArgument(7));
|
||||
var explosion = new ServerExplosion(anvil.level, inv.getArgument(0), inv.getArgument(1), null,
|
||||
new Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
|
||||
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
|
||||
java.util.function.Consumer<ServerExplosion> configure = inv.getArgument(13);
|
||||
if (configure != null) configure.accept(explosion);
|
||||
var event = new EntityExplodeEvent(anvil.player, anvil.menu.getBukkitView().getTopInventory()
|
||||
.getLocation().clone().add(.5, .5, .5), new ArrayList<>(), 1F, ExplosionResult.DESTROY);
|
||||
event.setCancelled(cancelExplosion);
|
||||
anvil.events.callEvent(event);
|
||||
explosion.wasCanceled = event.isCancelled();
|
||||
protectionEvents.add(event);
|
||||
explosions.add(explosion);
|
||||
duringExplosion.run();
|
||||
return explosion;
|
||||
});
|
||||
doCallRealMethod().when(anvil.world).createExplosion(any(org.bukkit.entity.Entity.class),
|
||||
any(org.bukkit.Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.inventory.AnvilMenu;
|
||||
import net.minecraft.world.inventory.ContainerLevelAccess;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.command.PluginCommandYamlParser;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.purpurmc.purpur.PurpurWorldConfig;
|
||||
|
||||
/** Real menu/inventory/events/items; only server, world, network and scheduler boundaries are ports. */
|
||||
final class NativeAnvilFixture implements AutoCloseable {
|
||||
static {
|
||||
try {
|
||||
NativeRuntime.bootstrap();
|
||||
} catch (Exception failure) {
|
||||
throw new ExceptionInInitializerError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
|
||||
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
|
||||
final CraftPlayer player = mock(CraftPlayer.class);
|
||||
final ServerLevel level = mock(ServerLevel.class);
|
||||
final CraftWorld world = mock(CraftWorld.class);
|
||||
final BlockPos position = new BlockPos(10, 65, -9);
|
||||
final net.minecraft.world.entity.player.Inventory realInventory =
|
||||
new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment());
|
||||
final SimpleCommandMap commands = new SimpleCommandMap(server, new HashMap<>());
|
||||
final PaperPluginManagerImpl events = new PaperPluginManagerImpl(server, commands, null);
|
||||
final List<Component> actionBars = new ArrayList<>();
|
||||
final List<Component> chats = new ArrayList<>();
|
||||
final List<String> adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
final java.util.concurrent.BlockingQueue<Runnable> nextTick = new java.util.concurrent.LinkedBlockingQueue<>();
|
||||
final List<Runnable> repeating = new ArrayList<>();
|
||||
final List<BukkitTask> tasks = new ArrayList<>();
|
||||
final List<Throwable> eventErrors = new ArrayList<>();
|
||||
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
final List<com.comphenix.protocol.events.PacketListener> protocolListeners = new ArrayList<>();
|
||||
private final Map<Integer, Integer> displayedStates = new HashMap<>();
|
||||
final SpigotInventoryHelperPlugin plugin;
|
||||
final AnvilMenu menu;
|
||||
private final Server previous;
|
||||
|
||||
NativeAnvilFixture(Path directory) throws Exception {
|
||||
this(directory, () -> 99, () -> 1.0);
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear) throws Exception {
|
||||
this(directory, risk, wear, "Purpur 26.2.build.2618-stable");
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear, String version) throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; })
|
||||
.when(protocol).addPacketListener(any());
|
||||
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; })
|
||||
.when(protocol).removePacketListener(any());
|
||||
previous = Bukkit.getServer();
|
||||
setServer(server);
|
||||
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
when(server.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)");
|
||||
when(server.getMinecraftVersion()).thenReturn("26.2");
|
||||
when(server.getBukkitVersion()).thenReturn("26.2.build.2618-stable");
|
||||
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
|
||||
when(server.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
when(server.getPluginCommand(any(String.class))).thenAnswer(invocation -> {
|
||||
var command = commands.getCommand(invocation.getArgument(0));
|
||||
return command instanceof PluginCommand owned ? owned : null;
|
||||
});
|
||||
Map<NamespacedKey, Recipe> recipes = new HashMap<>();
|
||||
when(server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.get(inv.getArgument(0)));
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(inv -> {
|
||||
Recipe recipe = inv.getArgument(0);
|
||||
return recipes.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> recipes.remove(inv.getArgument(0)) != null);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> schedule(inv.getArgument(1), nextTick));
|
||||
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
|
||||
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.getName()).thenReturn("AnvilPlayer");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
|
||||
when(nativePlayer.hasInfiniteMaterials()).thenReturn(true);
|
||||
doCallRealMethod().when(player).getItemOnCursor();
|
||||
doCallRealMethod().when(player).setItemOnCursor(any());
|
||||
when(player.getInventory()).thenReturn(new CraftInventoryPlayer(realInventory));
|
||||
when(player.hasPermission(any(String.class))).thenReturn(false);
|
||||
when(console.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
|
||||
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
|
||||
.when(console).sendMessage(any(String.class));
|
||||
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
|
||||
.when(player).sendMessage(any(String.class));
|
||||
doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; }).when(player).sendActionBar(any(Component.class));
|
||||
doAnswer(inv -> { chats.add(inv.getArgument(0)); return null; }).when(player).sendMessage(any(Component.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.getInventory()).thenReturn(realInventory);
|
||||
when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
when(level.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||
when(level.getWorld()).thenReturn(world);
|
||||
when(level.getBlockState(position)).thenReturn(Blocks.ANVIL.defaultBlockState());
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
when(block.getType()).thenReturn(org.bukkit.Material.ANVIL);
|
||||
when(world.getBlockAt(any(org.bukkit.Location.class))).thenReturn(block);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var configField = net.minecraft.world.level.Level.class.getDeclaredField("purpurConfig");
|
||||
configField.setAccessible(true);
|
||||
configField.set(level, mock(PurpurWorldConfig.class));
|
||||
menu = new AnvilMenu(7, realInventory, ContainerLevelAccess.create(level, position));
|
||||
menu.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
nativePlayer.containerMenu = menu;
|
||||
when(player.getOpenInventory()).thenAnswer(ignored -> nativePlayer.containerMenu.getBukkitView());
|
||||
plugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(server),
|
||||
description(), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile(), risk, wear, protocol,
|
||||
() -> version);
|
||||
commands.registerAll(plugin.getName(), PluginCommandYamlParser.parse(plugin));
|
||||
plugin.getLogger().addHandler(new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
if (record.getThrown() != null) eventErrors.add(record.getThrown());
|
||||
}
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
if (plugin.getServer() != server || plugin.getCommand("inventoryhelperadmin") == null) {
|
||||
throw new AssertionError("Explicit native command wiring failed: server=" + (plugin.getServer() == server)
|
||||
+ ", command=" + commands.getCommand("inventoryhelperadmin")
|
||||
+ ", owner=" + (commands.getCommand("inventoryhelperadmin") instanceof PluginCommand command
|
||||
&& command.getPlugin() == plugin));
|
||||
}
|
||||
plugin.setEnabled(true);
|
||||
// Even event-level fixtures must echo a genuinely published client frame, never the
|
||||
// server counter. Reuse the pinned native synchronizer with only its transport mocked.
|
||||
var transport = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||
nativePlayer.connection = transport;
|
||||
doAnswer(inv -> { send(inv.getArgument(0)); return null; })
|
||||
.when(transport).send(any(net.minecraft.network.protocol.Packet.class));
|
||||
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
|
||||
.getDeclaredConstructor(ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
Object created = constructor.newInstance(nativePlayer);
|
||||
if (!(created instanceof net.minecraft.world.inventory.ContainerSynchronizer synchronizer)) {
|
||||
throw new IllegalStateException("Unexpected native synchronizer");
|
||||
}
|
||||
var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(realInventory, true, nativePlayer);
|
||||
var inventoryField = net.minecraft.world.entity.player.Player.class.getDeclaredField("inventoryMenu");
|
||||
if (inventoryField.getType() != net.minecraft.world.inventory.InventoryMenu.class) {
|
||||
throw new IllegalStateException("Unexpected inventory-menu port");
|
||||
}
|
||||
inventoryField.setAccessible(true);
|
||||
inventoryField.set(nativePlayer, inventoryMenu);
|
||||
inventoryMenu.setSynchronizer(synchronizer);
|
||||
menu.setSynchronizer(synchronizer);
|
||||
}
|
||||
|
||||
void inputs(ItemStack left, ItemStack right) {
|
||||
// setItem triggers SimpleContainer.setChanged -> slotsChanged -> actual result event dispatch.
|
||||
menu.getSlot(0).set(CraftItemStack.asNMSCopy(left));
|
||||
menu.getSlot(1).set(CraftItemStack.asNMSCopy(right));
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
void admin(org.bukkit.command.CommandSender sender, String key, int value) {
|
||||
var command = plugin.getCommand("inventoryhelperadmin");
|
||||
if (command == null || command != commands.getCommand("inventoryhelperadmin")) {
|
||||
throw new AssertionError("Missing actual registered administrator command");
|
||||
}
|
||||
if (!command.execute(sender, "inventoryhelperadmin", new String[] {"config", key, Integer.toString(value)})) {
|
||||
throw new AssertionError("Registered administrator command refused execution");
|
||||
}
|
||||
}
|
||||
|
||||
void acknowledgeSave() throws InterruptedException {
|
||||
Runnable acknowledgment = nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (acknowledgment == null) throw new AssertionError("Durable save did not schedule acknowledgment");
|
||||
acknowledgment.run();
|
||||
assertNoEventErrors();
|
||||
if (adminMessages.stream().noneMatch(message -> message.endsWith("(saved)"))) {
|
||||
throw new AssertionError("No durable success acknowledgment: " + adminMessages);
|
||||
}
|
||||
}
|
||||
|
||||
void prepareAgain() {
|
||||
menu.slotsChanged(menu.getSlot(0).container);
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
org.bukkit.event.inventory.InventoryClickEvent click(int rawSlot,
|
||||
org.bukkit.event.inventory.ClickType click, org.bukkit.event.inventory.InventoryAction action,
|
||||
int button, net.minecraft.world.inventory.ContainerInput input) {
|
||||
var event = new org.bukkit.event.inventory.InventoryClickEvent(menu.getBukkitView(),
|
||||
rawSlot == 2 ? org.bukkit.event.inventory.InventoryType.SlotType.RESULT
|
||||
: org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
rawSlot, click, action, button);
|
||||
var packet = receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
menu.containerId, clientState(menu), (short) rawSlot, (byte) button, input,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
if (packet.isCancelled()) event.setCancelled(true);
|
||||
else events.callEvent(event);
|
||||
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
|
||||
assertNoEventErrors();
|
||||
return event;
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
|
||||
var event = clientEvent(payload);
|
||||
dispatchProtocol(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent clientEvent(Object payload) {
|
||||
var container = new com.comphenix.protocol.events.PacketContainer(
|
||||
com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, payload);
|
||||
return com.comphenix.protocol.events.PacketEvent.fromClient(protocol, container, player);
|
||||
}
|
||||
|
||||
void dispatchProtocol(com.comphenix.protocol.events.PacketEvent event) {
|
||||
List.copyOf(protocolListeners).stream()
|
||||
.filter(listener -> listener.getReceivingWhitelist().getTypes().contains(event.getPacketType()))
|
||||
.sorted(java.util.Comparator.comparingInt(listener -> listener.getReceivingWhitelist().getPriority().getSlot()))
|
||||
.forEach(listener -> listener.onPacketReceiving(event));
|
||||
}
|
||||
|
||||
int clientState(net.minecraft.world.inventory.AbstractContainerMenu active) {
|
||||
Integer state = displayedStates.get(active.containerId);
|
||||
if (state == null) throw new AssertionError("Menu has no displayed frame: " + active.containerId);
|
||||
return state;
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw) {
|
||||
com.comphenix.protocol.PacketType type;
|
||||
if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket) {
|
||||
type = com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS;
|
||||
} else if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket) {
|
||||
type = com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
|
||||
} else return null; // Non-state packets are outside this fixture's client header port.
|
||||
var event = com.comphenix.protocol.events.PacketEvent.fromServer(protocol,
|
||||
new com.comphenix.protocol.events.PacketContainer(type, raw), player);
|
||||
dispatchSending(event);
|
||||
if (!event.isCancelled()) {
|
||||
Object wire = event.getPacket().getHandle();
|
||||
if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content) {
|
||||
displayedStates.put(content.containerId(), content.stateId());
|
||||
} else if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot) {
|
||||
displayedStates.put(slot.getContainerId(), slot.getStateId());
|
||||
} else throw new AssertionError("Unsupported wire frame");
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void dispatchSending(com.comphenix.protocol.events.PacketEvent event) {
|
||||
List.copyOf(protocolListeners).stream()
|
||||
.filter(listener -> listener.getSendingWhitelist().getTypes().contains(event.getPacketType()))
|
||||
.sorted(java.util.Comparator.comparingInt(listener -> listener.getSendingWhitelist().getPriority().getSlot()))
|
||||
.forEach(listener -> listener.onPacketSending(event));
|
||||
}
|
||||
|
||||
ItemStack output() {
|
||||
return menu.getBukkitView().getTopInventory().getItem(2);
|
||||
}
|
||||
|
||||
void tick() {
|
||||
var ready = List.copyOf(nextTick);
|
||||
nextTick.clear();
|
||||
ready.forEach(Runnable::run);
|
||||
List.copyOf(repeating).forEach(Runnable::run);
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
void assertNoEventErrors() {
|
||||
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
|
||||
}
|
||||
|
||||
private BukkitTask schedule(Runnable runnable, java.util.Collection<Runnable> queue) {
|
||||
queue.add(runnable);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
|
||||
tasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
setServer(previous);
|
||||
} catch (Exception failure) {
|
||||
throw new AssertionError("Could not restore server", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static PluginDescriptionFile description() throws java.io.IOException, org.bukkit.plugin.InvalidDescriptionException {
|
||||
// Declared ProtocolLib also ships plugin.yml. Select the actual generated owning metadata,
|
||||
// not whichever dependency happens to be first on the native-test classpath.
|
||||
PluginDescriptionFile selected = null;
|
||||
var resources = SpigotInventoryHelperPlugin.class.getClassLoader().getResources("plugin.yml");
|
||||
while (resources.hasMoreElements()) {
|
||||
try (var stream = resources.nextElement().openStream()) {
|
||||
var description = new PluginDescriptionFile(stream);
|
||||
if (description.getMain().equals(SpigotInventoryHelperPlugin.class.getName())) {
|
||||
if (selected != null) throw new AssertionError("Duplicate owning plugin metadata");
|
||||
selected = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selected == null) throw new AssertionError("Missing generated owning plugin metadata");
|
||||
return selected;
|
||||
}
|
||||
|
||||
static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NativeAnvilFrameGenerationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void oldTwentyPercentFrameCannotPayNewNinetyFivePercentPreviewAfterNativeCounterReuse() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 50; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
var effects = new NativeAnvilEffectsFixture(f);
|
||||
NativeAdvancedAnvilProtocolTest.seed(f);
|
||||
f.player.updateInventory();
|
||||
int oldWire = lastFrame(packet);
|
||||
int oldNative = f.menu.getStateId();
|
||||
assertTrue(risk(f).contains("Failure 20%"));
|
||||
var newInput = NativeAdvancedAnvilTransactionTest.stackedSword(10, 3);
|
||||
f.inputs(newInput, newInput.clone());
|
||||
assertTrue(risk(f).contains("Failure 95%"));
|
||||
int synchronizations = 0;
|
||||
// Ordinary full native syncs only: no synthetic field writes or increment shortcut.
|
||||
do {
|
||||
f.player.updateInventory();
|
||||
synchronizations++;
|
||||
} while (f.menu.getStateId() != oldNative && synchronizations < 32768);
|
||||
assertEquals(oldNative, f.menu.getStateId());
|
||||
assertTrue(synchronizations > 32000 && synchronizations <= 32768);
|
||||
int newWire = lastFrame(packet);
|
||||
packet.click(f.menu.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
|
||||
f.tick();
|
||||
System.out.println("WRAP_FACTS oldWire=" + oldWire + ", newWire=" + newWire
|
||||
+ ", reusedNative=" + oldNative + ", fullSyncs=" + synchronizations
|
||||
+ ", rolls=" + rolls.get() + ", explosions=" + effects.explosions.size());
|
||||
assertAll("Old displayed 20% risk must not commit the new 95% attempt",
|
||||
() -> assertTrue(packet.received.getLast().isCancelled()),
|
||||
() -> assertTrue(packet.clicks.isEmpty()),
|
||||
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(0)),
|
||||
() -> assertEquals(newInput, f.menu.getBukkitView().getTopInventory().getItem(1)),
|
||||
() -> assertEquals(0, rolls.get()),
|
||||
() -> assertTrue(effects.explosions.isEmpty()),
|
||||
() -> assertTrue(f.player.getItemOnCursor().isEmpty()));
|
||||
assertNotEquals(oldWire, newWire);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void supportedNativeCodecsRoundTripFullPositiveIntegerVersionsAndEveryField() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory)) {
|
||||
var item = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
|
||||
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
|
||||
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
|
||||
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
|
||||
changes.put(0, hash);
|
||||
changes.put(2, net.minecraft.network.HashedStack.EMPTY);
|
||||
for (int version : new int[] {32767, 32768, 65536, Integer.MAX_VALUE}) {
|
||||
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, version, java.util.List.of(item), item);
|
||||
var decodedContent = roundTrip(ClientboundContainerSetContentPacket.STREAM_CODEC, content);
|
||||
assertEquals(content.containerId(), decodedContent.containerId());
|
||||
assertEquals(version, decodedContent.stateId());
|
||||
assertEquals(content.items().size(), decodedContent.items().size());
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.items().getFirst()));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedContent.carriedItem()));
|
||||
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, version, 2, item);
|
||||
var decodedSlot = roundTrip(ClientboundContainerSetSlotPacket.STREAM_CODEC, slot);
|
||||
assertEquals(slot.getContainerId(), decodedSlot.getContainerId());
|
||||
assertEquals(version, decodedSlot.getStateId());
|
||||
assertEquals(slot.getSlot(), decodedSlot.getSlot());
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(item, decodedSlot.getItem()));
|
||||
for (ContainerInput input : ContainerInput.values()) {
|
||||
var click = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
|
||||
version, (short) -999, (byte) 40, input, changes, hash);
|
||||
assertEquals(click, roundTrip(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.STREAM_CODEC, click));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void slotAndContentFramesOnlyReplaceHeaderAndCurrentEchoReachesNativeHandler() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
NativeAdvancedAnvilProtocolTest.seed(f);
|
||||
int nativeState = f.menu.getStateId();
|
||||
var unrelated = new ClientboundContainerSetContentPacket(19, nativeState, f.menu.getItems(), f.menu.getCarried());
|
||||
var unrelatedEvent = f.send(unrelated);
|
||||
assertFalse(unrelatedEvent.isCancelled());
|
||||
assertSame(unrelated, unrelatedEvent.getPacket().getHandle(), "Never rewrite another window's frame");
|
||||
var carried = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.EMERALD, 17);
|
||||
var content = new ClientboundContainerSetContentPacket(f.menu.containerId, nativeState, f.menu.getItems(), carried);
|
||||
var contentEvent = f.send(content);
|
||||
var translatedContent = (ClientboundContainerSetContentPacket) contentEvent.getPacket().getHandle();
|
||||
assertNotEquals(nativeState, translatedContent.stateId());
|
||||
assertEquals(content.containerId(), translatedContent.containerId());
|
||||
assertSame(content.items(), translatedContent.items());
|
||||
assertSame(carried, translatedContent.carriedItem());
|
||||
var slot = new ClientboundContainerSetSlotPacket(f.menu.containerId, nativeState, 2, f.menu.getSlot(2).getItem());
|
||||
var slotEvent = f.send(slot);
|
||||
var translatedSlot = (ClientboundContainerSetSlotPacket) slotEvent.getPacket().getHandle();
|
||||
assertTrue(translatedSlot.getStateId() > translatedContent.stateId());
|
||||
assertEquals(slot.getContainerId(), translatedSlot.getContainerId());
|
||||
assertEquals(slot.getSlot(), translatedSlot.getSlot());
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(slot.getItem(), translatedSlot.getItem()));
|
||||
assertEquals(nativeState, f.menu.getStateId(), "Translation must not touch native state");
|
||||
var hash = new net.minecraft.network.HashedStack.ActualItem(net.minecraft.world.item.Items.EMERALD.builtInRegistryHolder(),
|
||||
17, new net.minecraft.network.HashedPatchMap(java.util.Map.of(), java.util.Set.of()));
|
||||
var changes = new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<net.minecraft.network.HashedStack>();
|
||||
changes.put(3, hash);
|
||||
for (ContainerInput input : ContainerInput.values()) {
|
||||
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(f.menu.containerId,
|
||||
translatedSlot.getStateId(), (short) -999,
|
||||
(byte) (input == ContainerInput.PICKUP_ALL ? 0 : 40), input, changes, hash);
|
||||
var received = f.receive(original);
|
||||
assertFalse(received.isCancelled()); // Non-take actions must also be translated.
|
||||
var translated = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket) received.getPacket().getHandle();
|
||||
assertEquals(nativeState, translated.stateId());
|
||||
assertEquals(original.containerId(), translated.containerId());
|
||||
assertEquals(original.slotNum(), translated.slotNum());
|
||||
assertEquals(original.buttonNum(), translated.buttonNum());
|
||||
assertSame(original.containerInput(), translated.containerInput());
|
||||
assertEquals(original.changedSlots(), translated.changedSlots());
|
||||
assertSame(original.carriedItem(), translated.carriedItem());
|
||||
assertEquals(nativeState, f.menu.getStateId());
|
||||
}
|
||||
var expected = f.output().clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.received.getLast().isCancelled());
|
||||
var admitted = (net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
|
||||
packet.received.getLast().getPacket().getHandle();
|
||||
assertEquals(nativeState, admitted.stateId());
|
||||
f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(2, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exhaustionSuppressesFramesAndRevokesAlreadyQueuedPaymentWithoutClosingView() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
NativeAdvancedAnvilProtocolTest.seed(f);
|
||||
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled());
|
||||
nearExhaustion(f);
|
||||
f.player.updateInventory();
|
||||
assertEquals(Integer.MAX_VALUE, lastFrame(packet));
|
||||
long count = ownedFrameCount(packet);
|
||||
f.player.updateInventory();
|
||||
assertEquals(count, ownedFrameCount(packet), "No repeated/wrapped owned frame may escape");
|
||||
packet.click(f.menu.containerId, Integer.MAX_VALUE, 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
f.tick();
|
||||
assertAll("Exhaustion must also revoke an already consumed admission",
|
||||
() -> assertEquals(0, rolls.get()),
|
||||
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0)),
|
||||
() -> assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1)),
|
||||
() -> assertTrue(f.player.getItemOnCursor().isEmpty()),
|
||||
() -> assertNull(f.output()),
|
||||
() -> assertSame(f.menu.getBukkitView(), f.player.getOpenInventory()));
|
||||
f.prepareAgain();
|
||||
assertNull(f.output());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"content,cancel", "content,replace", "slot,cancel", "slot,replace"})
|
||||
void finallyCancelledOrReplacedOutboundFrameCannotAuthorizePaymentAndFreshFrameRecovers(String kind, String mode)
|
||||
throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
NativeAdvancedAnvilProtocolTest.seed(f);
|
||||
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
var tamperedVersion = new AtomicInteger();
|
||||
var tamperOnce = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var type = kind.equals("content") ? com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS
|
||||
: com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
|
||||
var foreign = new com.comphenix.protocol.events.PacketAdapter(f.plugin,
|
||||
com.comphenix.protocol.events.ListenerPriority.MONITOR, type) {
|
||||
@Override public void onPacketSending(com.comphenix.protocol.events.PacketEvent event) {
|
||||
if (!tamperOnce.getAndSet(false)) return;
|
||||
Object raw = event.getPacket().getHandle();
|
||||
tamperedVersion.set(raw instanceof ClientboundContainerSetContentPacket content
|
||||
? content.stateId() : ((ClientboundContainerSetSlotPacket) raw).getStateId());
|
||||
if (mode.equals("cancel")) event.setCancelled(true);
|
||||
else event.setPacket(new com.comphenix.protocol.events.PacketContainer(type,
|
||||
new ClientboundContainerSetContentPacket(f.menu.containerId, tamperedVersion.get(),
|
||||
f.menu.getItems(), f.menu.getCarried())));
|
||||
}
|
||||
};
|
||||
f.protocol.addPacketListener(foreign);
|
||||
try {
|
||||
var outgoing = f.send(kind.equals("content")
|
||||
? new ClientboundContainerSetContentPacket(f.menu.containerId, f.menu.getStateId(), f.menu.getItems(), f.menu.getCarried())
|
||||
: new ClientboundContainerSetSlotPacket(f.menu.containerId, f.menu.getStateId(), 2, f.menu.getSlot(2).getItem()));
|
||||
assertEquals(mode.equals("cancel"), outgoing.isCancelled());
|
||||
assertTrue(tamperedVersion.get() >= 32768);
|
||||
packet.click(f.menu.containerId, tamperedVersion.get(), 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(before, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
var expected = f.output().clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.received.getLast().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(1, rolls.get());
|
||||
} finally { f.protocol.removePacketListener(foreign); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeCloseRevokesQueuedAdmissionAndReusedWindowKeepsVersionsUnique() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
NativeAdvancedAnvilProtocolTest.seed(f);
|
||||
int oldWire = f.clientState(f.menu);
|
||||
var before = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.clicks.getLast().isCancelled());
|
||||
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f, NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
|
||||
assertFalse(f.menu.bypassCost);
|
||||
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
|
||||
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
|
||||
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
f.nativePlayer.containerMenu = reopened;
|
||||
reopened.setSynchronizer(packet.synchronizer);
|
||||
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(before));
|
||||
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(before));
|
||||
int newWire = f.clientState(reopened);
|
||||
assertTrue(newWire > oldWire);
|
||||
packet.click(reopened.containerId, oldWire, 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertEquals(1, packet.clicks.size(), "Reopened stale intent never reaches Bukkit");
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(before, reopened.getBukkitView().getTopInventory().getItem(1));
|
||||
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.received.getLast().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(1, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only boundary arrangement avoids billions of packets; production has no reset hook.
|
||||
private static void nearExhaustion(NativeAnvilFixture f) throws ReflectiveOperationException {
|
||||
var listenerField = SpigotInventoryHelperPlugin.class.getDeclaredField("advancedAnvil");
|
||||
listenerField.setAccessible(true);
|
||||
var listener = listenerField.get(f.plugin);
|
||||
var protocolField = AdvancedAnvilListener.class.getDeclaredField("protocol");
|
||||
protocolField.setAccessible(true);
|
||||
var guard = protocolField.get(listener);
|
||||
var versionsField = AdvancedAnvilProtocolGuard.class.getDeclaredField("versions");
|
||||
versionsField.setAccessible(true);
|
||||
var nextField = AnvilFrameVersions.class.getDeclaredField("next");
|
||||
nextField.setAccessible(true);
|
||||
nextField.setLong(versionsField.get(guard), Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
private static long ownedFrameCount(NativeAnvilPacketFixture packet) {
|
||||
return packet.packets.stream().filter(raw ->
|
||||
raw instanceof ClientboundContainerSetContentPacket content
|
||||
&& content.containerId() == packet.anvil.menu.containerId
|
||||
|| raw instanceof ClientboundContainerSetSlotPacket slot
|
||||
&& slot.getContainerId() == packet.anvil.menu.containerId).count();
|
||||
}
|
||||
|
||||
private static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
|
||||
T packet) throws ReflectiveOperationException {
|
||||
// Paper's real item sanitizer consults the server registry during class initialization.
|
||||
// Supply only that external registry port, and restore the singleton even on failure.
|
||||
var serverField = net.minecraft.server.MinecraftServer.class.getDeclaredField("SERVER");
|
||||
assertEquals(net.minecraft.server.MinecraftServer.class, serverField.getType());
|
||||
serverField.setAccessible(true);
|
||||
Object previous = serverField.get(null);
|
||||
var server = org.mockito.Mockito.mock(net.minecraft.server.MinecraftServer.class);
|
||||
org.mockito.Mockito.when(server.registryAccess()).thenReturn(org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry().freeze());
|
||||
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
serverField.set(null, server);
|
||||
codec.encode(buffer, packet);
|
||||
T decoded = codec.decode(buffer);
|
||||
assertEquals(0, buffer.readableBytes());
|
||||
return decoded;
|
||||
} finally {
|
||||
buffer.release();
|
||||
serverField.set(null, previous);
|
||||
}
|
||||
}
|
||||
|
||||
static int lastFrame(NativeAnvilPacketFixture packet) {
|
||||
for (int index = packet.packets.size() - 1; index >= 0; index--) {
|
||||
var raw = packet.packets.get(index);
|
||||
if (raw instanceof ClientboundContainerSetContentPacket content
|
||||
&& content.containerId() == packet.anvil.menu.containerId) return content.stateId();
|
||||
if (raw instanceof ClientboundContainerSetSlotPacket slot
|
||||
&& slot.getContainerId() == packet.anvil.menu.containerId) return slot.getStateId();
|
||||
}
|
||||
throw new AssertionError("No real displayed menu frame");
|
||||
}
|
||||
|
||||
private static String risk(NativeAnvilFixture f) {
|
||||
return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||
.serialize(f.actionBars.getLast());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.configuration.GlobalConfiguration;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.HashedStack;
|
||||
import net.minecraft.network.PacketProcessor;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.network.ServerCommonPacketListenerImpl;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.entity.player.Abilities;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.inventory.ContainerSynchronizer;
|
||||
import net.minecraft.world.inventory.InventoryMenu;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
|
||||
/** Real packet classification/event dispatch/native clicked; explicit world/thread/network ports only. */
|
||||
final class NativeAnvilPacketFixture implements AutoCloseable {
|
||||
final NativeAnvilFixture anvil;
|
||||
final MinecraftServer nativeServer = mock(MinecraftServer.class);
|
||||
final PacketProcessor processor = mock(PacketProcessor.class);
|
||||
// Final external CraftServer port only; retain the suite's subclass maker everywhere else.
|
||||
final CraftServer craftServer = mock(CraftServer.class,
|
||||
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
final Connection connection;
|
||||
final ServerGamePacketListenerImpl handler;
|
||||
private final GlobalConfiguration previousConfiguration;
|
||||
final Abilities abilities = new Abilities();
|
||||
final InventoryMenu inventoryMenu;
|
||||
final ContainerSynchronizer synchronizer;
|
||||
final List<Packet<?>> packets = new ArrayList<>();
|
||||
final java.util.Map<Packet<?>, String> privateRiskAtSend = new java.util.IdentityHashMap<>();
|
||||
final List<InventoryClickEvent> clicks = new ArrayList<>();
|
||||
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
|
||||
final List<net.minecraft.world.item.ItemStack> drops = new ArrayList<>();
|
||||
|
||||
NativeAnvilPacketFixture(NativeAnvilFixture anvil) throws Exception {
|
||||
this.anvil = anvil;
|
||||
previousConfiguration = GlobalConfiguration.get();
|
||||
if (previousConfiguration == null) {
|
||||
// Connection static initialization and the packet equipment guard need default config.
|
||||
// No file/configuration I/O; restore the singleton at this fixture's boundary.
|
||||
var config = new GlobalConfiguration();
|
||||
config.misc = config.new Misc();
|
||||
config.unsupportedSettings = config.new UnsupportedSettings();
|
||||
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
|
||||
}
|
||||
connection = mock(Connection.class);
|
||||
handler = mock(ServerGamePacketListenerImpl.class);
|
||||
when(anvil.level.getServer()).thenReturn(nativeServer);
|
||||
when(anvil.level.getCraftServer()).thenReturn(craftServer);
|
||||
when(anvil.nativePlayer.isAlive()).thenReturn(true);
|
||||
doCallRealMethod().when(anvil.nativePlayer).doCloseContainer();
|
||||
doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
|
||||
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean());
|
||||
when(nativeServer.packetProcessor()).thenReturn(processor);
|
||||
when(processor.isSameThread()).thenReturn(true);
|
||||
when(craftServer.getPluginManager()).thenReturn(anvil.events);
|
||||
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
|
||||
when(anvil.nativePlayer.getAbilities()).thenReturn(abilities);
|
||||
when(anvil.nativePlayer.registryAccess()).thenReturn(
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
survival();
|
||||
inventoryMenu = new InventoryMenu(anvil.realInventory, true, anvil.nativePlayer);
|
||||
field(Player.class, anvil.nativePlayer, "inventoryMenu", InventoryMenu.class, inventoryMenu);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "server", MinecraftServer.class, nativeServer);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "cserver", CraftServer.class, craftServer);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "connection", Connection.class, connection);
|
||||
field(ServerGamePacketListenerImpl.class, handler, "player", net.minecraft.server.level.ServerPlayer.class,
|
||||
anvil.nativePlayer);
|
||||
anvil.nativePlayer.connection = handler;
|
||||
doAnswer(inv -> {
|
||||
Packet<?> sent = inv.getArgument(0);
|
||||
var outgoing = anvil.send(sent);
|
||||
if (outgoing != null) {
|
||||
if (outgoing.isCancelled()) return null;
|
||||
Object wire = outgoing.getPacket().getHandle();
|
||||
if (!(wire instanceof Packet<?> translated)) throw new AssertionError("Unsupported wire payload");
|
||||
sent = translated;
|
||||
}
|
||||
packets.add(sent);
|
||||
privateRiskAtSend.put(sent, anvil.actionBars.isEmpty() ? ""
|
||||
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||
.serialize(anvil.actionBars.getLast()));
|
||||
return null;
|
||||
}).when(connection).send(any(Packet.class), any(), anyBoolean());
|
||||
doCallRealMethod().when(handler).send(any(Packet.class));
|
||||
doCallRealMethod().when(handler).send(any(Packet.class), any());
|
||||
doCallRealMethod().when(handler).handleContainerClick(any(ServerboundContainerClickPacket.class));
|
||||
// Reuse the runtime's cheap native synchronizer, including actual hashed remote slots and packets.
|
||||
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
|
||||
.getDeclaredConstructor(net.minecraft.server.level.ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
Object created = constructor.newInstance(anvil.nativePlayer);
|
||||
if (!(created instanceof ContainerSynchronizer nativeSynchronizer)) {
|
||||
throw new IllegalStateException("Unexpected native player synchronizer: " + created.getClass());
|
||||
}
|
||||
synchronizer = nativeSynchronizer;
|
||||
field(net.minecraft.server.level.ServerPlayer.class, anvil.nativePlayer, "containerSynchronizer",
|
||||
ContainerSynchronizer.class, synchronizer);
|
||||
inventoryMenu.setSynchronizer(synchronizer);
|
||||
anvil.menu.setSynchronizer(synchronizer);
|
||||
doCallRealMethod().when(anvil.player).updateInventory();
|
||||
doCallRealMethod().when(anvil.player).closeInventory();
|
||||
doCallRealMethod().when(anvil.player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
// An observer of the registered event, never a replacement event supplied to production.
|
||||
listen(EventPriority.MONITOR, clicks::add);
|
||||
}
|
||||
|
||||
void survival() {
|
||||
abilities.instabuild = false;
|
||||
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(false);
|
||||
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.SURVIVAL);
|
||||
}
|
||||
|
||||
void creative() {
|
||||
abilities.instabuild = true;
|
||||
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
|
||||
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(true);
|
||||
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.CREATIVE);
|
||||
}
|
||||
|
||||
void listen(EventPriority priority, Consumer<InventoryClickEvent> observer) {
|
||||
anvil.events.registerEvent(InventoryClickEvent.class, new Listener() {}, priority,
|
||||
(ignored, event) -> {
|
||||
if (!(event instanceof InventoryClickEvent click)) {
|
||||
throw new IllegalStateException("Unexpected native click event: " + event.getClass());
|
||||
}
|
||||
observer.accept(click);
|
||||
}, anvil.plugin, false);
|
||||
}
|
||||
|
||||
void click(int slot, int button, ContainerInput input) {
|
||||
click(anvil.nativePlayer.containerMenu.containerId, anvil.clientState(anvil.nativePlayer.containerMenu),
|
||||
slot, button, input);
|
||||
}
|
||||
|
||||
void click(int containerId, int stateId, int slot, int button, ContainerInput input) {
|
||||
var raw = new ServerboundContainerClickPacket(containerId, stateId,
|
||||
(short) slot, (byte) button, input, new Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
|
||||
var event = anvil.receive(raw);
|
||||
received.add(event);
|
||||
if (!event.isCancelled()) {
|
||||
Object admitted = event.getPacket().getHandle();
|
||||
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
|
||||
handler.handleContainerClick(translated);
|
||||
}
|
||||
anvil.assertNoEventErrors();
|
||||
}
|
||||
|
||||
void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason reason) {
|
||||
anvil.nativePlayer.closeContainer(reason);
|
||||
anvil.assertNoEventErrors();
|
||||
}
|
||||
|
||||
void dieAndClose() {
|
||||
// The world/damage boundary reports death; native menu removal owns remainder drops.
|
||||
when(anvil.nativePlayer.isAlive()).thenReturn(false);
|
||||
when(anvil.nativePlayer.isDeadOrDying()).thenReturn(true);
|
||||
when(anvil.player.isDead()).thenReturn(true);
|
||||
closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DEATH);
|
||||
}
|
||||
|
||||
void inactiveView() {
|
||||
anvil.nativePlayer.containerMenu = inventoryMenu;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, previousConfiguration);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError("Could not restore native config port", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void field(Class<?> owner, Object target, String name, Class<T> type, T value)
|
||||
throws ReflectiveOperationException {
|
||||
var field = owner.getDeclaredField(name);
|
||||
if (field.getType() != type) throw new IllegalStateException("Unexpected native field type: " + field);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
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.assertTrue;
|
||||
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.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.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 NativeCobwebPluginWiringTest {
|
||||
private static final NamespacedKey COBWEB = key("string_cobweb");
|
||||
private static final Set<NamespacedKey> EXISTING = Set.of(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();
|
||||
serverUnderTest = mock(Server.class);
|
||||
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 2618 (MC: 26.2)");
|
||||
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.2");
|
||||
when(serverUnderTest.getBukkitVersion()).thenReturn("26.2.build.2618-stable");
|
||||
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
logHandler = new Handler() {
|
||||
@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.scheduler.BukkitScheduler.class);
|
||||
when(serverUnderTest.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(any(), any(Runnable.class),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()))
|
||||
.thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class));
|
||||
events = new PaperPluginManagerImpl(serverUnderTest, commands, null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@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(COBWEB));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
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());
|
||||
|
||||
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(COBWEB));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cobwebKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(COBWEB, 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(COBWEB));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(COBWEB));
|
||||
assertEquals(0, grants.get());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA, COBWEB), registry.keySet());
|
||||
assertSame(conflict, registry.get(COBWEB));
|
||||
|
||||
registry.remove(COBWEB);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(COBWEB));
|
||||
assertTrue(known.contains(COBWEB));
|
||||
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(COBWEB));
|
||||
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 partialDiscoveryListenerFailureRollsBackOnlyCobwebAndWithholdsJoinDiscovery() {
|
||||
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() == 3) {
|
||||
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(COBWEB));
|
||||
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(COBWEB));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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.2.build.2618-stable");
|
||||
commands.registerAll(createdPlugin.getName(),
|
||||
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
return createdPlugin;
|
||||
} catch (java.io.IOException | InvalidDescriptionException exception) {
|
||||
throw new AssertionError("Could not read generated plugin metadata", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known,
|
||||
AtomicInteger cobwebGrants, AtomicBoolean failCobweb) {
|
||||
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(COBWEB)) {
|
||||
if (failCobweb.get()) {
|
||||
throw new IllegalStateException("simulated player discovery failure");
|
||||
}
|
||||
cobwebGrants.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(COBWEB);
|
||||
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,405 @@
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Registered recipe -> native display/autofill -> ordinary or quick-move result completion. */
|
||||
class NativeCobwebRecipeTest {
|
||||
private static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "string_cobweb");
|
||||
private static final List<Integer> STRING_SLOTS = List.of(0, 2, 4, 6, 8);
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registersFiveStringXForOneOrdinaryCobwebInBuildingCategory() {
|
||||
ShapedRecipe recipe = registeredRecipe();
|
||||
assertEquals(KEY, recipe.getKey());
|
||||
assertEquals(List.of("S S", " S ", "S S"), List.of(recipe.getShape()));
|
||||
assertEquals(List.of(Material.STRING),
|
||||
((RecipeChoice.MaterialChoice) recipe.getChoiceMap().get('S')).getChoices());
|
||||
assertEquals(CraftingBookCategory.BUILDING, recipe.getCategory());
|
||||
assertOrdinaryCobweb(recipe.getResult(), 1);
|
||||
var nativeRecipe = nativeRecipe(recipe);
|
||||
assertEquals(net.minecraft.world.item.crafting.RecipeBookCategories.CRAFTING_BUILDING_BLOCKS,
|
||||
nativeRecipe.recipeBookCategory());
|
||||
var display = (net.minecraft.world.item.crafting.display.ShapedCraftingRecipeDisplay)
|
||||
nativeRecipe.display().getFirst();
|
||||
assertEquals(3, display.width());
|
||||
assertEquals(3, display.height());
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
if (STRING_SLOTS.contains(slot)) {
|
||||
var expected = CraftRecipe.toIngredient(
|
||||
new RecipeChoice.MaterialChoice(Material.STRING), false).display();
|
||||
assertEquals(expected, display.ingredients().get(slot));
|
||||
} else {
|
||||
assertEquals(net.minecraft.world.item.crafting.display.SlotDisplay.Empty.INSTANCE,
|
||||
display.ingredients().get(slot));
|
||||
}
|
||||
}
|
||||
var result = (net.minecraft.world.item.crafting.display.SlotDisplay.ItemStackSlotDisplay)
|
||||
display.result();
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(result.stack().create()), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void incorrectPatternsMissingIngredientsAndTwoByTwoCraftingNeverMatch() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var level = mock(net.minecraft.world.level.Level.class);
|
||||
List<net.minecraft.world.item.ItemStack> valid = pattern();
|
||||
assertTrue(recipe.matches(input(valid), level));
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
List<net.minecraft.world.item.ItemStack> wrong = new ArrayList<>(valid);
|
||||
wrong.set(slot, STRING_SLOTS.contains(slot) ? net.minecraft.world.item.ItemStack.EMPTY
|
||||
: CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
assertFalse(recipe.matches(input(wrong), level), "Wrong occupancy at slot " + slot);
|
||||
}
|
||||
List<net.minecraft.world.item.ItemStack> wrongMaterial = new ArrayList<>(valid);
|
||||
wrongMaterial.set(4, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT)));
|
||||
assertFalse(recipe.matches(input(wrongMaterial), level));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(2, 2,
|
||||
java.util.stream.IntStream.range(0, 4).mapToObj(i ->
|
||||
CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))).toList()), level));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordinaryNativeResultSlotConsumesFiveStringAndPreservesRemaindersAndMetadata() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot,
|
||||
CraftItemStack.asNMSCopy(marked(Material.STRING, 2)));
|
||||
}
|
||||
var grid = fixture.menu().craftSlots;
|
||||
assertTrue(recipe.matches(nativeInput(grid), fixture.player().level()));
|
||||
List<ItemStack> before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var assembled = recipe.assemble(nativeInput(grid));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(assembled), 1);
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList(),
|
||||
"Preview must neither consume ingredients nor mutate metadata");
|
||||
setResult(fixture.menu(), recipe, assembled);
|
||||
var slot = fixture.menu().getResultSlot();
|
||||
var taken = slot.remove(1);
|
||||
slot.onTake(fixture.player(), taken);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(taken), 1);
|
||||
assertEquals(5, count(grid, Material.STRING));
|
||||
for (int index : STRING_SLOTS) {
|
||||
assertEquals(marked(Material.STRING, 1), CraftItemStack.asBukkitCopy(grid.getItem(index)));
|
||||
}
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 3, 64})
|
||||
void nativeAutofillAndQuickMoveBatchConsumeFiveStringPerCobweb(int crafts) {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
fixture.inventory().setItem(i, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, crafts)));
|
||||
}
|
||||
ItemStack unrelated = marked(Material.DIAMOND, 7);
|
||||
fixture.inventory().setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
fixture.menu().craftSlots.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
var action = autofill(fixture, recipe);
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(5L * crafts, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB), "Autofill creates no output");
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT), "Relocated input must be preserved");
|
||||
for (int attempt = 0; attempt < crafts; attempt++) {
|
||||
assertTrue(recipe.matches(nativeInput(fixture.menu().craftSlots), fixture.player().level()));
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
var moved = fixture.menu().quickMoveStack(fixture.player(), 0);
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(moved), 1);
|
||||
assertEquals(5L * (crafts - attempt - 1), count(fixture.menu().craftSlots, Material.STRING));
|
||||
}
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertEquals(crafts, count(fixture.inventory(), Material.COBWEB));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(fixture.inventory().getItem(20)));
|
||||
assertEquals(2, count(fixture.inventory(), Material.DIRT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryWithholdsQuickMoveOutputWithoutSpendingString() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var fixture = crafting();
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
fixture.inventory().setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 64)));
|
||||
}
|
||||
for (int slot : STRING_SLOTS) {
|
||||
fixture.menu().craftSlots.setItem(slot, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING)));
|
||||
}
|
||||
setResult(fixture.menu(), recipe, recipe.assemble(nativeInput(fixture.menu().craftSlots)));
|
||||
assertTrue(fixture.menu().quickMoveStack(fixture.player(), 0).isEmpty());
|
||||
assertEquals(5, count(fixture.menu().craftSlots, Material.STRING));
|
||||
assertEquals(36L * 64, count(fixture.inventory(), Material.DIRT));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertOrdinaryCobweb(CraftItemStack.asBukkitCopy(fixture.menu().resultSlots.getItem(0)), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void insufficientStringShowsGhostRecipeWithoutCreatingOutputOrSpendingItems() {
|
||||
var fixture = crafting();
|
||||
fixture.inventory().setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.STRING, 4)));
|
||||
var action = autofill(fixture, nativeRecipe(registeredRecipe()));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, action);
|
||||
assertEquals(4, count(fixture.inventory(), Material.STRING));
|
||||
assertEquals(0, count(fixture.inventory(), Material.COBWEB));
|
||||
assertTrue(fixture.menu().craftSlots.isEmpty());
|
||||
assertTrue(fixture.menu().resultSlots.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeDiscoveryCancellationWithholdsKnowledgeAndLaterRetryWorks() {
|
||||
var recipe = nativeRecipe(registeredRecipe());
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe);
|
||||
var book = new TestRecipeBook((key, consumer) -> consumer.accept(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(0),
|
||||
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
AtomicBoolean cancel = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
var event = (org.bukkit.event.player.PlayerRecipeDiscoverEvent) invocation.getArgument(0);
|
||||
event.setCancelled(cancel.get());
|
||||
return null;
|
||||
}).when(events).callEvent(any(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertFalse(book.contains(nativeKey()));
|
||||
cancel.set(false);
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(nativeKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedCobwebRecipe() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey foreign = new NamespacedKey("minecraft", "stick");
|
||||
Recipe unrelated = mock(Recipe.class);
|
||||
registry.put(foreign, unrelated);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.get(invocation.getArgument(0)));
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registry.put(KEY, invocation.getArgument(0));
|
||||
throw new IllegalStateException("simulated post-insertion failure");
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
assertEquals(Map.of(foreign, unrelated), registry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRegistrationDoesNotClaimOwnershipAndRemovalFailureCanRetry() {
|
||||
AtomicInteger registrations = new AtomicInteger();
|
||||
AtomicBoolean allow = new AtomicBoolean();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registrations.incrementAndGet();
|
||||
return allow.get();
|
||||
});
|
||||
var registrar = new CobwebRecipeRegistrar(KEY);
|
||||
assertFalse(registrar.register(serverUnderTest));
|
||||
registrar.unregister();
|
||||
allow.set(true);
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertTrue(registrar.register(serverUnderTest));
|
||||
assertEquals(2, registrations.get(), "Owned registration must be idempotent");
|
||||
AtomicInteger removals = new AtomicInteger();
|
||||
when(serverUnderTest.removeRecipe(KEY)).thenAnswer(invocation -> removals.incrementAndGet() > 1);
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
registrar.unregister();
|
||||
assertEquals(2, removals.get());
|
||||
}
|
||||
|
||||
private static ShapedRecipe registeredRecipe() {
|
||||
List<Recipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add(invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new CobwebRecipeRegistrar(KEY).register(serverUnderTest));
|
||||
assertEquals(1, registered.size());
|
||||
return (ShapedRecipe) registered.getFirst();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) -> {
|
||||
if (choice != null) {
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false));
|
||||
}
|
||||
});
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, recipe.getShape()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static CraftingFixture crafting() {
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
return new CraftingFixture(player, inventory, new net.minecraft.world.inventory.CraftingMenu(0, inventory));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(
|
||||
CraftingFixture fixture, net.minecraft.world.item.crafting.ShapedRecipe recipe) {
|
||||
var grid = fixture.menu().craftSlots;
|
||||
var slots = fixture.menu().getInputGridSlots();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
@Override public void clearCraftingContent() { grid.clearContent(); }
|
||||
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid), fixture.player().level());
|
||||
}
|
||||
};
|
||||
return net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(access, 3, 3, slots, slots,
|
||||
fixture.inventory(), new net.minecraft.world.item.crafting.RecipeHolder<>(nativeKey(), recipe),
|
||||
true, false);
|
||||
}
|
||||
|
||||
private static void setResult(net.minecraft.world.inventory.CraftingMenu menu,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe recipe, net.minecraft.world.item.ItemStack result) {
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(nativeKey(), recipe);
|
||||
menu.craftSlots.setCurrentRecipe(holder);
|
||||
menu.resultSlots.setRecipeUsed(holder);
|
||||
menu.resultSlots.setItem(0, result);
|
||||
}
|
||||
|
||||
private static List<net.minecraft.world.item.ItemStack> pattern() {
|
||||
return java.util.stream.IntStream.range(0, 9).mapToObj(slot -> STRING_SLOTS.contains(slot)
|
||||
? CraftItemStack.asNMSCopy(new ItemStack(Material.STRING))
|
||||
: net.minecraft.world.item.ItemStack.EMPTY).toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(net.minecraft.world.Container grid) {
|
||||
return input(java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(List<net.minecraft.world.item.ItemStack> items) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container container, Material material) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static ItemStack marked(Material material, int amount) {
|
||||
ItemStack item = new ItemStack(material, amount);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text("Keep metadata"));
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
|
||||
PersistentDataType.STRING, "unchanged");
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void assertOrdinaryCobweb(ItemStack item, int amount) {
|
||||
assertEquals(Material.COBWEB, item.getType());
|
||||
assertEquals(amount, item.getAmount());
|
||||
assertFalse(item.hasItemMeta());
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey() {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(KEY.getNamespace(), KEY.getKey()));
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
private static final class TestRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) { super(resolver); }
|
||||
}
|
||||
|
||||
private record CraftingFixture(net.minecraft.world.entity.player.Player player,
|
||||
net.minecraft.world.entity.player.Inventory inventory, net.minecraft.world.inventory.CraftingMenu menu) {}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
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.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
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.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur coverage for the alternative Dispenser's registered native path. */
|
||||
class NativeDispenserRecipeTest {
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registeredRecipeDiscoversIndependentlyAndPreservesVanillaDispenserRecipe() {
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
NamespacedKey vanilla = new NamespacedKey("minecraft", "dispenser");
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
Recipe vanillaRecipe = mock(Recipe.class);
|
||||
registry.put(vanilla, vanillaRecipe);
|
||||
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);
|
||||
var events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin();
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(key);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
serverUnderTest, plugin, registrar, plugin.getLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
ShapelessRecipe recipe = (ShapelessRecipe) registry.get(key);
|
||||
assertEquals(CraftingBookCategory.REDSTONE, recipe.getCategory());
|
||||
assertEquals(Material.DISPENSER, recipe.getResult().getType());
|
||||
assertEquals(1, recipe.getResult().getAmount());
|
||||
assertFalse(recipe.getResult().hasItemMeta());
|
||||
assertEquals(List.of(Material.DROPPER, Material.TRIPWIRE_HOOK),
|
||||
recipe.getChoiceList().stream()
|
||||
.map(org.bukkit.inventory.RecipeChoice.MaterialChoice.class::cast)
|
||||
.map(choice -> choice.getChoices().getFirst())
|
||||
.toList());
|
||||
assertEquals(Set.of(key), known);
|
||||
|
||||
lifecycle.disable();
|
||||
|
||||
assertEquals(Set.of(vanilla), registry.keySet());
|
||||
assertEquals(vanillaRecipe, registry.get(vanilla));
|
||||
assertEquals(Set.of(key), known, "Disable must retain native recipe-book knowledge");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeAutofillAndRepeatedCompletionConsumeOnePairPerDispenser() {
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(org.bukkit.plugin.PluginManager.class));
|
||||
List<ShapelessRecipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add((ShapelessRecipe) invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
assertTrue(new DispenserRecipeRegistrar(new NamespacedKey(
|
||||
"spigotinventoryhelper", "dropper_dispenser")).register(serverUnderTest));
|
||||
ShapelessRecipe bukkit = registered.getFirst();
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
nativePlayer, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DROPPER, 3)));
|
||||
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.TRIPWIRE_HOOK, 3)));
|
||||
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class),
|
||||
3, 3, nativePlayer);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 2)));
|
||||
List<net.minecraft.world.inventory.Slot> slots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapelessRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapelessRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
|
||||
access, 3, 3, slots, slots, inventory, holder, true, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertEquals(3, count(grid, Material.DROPPER));
|
||||
assertEquals(3, count(grid, Material.TRIPWIRE_HOOK));
|
||||
assertEquals(2, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount).sum());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
int crafted = 0;
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
net.minecraft.world.item.ItemStack assembled = nativeRecipe.assemble(nativeInput(grid));
|
||||
ItemStack preview = CraftItemStack.asBukkitCopy(assembled);
|
||||
assertEquals(Material.DISPENSER, preview.getType());
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertFalse(preview.hasItemMeta());
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var result = new net.minecraft.world.inventory.ResultContainer();
|
||||
result.setRecipeUsed(holder);
|
||||
result.setItem(0, assembled);
|
||||
var slot = new net.minecraft.world.inventory.ResultSlot(
|
||||
nativePlayer, grid, result, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = slot.remove(1);
|
||||
slot.onTake(nativePlayer, taken);
|
||||
crafted += taken.getCount();
|
||||
}
|
||||
|
||||
assertEquals(3, crafted);
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertFalse(inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.anyMatch(item -> item.getType() == Material.DISPENSER),
|
||||
"Preview and autofill must not create an extra result");
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
|
||||
net.minecraft.world.Container grid) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList());
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>
|
||||
nativeRecipeKey(NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(
|
||||
net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(
|
||||
key.getNamespace(), key.getKey()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(
|
||||
ShapelessRecipe recipe) {
|
||||
List<net.minecraft.world.item.crafting.Ingredient> ingredients = recipe.getChoiceList()
|
||||
.stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
|
||||
return new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Player discoveryPlayer(
|
||||
String name, Set<NamespacedKey> known) {
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.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();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeDispenserRecipe"));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile(
|
||||
"SpigotInventoryHelper", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
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.assertTrue;
|
||||
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.never;
|
||||
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.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.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
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.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Pinned Purpur recipe-book and crafting behavior with CraftServer, CraftPlayer, and client
|
||||
* boundaries doubled. No listening server, world, or client is started by this harness.
|
||||
*/
|
||||
class NativeRecipeBookTest {
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
serverUnderTest = mock(Server.class);
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothSortingStickVariantsAppearInTheEquipmentCategory() {
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
|
||||
assertEquals(2, registered.bukkit().size());
|
||||
assertTrue(registered.bukkit().stream()
|
||||
.allMatch(recipe -> recipe.getCategory() == CraftingBookCategory.EQUIPMENT));
|
||||
assertTrue(registered.bukkit().stream().allMatch(recipe ->
|
||||
registered.items().isSortingStick(recipe.getResult())
|
||||
&& recipe.getResult().getAmount() == 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverRecipeBookPersistsKnownHighlightsAndVanillaStateWithOneNotification() {
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> nativeRecipes = new HashMap<>();
|
||||
List<net.minecraft.world.item.crafting.RecipeHolder<?>> holders = new ArrayList<>();
|
||||
for (ShapedRecipe bukkit : registered.bukkit()) {
|
||||
var key = nativeRecipeKey(bukkit.getKey());
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
nativeRecipes.put(key, nativeRecipe);
|
||||
holders.add(new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe));
|
||||
}
|
||||
AtomicInteger nextDisplayId = new AtomicInteger();
|
||||
Map<net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>,
|
||||
Integer> displayIds = new HashMap<>();
|
||||
nativeRecipes.keySet().forEach(key -> displayIds.put(key, nextDisplayId.getAndIncrement()));
|
||||
TestServerRecipeBook book = new TestServerRecipeBook((key, consumer) -> {
|
||||
var recipe = nativeRecipes.get(key);
|
||||
consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(displayIds.get(key)),
|
||||
recipe.display().getFirst(), OptionalInt.empty(), recipe.recipeBookCategory(),
|
||||
Optional.of(recipe.placementInfo().ingredients())));
|
||||
});
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
List<org.bukkit.event.Event> fired = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
fired.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(events).callEvent(any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(2, book.addRecipes(holders, nativePlayer));
|
||||
assertEquals(0, book.addRecipes(holders, nativePlayer));
|
||||
|
||||
var vanillaKey = nativeRecipeKey(new NamespacedKey("minecraft", "stick"));
|
||||
book.add(vanillaKey);
|
||||
book.highlight(vanillaKey);
|
||||
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
assertEquals(new LinkedHashSet<>(nativeRecipes.keySet()),
|
||||
new LinkedHashSet<>(book.pack().known().stream().filter(nativeRecipes::containsKey).toList()));
|
||||
assertEquals(2, fired.size());
|
||||
assertTrue(fired.stream().map(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class::cast)
|
||||
.allMatch(org.bukkit.event.player.PlayerRecipeDiscoverEvent::shouldShowNotification));
|
||||
|
||||
net.minecraft.nbt.Tag serialized = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
net.minecraft.stats.ServerRecipeBook.Packed decoded = net.minecraft.stats.ServerRecipeBook.Packed.CODEC
|
||||
.parse(net.minecraft.nbt.NbtOps.INSTANCE, serialized).getOrThrow();
|
||||
TestServerRecipeBook restored = new TestServerRecipeBook();
|
||||
restored.loadUntrusted(decoded, key -> true);
|
||||
|
||||
assertTrue(restored.contains(vanillaKey));
|
||||
nativeRecipes.keySet().forEach(key -> assertTrue(restored.contains(key)));
|
||||
assertEquals(new LinkedHashSet<>(decoded.known()), new LinkedHashSet<>(restored.pack().known()));
|
||||
assertEquals(new LinkedHashSet<>(decoded.highlight()),
|
||||
new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
int knownBefore = restored.pack().known().size();
|
||||
nativeRecipes.keySet().forEach(restored::add);
|
||||
assertEquals(knownBefore, restored.pack().known().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleDiscoversOnlineAndJoinersThenReenablesWithoutLosingKnowledge() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
registry.put(unrelatedKey, 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);
|
||||
PluginManager events = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Plugin plugin = enabledPlugin("NativeLifecycle");
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
org.bukkit.entity.Player online = discoveryPlayer("Online", onlineKnown);
|
||||
org.bukkit.entity.Player joining = discoveryPlayer("Joining", joiningKnown);
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
RegisteredRecipes firstRegistration = recipeServices();
|
||||
RecipeLifecycle first = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
firstRegistration.registrar(), plugin.getLogger());
|
||||
|
||||
assertTrue(first.enable());
|
||||
events.callEvent(new org.bukkit.event.player.PlayerJoinEvent(joining, "joined"));
|
||||
first.disable();
|
||||
|
||||
assertEquals(Set.of(unrelatedKey), registry.keySet());
|
||||
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(firstRegistration.registrar().recipeKeys()), joiningKnown);
|
||||
RegisteredRecipes secondRegistration = recipeServices();
|
||||
assertTrue(new RecipeLifecycle(serverUnderTest, plugin,
|
||||
secondRegistration.registrar(), plugin.getLogger()).enable());
|
||||
assertTrue(registry.keySet().containsAll(secondRegistration.registrar().recipeKeys()));
|
||||
assertTrue(registry.containsKey(unrelatedKey));
|
||||
verify(online, times(1)).discoverRecipes(any());
|
||||
verify(online, never()).undiscoverRecipes(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleConflictRollsBackOwnedVariantAndAwardsNobody() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
NamespacedKey unrelatedKey = new NamespacedKey("minecraft", "stick");
|
||||
NamespacedKey conflictKey = new NamespacedKey(
|
||||
"spigotinventoryhelper", "sorting_stick_recipe_barrel");
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(unrelatedKey, mock(Recipe.class));
|
||||
registry.put(conflictKey, conflict);
|
||||
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);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(discoveryPlayer("Online", known))).when(serverUnderTest).getOnlinePlayers();
|
||||
RegisteredRecipes services = recipeServices();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest,
|
||||
mock(Plugin.class), services.registrar(), Logger.getAnonymousLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(unrelatedKey, conflictKey), registry.keySet());
|
||||
assertTrue(known.isEmpty());
|
||||
assertEquals(conflict, registry.get(conflictKey));
|
||||
verify(events, never()).registerEvents(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeLifecycleFailureRemovesPartiallyRegisteredListenerAndRecipes() {
|
||||
Map<NamespacedKey, Recipe> registry = new HashMap<>();
|
||||
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);
|
||||
registry.put(((Keyed) recipe).getKey(), recipe);
|
||||
return true;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
PluginManager registeredEvents = new PaperPluginManagerImpl(serverUnderTest,
|
||||
new SimpleCommandMap(serverUnderTest, new HashMap<>()), null);
|
||||
PluginManager throwingEvents = mock(PluginManager.class);
|
||||
doAnswer(invocation -> {
|
||||
registeredEvents.registerEvents(invocation.getArgument(0), invocation.getArgument(1));
|
||||
throw new IllegalStateException("simulated post-registration failure");
|
||||
}).when(throwingEvents).registerEvents(any(), any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(throwingEvents);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
Plugin plugin = enabledPlugin("NativeLifecycleFailure");
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
RegisteredRecipes services = recipeServices();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
services.registrar(), plugin.getLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
registeredEvents.callEvent(new org.bukkit.event.player.PlayerJoinEvent(
|
||||
discoveryPlayer("Joining", joiningKnown), "joined"));
|
||||
|
||||
assertTrue(registry.isEmpty());
|
||||
assertTrue(joiningKnown.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeAutofillAndResultSlotConsumeEachVariantIntoOneAuthenticatedResult() {
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(mock(PluginManager.class));
|
||||
RegisteredRecipes registered = registerRecipes();
|
||||
for (int index = 0; index < registered.bukkit().size(); index++) {
|
||||
ShapedRecipe bukkit = registered.bukkit().get(index);
|
||||
Material storage = index == 0 ? Material.CHEST : Material.BARREL;
|
||||
var nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
nativeRecipeKey(bukkit.getKey()), nativeRecipe);
|
||||
var nativePlayer = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(nativePlayer.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(
|
||||
mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(
|
||||
nativePlayer, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
inventory.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(storage)));
|
||||
inventory.setItem(1, CraftItemStack.asNMSCopy(new ItemStack(Material.REDSTONE)));
|
||||
inventory.setItem(2, CraftItemStack.asNMSCopy(new ItemStack(Material.STICK)));
|
||||
ItemStack unrelated = new ItemStack(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class),
|
||||
3, 3, nativePlayer);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
|
||||
List<net.minecraft.world.inventory.Slot> gridSlots =
|
||||
java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(
|
||||
grid, slot, 0, 0))
|
||||
.toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe>() {
|
||||
@Override
|
||||
public void fillCraftSlotsStackedContents(
|
||||
net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearCraftingContent() {
|
||||
grid.clearContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.ShapedRecipe> candidate) {
|
||||
return candidate.value().matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
|
||||
access, 3, 3, gridSlots, gridSlots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING,
|
||||
action);
|
||||
assertTrue(nativeRecipe.matches(nativeInput(grid),
|
||||
mock(net.minecraft.world.level.Level.class)));
|
||||
assertEquals(1, count(grid, storage));
|
||||
assertEquals(1, count(grid, Material.REDSTONE));
|
||||
assertEquals(1, count(grid, Material.STICK));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT)
|
||||
.mapToInt(ItemStack::getAmount).sum());
|
||||
List<ItemStack> gridBeforePreview = grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList();
|
||||
net.minecraft.world.item.ItemStack nativeResult = nativeRecipe.assemble(nativeInput(grid));
|
||||
ItemStack preview = CraftItemStack.asBukkitCopy(nativeResult);
|
||||
assertTrue(registered.items().isSortingStick(preview));
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertEquals(gridBeforePreview, grid.getItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy).toList());
|
||||
|
||||
grid.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<
|
||||
net.minecraft.world.item.crafting.CraftingRecipe>(holder.id(), holder.value()));
|
||||
var resultContainer = new net.minecraft.world.inventory.ResultContainer();
|
||||
resultContainer.setRecipeUsed(holder);
|
||||
resultContainer.setItem(0, nativeResult);
|
||||
var resultSlot = new net.minecraft.world.inventory.ResultSlot(
|
||||
nativePlayer, grid, resultContainer, 0, 0, 0);
|
||||
net.minecraft.world.item.ItemStack taken = resultSlot.remove(1);
|
||||
resultSlot.onTake(nativePlayer, taken);
|
||||
|
||||
ItemStack crafted = CraftItemStack.asBukkitCopy(taken);
|
||||
assertTrue(registered.items().isSortingStick(crafted));
|
||||
assertEquals(1, crafted.getAmount());
|
||||
assertTrue(resultContainer.isEmpty());
|
||||
assertTrue(grid.isEmpty());
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertFalse(inventory.getNonEquipmentItems().stream()
|
||||
.map(CraftItemStack::asBukkitCopy)
|
||||
.anyMatch(registered.items()::isSortingStick),
|
||||
"Preview, autofill, and completion must not create an extra result");
|
||||
}
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.CraftingInput nativeInput(
|
||||
net.minecraft.world.Container grid) {
|
||||
List<net.minecraft.world.item.ItemStack> items =
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).toList();
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3, items);
|
||||
}
|
||||
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize())
|
||||
.mapToObj(grid::getItem).map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == material)
|
||||
.mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
private static Plugin enabledPlugin(String loggerName) {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getName()).thenReturn("SpigotInventoryHelper");
|
||||
when(plugin.getLogger()).thenReturn(Logger.getLogger(loggerName));
|
||||
PluginDescriptionFile description = new PluginDescriptionFile(
|
||||
"SpigotInventoryHelper", "test", "unused.Main");
|
||||
when(plugin.getDescription()).thenReturn(description);
|
||||
when(plugin.getPluginMeta()).thenReturn(description);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private static org.bukkit.entity.Player discoveryPlayer(
|
||||
String name, Set<NamespacedKey> known) {
|
||||
org.bukkit.entity.Player player = mock(org.bukkit.entity.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();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private static RegisteredRecipes recipeServices() {
|
||||
SortingStickItemFactory items = new SortingStickItemFactory(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick"));
|
||||
SortingStickRecipeRegistrar recipes = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe"),
|
||||
items, SortingStickRecipeSpec.defaultRecipe());
|
||||
return new RegisteredRecipes(items, recipes, List.of());
|
||||
}
|
||||
|
||||
private static RegisteredRecipes registerRecipes() {
|
||||
List<ShapedRecipe> registered = new ArrayList<>();
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
registered.add((ShapedRecipe) invocation.getArgument(0));
|
||||
return true;
|
||||
});
|
||||
RegisteredRecipes services = recipeServices();
|
||||
assertTrue(services.registrar().register(serverUnderTest));
|
||||
return new RegisteredRecipes(services.items(), services.registrar(), registered);
|
||||
}
|
||||
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>>
|
||||
nativeRecipeKey(NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(
|
||||
key.getNamespace(), key.getKey()));
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapedRecipe nativeRecipe(ShapedRecipe recipe) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
recipe.getChoiceMap().forEach((symbol, choice) ->
|
||||
ingredients.put(symbol, CraftRecipe.toIngredient(choice, false)));
|
||||
var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(
|
||||
ingredients, recipe.getShape());
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
pattern, CraftItemStack.asTemplate(recipe.getResult()));
|
||||
}
|
||||
|
||||
private static final class TestServerRecipeBook extends net.minecraft.stats.ServerRecipeBook {
|
||||
TestServerRecipeBook() {
|
||||
this((key, consumer) -> { });
|
||||
}
|
||||
|
||||
TestServerRecipeBook(net.minecraft.stats.ServerRecipeBook.DisplayResolver resolver) {
|
||||
super(resolver);
|
||||
}
|
||||
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) {
|
||||
highlight.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
private record RegisteredRecipes(
|
||||
SortingStickItemFactory items,
|
||||
SortingStickRecipeRegistrar registrar,
|
||||
List<ShapedRecipe> bukkit
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
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 NativeRuntime() {}
|
||||
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (bootstrapped) {
|
||||
return;
|
||||
}
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(),
|
||||
List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
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);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
mock-maker-subclass
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class AdvancedAnvilPolicyTest {
|
||||
@Test
|
||||
void defaultCurveMatchesApprovedTableAndHigherLevels() {
|
||||
AdvancedAnvilPolicy policy = AdvancedAnvilPolicy.defaults();
|
||||
assertEquals(new AdvancedAnvilPolicy(20, 5, 99), policy);
|
||||
int[] expected = {0, 20, 25, 35, 50, 70, 95, 99, 99, 99};
|
||||
for (int excess = 0; excess < expected.length; excess++) {
|
||||
assertEquals(expected[excess], policy.failurePercent(excess), "E=" + excess);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsIndependentlyControlBaseStepAndCapIncludingEndpoints() {
|
||||
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 100).failurePercent(0));
|
||||
assertEquals(0, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(1));
|
||||
assertEquals(5, new AdvancedAnvilPolicy(0, 5, 99).failurePercent(2));
|
||||
assertEquals(20, new AdvancedAnvilPolicy(20, 0, 99).failurePercent(Integer.MAX_VALUE));
|
||||
assertEquals(0, new AdvancedAnvilPolicy(100, 100, 0).failurePercent(Integer.MAX_VALUE));
|
||||
assertEquals(100, new AdvancedAnvilPolicy(20, 5, 100).failurePercent(7));
|
||||
assertEquals(12, new AdvancedAnvilPolicy(20, 5, 12).failurePercent(1));
|
||||
assertEquals(99, AdvancedAnvilPolicy.defaults().failurePercent(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void triangularGrowthMatchesExactArithmeticWithoutOverflowOrArbitraryCap() {
|
||||
int[] excesses = {0, 1, 2, 3, 6, 7, 10, 100, 65536, 1000000, Integer.MAX_VALUE};
|
||||
for (int base : new int[] {0, 1, 20, 100}) {
|
||||
for (int step : new int[] {0, 1, 5, 100}) {
|
||||
for (int cap : new int[] {0, 1, 99, 100}) {
|
||||
AdvancedAnvilPolicy policy = new AdvancedAnvilPolicy(base, step, cap);
|
||||
for (int excess : excesses) {
|
||||
BigInteger e = BigInteger.valueOf(excess);
|
||||
int expected = excess == 0 ? 0 : e.multiply(e.subtract(BigInteger.ONE))
|
||||
.divide(BigInteger.TWO).multiply(BigInteger.valueOf(step))
|
||||
.add(BigInteger.valueOf(base)).min(BigInteger.valueOf(cap)).intValueExact();
|
||||
assertEquals(expected, policy.failurePercent(excess), policy + " E=" + excess);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidPolicyFieldsAndNegativeExcess() {
|
||||
for (int invalid : new int[] {-1, 101, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(invalid, 5, 99));
|
||||
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, invalid, 99));
|
||||
assertThrows(IllegalArgumentException.class, () -> new AdvancedAnvilPolicy(20, 5, invalid));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> AdvancedAnvilPolicy.defaults().failurePercent(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.ConsoleCommandSender;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
final class AdvancedAnvilSettingsTest {
|
||||
private static final String BASE = "advanced-anvil-base-failure-percent";
|
||||
private static final String STEP = "advanced-anvil-risk-step-percent";
|
||||
private static final String CAP = "advanced-anvil-max-failure-percent";
|
||||
private static final String PERMISSION = "spigotinventoryhelper.admin";
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void boundConsoleCommandPublishesOnlyAfterDurableMainThreadAcknowledgmentAndRestarts()
|
||||
throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", BASE);
|
||||
assertEquals(List.of(BASE + " = 20"), fixture.messages);
|
||||
assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", BASE, "37");
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
assertEquals(20, settings.policy().basePercent());
|
||||
assertTrue(changes.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty(), "No success before acknowledgment");
|
||||
Fixture restart = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings loaded = restart.open(ignored -> {})) {
|
||||
assertEquals(37, loaded.policy().basePercent(), "Acknowledgment follows durable save");
|
||||
}
|
||||
acknowledgment.run();
|
||||
assertEquals(37, settings.policy().basePercent());
|
||||
assertEquals(List.of(settings.policy()), changes);
|
||||
assertEquals(List.of(BASE + " = 37 (saved)"), fixture.messages);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentAndPartialConfigurationUseDefaultsWithoutStartupWrites() throws Exception {
|
||||
Fixture fixture = new Fixture(directory.resolve("absent"));
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Startup callback"))) {
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertFalse(Files.exists(directory.resolve("absent")));
|
||||
}
|
||||
Files.writeString(directory.resolve("config.yml"), STEP + ": 0\nunrelated: keep\n");
|
||||
try (AdvancedAnvilSettings settings = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(new AdvancedAnvilPolicy(20, 0, 99), settings.policy());
|
||||
}
|
||||
assertEquals(STEP + ": 0\nunrelated: keep\n", Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"-1", "101", "20.0", "'20'", "true", "null", "999999999999999999999999"})
|
||||
void presentInvalidSettingsFailClosedForEveryKey(String invalid) throws Exception {
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
Files.writeString(directory.resolve("config.yml"), key + ": " + invalid + "\n");
|
||||
Fixture fixture = new Fixture(directory);
|
||||
assertThrows(IOException.class, () -> fixture.open(ignored -> fail("Partial installation")));
|
||||
assertNull(fixture.command.getTabCompleter());
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
assertEquals(key + ": " + invalid + "\n", Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"[broken", "- list", "!!java/object:java.lang.Runtime {}",
|
||||
"advanced-anvil-base-failure-percent: 20\nadvanced-anvil-base-failure-percent: 30\n"})
|
||||
void malformedYamlAndDuplicateSettingsFailBeforeOpening(String document) throws Exception {
|
||||
Files.writeString(directory.resolve("config.yml"), document);
|
||||
assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> {}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializedQueuedWritesComposeBeforeAnyAcknowledgmentAndPreserveUnknownKeys() throws Exception {
|
||||
Files.writeString(directory.resolve("config.yml"), "unrelated:\n list: [one, two]\nother: true\n");
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", BASE, "31");
|
||||
fixture.execute("config", STEP, "7");
|
||||
fixture.execute("config", CAP, "100");
|
||||
fixture.execute("config", BASE, "42");
|
||||
List<Runnable> callbacks = new ArrayList<>();
|
||||
for (int count = 0; count < 4; count++) {
|
||||
callbacks.add(fixture.nextAcknowledgment());
|
||||
}
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
fixture.execute("config", BASE);
|
||||
assertEquals(List.of(BASE + " = 20"), fixture.messages);
|
||||
fixture.messages.clear();
|
||||
callbacks.getLast().run();
|
||||
AdvancedAnvilPolicy expected = new AdvancedAnvilPolicy(42, 7, 100);
|
||||
assertEquals(expected, settings.policy());
|
||||
for (Runnable callback : callbacks) {
|
||||
callback.run(); // Older and repeated acknowledgments cannot regress or duplicate publication.
|
||||
}
|
||||
assertEquals(List.of(expected), changes);
|
||||
assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages);
|
||||
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(expected, restarted.policy());
|
||||
}
|
||||
Object document = new org.yaml.snakeyaml.Yaml().load(Files.readString(directory.resolve("config.yml")));
|
||||
assertInstanceOf(java.util.Map.class, document);
|
||||
java.util.Map<?, ?> values = (java.util.Map<?, ?>) document;
|
||||
assertEquals(java.util.Map.of("list", List.of("one", "two")), values.get("unrelated"));
|
||||
assertEquals(true, values.get("other"));
|
||||
try (var files = Files.list(directory)) {
|
||||
assertEquals(List.of("config.yml"), files.map(path -> path.getFileName().toString()).toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSaveRetainsEffectivePolicyAndDoesNotPoisonSubsequentQueuedUpdates() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", BASE, "31");
|
||||
fixture.nextAcknowledgment().run();
|
||||
Path file = directory.resolve("config.yml");
|
||||
String prior = Files.readString(file);
|
||||
Files.delete(file);
|
||||
Files.createDirectory(file); // Real unwritable target, not a mocked save failure.
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", STEP, "17");
|
||||
fixture.nextAcknowledgment().run();
|
||||
assertEquals(new AdvancedAnvilPolicy(31, 5, 99), settings.policy());
|
||||
assertEquals(1, changes.size());
|
||||
assertEquals(List.of("Configuration save failed; effective settings unchanged."), fixture.messages);
|
||||
Files.delete(file);
|
||||
Files.writeString(file, prior);
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", CAP, "0");
|
||||
fixture.nextAcknowledgment().run();
|
||||
assertEquals(new AdvancedAnvilPolicy(31, 5, 0), settings.policy());
|
||||
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(settings.policy(), restarted.policy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"-1", "101", "2147483648", "1.5", "true", "", " 20", "20 ", "garbage"})
|
||||
void invalidCommandValuesNeverWriteOrPublish(String invalid) throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Invalid update"))) {
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
fixture.execute("config", key, invalid);
|
||||
}
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
assertEquals(3, fixture.messages.size());
|
||||
assertTrue(fixture.messages.stream().allMatch(message -> message.contains("0 through 100")));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"37", "+37", "00037", "00000000000000000037", "-0"})
|
||||
void validManuallyEnteredIntegersAreNotLimitedToCompletionSuggestions(String raw) throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
|
||||
fixture.execute("config", BASE, raw);
|
||||
assertTrue(fixture.messages.isEmpty(), "Valid integer should be accepted, not rejected");
|
||||
fixture.nextAcknowledgment().run();
|
||||
assertEquals(Integer.parseInt(raw), settings.policy().basePercent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedRoutesAndExtraArgumentsAreRejectedWithoutMutation() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Unsupported update"))) {
|
||||
for (String[] args : List.of(new String[0], new String[] {"config"},
|
||||
new String[] {"other", BASE, "10"}, new String[] {"config", "unknown", "10"},
|
||||
new String[] {"config", BASE, "10", "extra"})) {
|
||||
fixture.execute(args);
|
||||
if (args.length >= 2) {
|
||||
assertEquals(List.of(), fixture.complete(args));
|
||||
}
|
||||
}
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
assertTrue(fixture.messages.stream().allMatch(message -> message.startsWith("Usage:")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationDeniesReadsWritesCompletionAndExecutorBypassForPlayersAndConsole() throws Exception {
|
||||
for (CommandSender sender : List.of(mock(ConsoleCommandSender.class), mock(Player.class))) {
|
||||
Fixture fixture = new Fixture(directory, sender);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> fail("Denied update"))) {
|
||||
when(sender.hasPermission(PERMISSION)).thenReturn(false);
|
||||
fixture.execute("config", BASE);
|
||||
fixture.execute("config", BASE, "10");
|
||||
assertTrue(fixture.command.getExecutor().onCommand(sender, fixture.command,
|
||||
"inventoryhelperadmin", new String[] {"config", BASE, "10"}));
|
||||
assertEquals(List.of(), fixture.complete());
|
||||
assertEquals(List.of(), fixture.complete("config", BASE, ""));
|
||||
assertEquals(List.of("Permission denied."), fixture.messages);
|
||||
verify(sender, times(2)).sendMessage(Component.text("Permission denied."));
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizedPlayerCanSetEndpointsAndReadAllThreeEffectiveSettings() throws Exception {
|
||||
Fixture fixture = new Fixture(directory, mock(Player.class));
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
fixture.execute("config", key, "100");
|
||||
fixture.nextAcknowledgment().run();
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", key);
|
||||
assertEquals(List.of(key + " = 100"), fixture.messages);
|
||||
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
|
||||
assertEquals(List.of("100", defaultValue, "0"), fixture.complete("config", key, ""));
|
||||
}
|
||||
assertEquals(new AdvancedAnvilPolicy(100, 100, 100), settings.policy());
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
fixture.execute("config", key, "0");
|
||||
fixture.nextAcknowledgment().run();
|
||||
}
|
||||
assertEquals(new AdvancedAnvilPolicy(0, 0, 0), settings.policy());
|
||||
assertEquals(List.of("0", "20", "100"), fixture.complete("config", BASE, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundCompletionIsStablePrefixFilteredDeduplicatedAndSideEffectFree() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
|
||||
assertNotNull(fixture.command.getExecutor());
|
||||
assertNotNull(fixture.command.getTabCompleter());
|
||||
assertEquals(List.of("config"), fixture.complete());
|
||||
assertEquals(List.of("config"), fixture.complete(""));
|
||||
assertEquals(List.of("config"), fixture.complete("con"));
|
||||
assertEquals(List.of(), fixture.complete("bad"));
|
||||
assertEquals(List.of(BASE, STEP, CAP), fixture.complete("config", ""));
|
||||
assertEquals(List.of(STEP), fixture.complete("config", "advanced-anvil-risk"));
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
|
||||
assertEquals(List.of(defaultValue, "0", "100"), fixture.complete("config", key, ""));
|
||||
assertEquals(List.of("100"), fixture.complete("config", key, "1"));
|
||||
assertEquals(List.of(), fixture.complete("config", key, "garbage"));
|
||||
}
|
||||
assertEquals(List.of(), fixture.complete("other", BASE, ""));
|
||||
assertEquals(List.of(), fixture.complete("config", "unknown", ""));
|
||||
assertEquals(List.of(), fixture.complete("config", BASE, "", "extra"));
|
||||
assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty());
|
||||
fixture.execute("config", BASE, "37");
|
||||
fixture.nextAcknowledgment().run();
|
||||
Files.delete(directory.resolve("config.yml"));
|
||||
Files.createDirectory(directory.resolve("config.yml")); // Completion must never read disk.
|
||||
fixture.messages.clear();
|
||||
assertEquals(List.of("37", "20", "0", "100"), fixture.complete("config", BASE, ""));
|
||||
assertEquals(List.of("37"), fixture.complete("config", BASE, "3"));
|
||||
assertEquals(37, settings.policy().basePercent());
|
||||
assertTrue(fixture.acknowledgments.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionRevokedBeforeAcknowledgmentDoesNotLeakSavedValue() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", BASE, "37");
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false);
|
||||
acknowledgment.run();
|
||||
assertEquals(37, settings.policy().basePercent());
|
||||
assertEquals(1, changes.size());
|
||||
assertTrue(fixture.messages.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeIsNonblockingIdempotentAndSuppressesLateCallbacksAndCompletion() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
AdvancedAnvilSettings settings = fixture.open(changes::add);
|
||||
try {
|
||||
fixture.execute("config", BASE, "37");
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
assertTimeout(java.time.Duration.ofMillis(250), settings::close);
|
||||
settings.close();
|
||||
acknowledgment.run();
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertTrue(changes.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty());
|
||||
assertEquals(List.of(), fixture.complete());
|
||||
fixture.execute("config", BASE, "50");
|
||||
assertEquals(List.of("Configuration unavailable."), fixture.messages);
|
||||
assertThrows(IllegalStateException.class, () -> settings.bind(fixture.command));
|
||||
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(37, restarted.policy().basePercent());
|
||||
}
|
||||
} finally {
|
||||
settings.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final Plugin plugin = mock(Plugin.class);
|
||||
private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
private final CommandSender sender;
|
||||
private final BlockingQueue<Runnable> acknowledgments = new LinkedBlockingQueue<>();
|
||||
private final List<String> messages = new ArrayList<>();
|
||||
private final PluginCommand command;
|
||||
private final Thread mainThread = Thread.currentThread();
|
||||
|
||||
Fixture(Path dataFolder) throws Exception {
|
||||
this(dataFolder, mock(ConsoleCommandSender.class));
|
||||
}
|
||||
|
||||
Fixture(Path dataFolder, CommandSender sender) throws Exception {
|
||||
this.sender = sender;
|
||||
Server server = mock(Server.class);
|
||||
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(plugin.getDataFolder()).thenReturn(dataFolder.toFile());
|
||||
when(plugin.isEnabled()).thenReturn(true);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(sender.hasPermission(PERMISSION)).thenReturn(true);
|
||||
doAnswer(call -> {
|
||||
assertSame(mainThread, Thread.currentThread(), "Feedback must stay on main thread");
|
||||
messages.add(call.getArgument(0, String.class));
|
||||
return null;
|
||||
}).when(sender).sendMessage(anyString());
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
|
||||
assertNotSame(mainThread, Thread.currentThread(), "Persistence must be asynchronous");
|
||||
acknowledgments.add(call.getArgument(1, Runnable.class));
|
||||
return mock(BukkitTask.class);
|
||||
});
|
||||
Constructor<PluginCommand> constructor =
|
||||
PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
|
||||
constructor.setAccessible(true);
|
||||
command = constructor.newInstance("inventoryhelperadmin", plugin);
|
||||
}
|
||||
|
||||
AdvancedAnvilSettings open(Consumer<AdvancedAnvilPolicy> changes) throws Exception {
|
||||
AdvancedAnvilSettings settings = AdvancedAnvilSettings.open(plugin, changes);
|
||||
settings.bind(command);
|
||||
return settings;
|
||||
}
|
||||
|
||||
void execute(String... args) {
|
||||
assertTrue(command.execute(sender, "inventoryhelperadmin", args));
|
||||
}
|
||||
|
||||
List<String> complete(String... args) {
|
||||
return command.tabComplete(sender, "inventoryhelperadmin", args);
|
||||
}
|
||||
|
||||
Runnable nextAcknowledgment() throws InterruptedException {
|
||||
Runnable acknowledgment = acknowledgments.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(acknowledgment, "Expected scheduled durable acknowledgment");
|
||||
return acknowledgment;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class DispenserRecipeRegistrarTest {
|
||||
@Test
|
||||
void registersOrdinaryDispenserRecipeInRedstoneCategory() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack dispenser = mock(ItemStack.class);
|
||||
when(dispenser.getType()).thenReturn(Material.DISPENSER);
|
||||
when(dispenser.getAmount()).thenReturn(1);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> dispenser, recipes);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
|
||||
ArgumentCaptor<ItemStack> result = ArgumentCaptor.forClass(ItemStack.class);
|
||||
verify(recipes).create(org.mockito.ArgumentMatchers.eq(key), result.capture());
|
||||
assertEquals(Material.DISPENSER, result.getValue().getType());
|
||||
assertEquals(1, result.getValue().getAmount());
|
||||
assertFalse(result.getValue().hasItemMeta());
|
||||
verify(recipe).addIngredient(Material.DROPPER);
|
||||
verify(recipe).addIngredient(Material.TRIPWIRE_HOOK);
|
||||
verify(recipe).setCategory(CraftingBookCategory.REDSTONE);
|
||||
verify(server).addRecipe(recipe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionAfterInsertionRemovesOnlyTheOwnedRecipe() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.getRecipe(key)).thenReturn(null, recipe);
|
||||
when(server.addRecipe(recipe)).thenThrow(new IllegalStateException("registry failed"));
|
||||
when(server.removeRecipe(key)).thenReturn(true);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server).removeRecipe(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRemovalRetainsOwnershipForRetry() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.addRecipe(recipe)).thenReturn(true);
|
||||
when(server.removeRecipe(key)).thenReturn(false, true);
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, times(2)).removeRecipe(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingRecipeIsPreservedAndNotClaimed() {
|
||||
Server server = mock(Server.class);
|
||||
ShapelessRecipe recipe = mock(ShapelessRecipe.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(server.getRecipe(key)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
|
||||
DispenserRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(DispenserRecipeRegistrar.RecipeFactory.class);
|
||||
when(recipes.create(any(), any())).thenReturn(recipe);
|
||||
DispenserRecipeRegistrar registrar = new DispenserRecipeRegistrar(
|
||||
key, () -> result, recipes);
|
||||
|
||||
assertFalse(registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, never()).addRecipe(any());
|
||||
verify(server, never()).removeRecipe(any());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
@@ -23,4 +25,13 @@ final class PluginFoundationTest {
|
||||
assertEquals("1.20", plugin.get("api-version"));
|
||||
assertNotNull(Class.forName((String) plugin.get("main")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginEntrypointOwnsIndependentEnableDisableRecipeLifecycleWiring() throws Exception {
|
||||
Field lifecycles = SpigotInventoryHelperPlugin.class.getDeclaredField("recipeLifecycles");
|
||||
Method disable = SpigotInventoryHelperPlugin.class.getDeclaredMethod("onDisable");
|
||||
|
||||
assertEquals(RecipeLifecycleGroup.class, lifecycles.getType());
|
||||
assertEquals(void.class, disable.getReturnType());
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,51 @@ class PurpurDistributionTest {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
|
||||
assertFalse(metadata.containsKey("permissions"));
|
||||
assertFalse(metadata.containsKey("commands"));
|
||||
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
|
||||
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
|
||||
assertEquals(java.util.Set.of("spigotinventoryhelper.admin"), permissions.keySet());
|
||||
assertEquals("op", ((Map<?, ?>) permissions.get("spigotinventoryhelper.admin")).get("default"));
|
||||
assertEquals(java.util.Set.of("inventoryhelperadmin"), commands.keySet());
|
||||
assertEquals("spigotinventoryhelper.admin", ((Map<?, ?>) commands.get("inventoryhelperadmin")).get("permission"));
|
||||
Map<?, ?> config = new Yaml().load(jar.getInputStream(jar.getJarEntry("config.yml")));
|
||||
assertEquals(Map.of("advanced-anvil-base-failure-percent", 20,
|
||||
"advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99), config);
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilListener.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/NativeAnvilGeneration.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.class"));
|
||||
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeDiscovery.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycle.class"));
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/RecipeLifecycleGroup.class"));
|
||||
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")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentsPinnedNativeCoverageAndDoubledBoundaries() throws Exception {
|
||||
String readme = Files.readString(Path.of("README.md"));
|
||||
|
||||
assertTrue(readme.contains("ServerPlaceRecipe"));
|
||||
assertTrue(readme.contains("ServerRecipeBook"));
|
||||
assertTrue(readme.contains("ResultSlot"));
|
||||
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"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pluginBytecodeTargetsJava25() throws Exception {
|
||||
try (var jar = new JarFile(System.getProperty("distribution.jar"));
|
||||
@@ -45,7 +80,11 @@ class PurpurDistributionTest {
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
assertTrue(build.contains("nativeInventoryTest"));
|
||||
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
|
||||
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class RecipeDiscoveryTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void warningIdentifiesTheRecipeWhoseDiscoveryPostconditionFailed() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Partial", known);
|
||||
doAnswer(invocation -> 0).when(player).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(
|
||||
keys, "alternative Dispenser recipe", logger(warnings));
|
||||
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
assertTrue(warnings.getFirst().getMessage().contains("alternative Dispenser recipe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineDiscoveryAwardsOnlyUnknownRecipesAndRepeatedDiscoveryIsIdempotent() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
known.add(keys.getFirst());
|
||||
Player player = player("Online", known);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.discoverAll(List.of(player));
|
||||
discovery.discoverAll(List.of(player));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player, times(1)).discoverRecipes(List.of(keys.getLast()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void joiningPlayerReceivesBothUnknownRecipes() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Joining", known);
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, Logger.getAnonymousLogger());
|
||||
|
||||
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player).discoverRecipes(keys);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPostconditionIsLoggedAndOnlyTheMissingRecipeRetries() {
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
Player player = player("Partial", known);
|
||||
AtomicBoolean firstAward = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
if (firstAward.getAndSet(false)) {
|
||||
known.add(requested.getFirst());
|
||||
return 1;
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
}).when(player).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), known);
|
||||
verify(player).discoverRecipes(keys);
|
||||
verify(player).discoverRecipes(List.of(keys.getLast()));
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayerExceptionIsLoggedIsolatedAndRetriedLater() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>();
|
||||
AtomicBoolean firstAttempt = new AtomicBoolean(true);
|
||||
Player failing = player("Failing", failedKnown);
|
||||
doAnswer(invocation -> {
|
||||
if (firstAttempt.getAndSet(false)) {
|
||||
throw new IllegalStateException("simulated discovery failure");
|
||||
}
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
failedKnown.addAll(requested);
|
||||
return requested.size();
|
||||
}).when(failing).discoverRecipes(any());
|
||||
List<LogRecord> warnings = new ArrayList<>();
|
||||
RecipeDiscovery discovery = new RecipeDiscovery(keys, logger(warnings));
|
||||
|
||||
assertDoesNotThrow(() -> discovery.discover(failing));
|
||||
discovery.discover(player("Healthy", healthyKnown));
|
||||
discovery.discover(failing);
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), healthyKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), failedKnown);
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals(IllegalStateException.class, warnings.getFirst().getThrown().getClass());
|
||||
}
|
||||
|
||||
private static Logger logger(List<LogRecord> records) {
|
||||
Logger logger = Logger.getAnonymousLogger();
|
||||
logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { records.add(record); }
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
return logger;
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known) {
|
||||
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();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
final class RecipeLifecycleGroupTest {
|
||||
@Test
|
||||
void cleanupFailureDoesNotPreventOtherRecipeCleanup() {
|
||||
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
|
||||
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("cleanup failed"))
|
||||
.when(dispenser).disable();
|
||||
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
|
||||
|
||||
assertThrows(IllegalStateException.class, group::disableAll);
|
||||
|
||||
verify(dispenser).disable();
|
||||
verify(sorting).disable();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneFailedRecipeLifecycleDoesNotBlockTheOther() {
|
||||
RecipeLifecycle sorting = mock(RecipeLifecycle.class);
|
||||
RecipeLifecycle dispenser = mock(RecipeLifecycle.class);
|
||||
when(sorting.enable()).thenReturn(true);
|
||||
when(dispenser.enable()).thenReturn(false);
|
||||
RecipeLifecycleGroup group = new RecipeLifecycleGroup(List.of(sorting, dispenser));
|
||||
|
||||
group.enableAll();
|
||||
group.disableAll();
|
||||
|
||||
verify(sorting).enable();
|
||||
verify(dispenser).enable();
|
||||
InOrder disabled = inOrder(dispenser, sorting);
|
||||
disabled.verify(dispenser).disable();
|
||||
disabled.verify(sorting).disable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class RecipeLifecycleTest {
|
||||
private final List<NamespacedKey> keys = List.of(
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_chest"),
|
||||
new NamespacedKey("spigotinventoryhelper", "sorting_stick_recipe_barrel"));
|
||||
|
||||
@Test
|
||||
void failedRegistrationWithholdsDiscoveryUntilALaterRetrySucceeds() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(recipes.register(server)).thenReturn(false, true);
|
||||
when(recipes.recipeKeys()).thenReturn(List.of(key));
|
||||
when(recipes.description()).thenReturn("alternative Dispenser recipe");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertFalse(lifecycle.enable());
|
||||
assertTrue(known.isEmpty());
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(key), known);
|
||||
verify(recipes, times(2)).register(server);
|
||||
verify(events).registerEvents(any(RecipeDiscovery.class),
|
||||
org.mockito.ArgumentMatchers.eq(plugin));
|
||||
}
|
||||
|
||||
@Test
|
||||
void independentlyDiscoversARegisteredAlternativeRecipe() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
RecipeRegistrar recipes = mock(RecipeRegistrar.class);
|
||||
NamespacedKey key = new NamespacedKey("spigotinventoryhelper", "dropper_dispenser");
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(List.of(key));
|
||||
when(recipes.description()).thenReturn("alternative Dispenser recipe");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", known))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
|
||||
assertEquals(Set.of(key), known);
|
||||
verify(events).registerEvents(any(RecipeDiscovery.class),
|
||||
org.mockito.ArgumentMatchers.eq(plugin));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationExceptionCleansUpAndLeavesDiscoveryInactive() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenThrow(new IllegalStateException("registry failed"));
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
boolean enabled = assertDoesNotThrow(lifecycle::enable);
|
||||
|
||||
assertFalse(enabled);
|
||||
verify(recipes).unregister();
|
||||
verify(events, never()).registerEvents(any(), any());
|
||||
verify(server, never()).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesCurrentLifecycleRecipes() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
lifecycle.disable();
|
||||
|
||||
verify(recipes).unregister();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulRegistrationDiscoversForOnlineAndLaterJoiningPlayers() {
|
||||
Server server = mock(Server.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
PluginManager events = mock(PluginManager.class);
|
||||
SortingStickRecipeRegistrar recipes = mock(SortingStickRecipeRegistrar.class);
|
||||
when(recipes.register(server)).thenReturn(true);
|
||||
when(recipes.recipeKeys()).thenReturn(keys);
|
||||
when(recipes.description()).thenReturn("Sorting Stick recipes");
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>();
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>();
|
||||
doReturn(List.of(player("Online", onlineKnown))).when(server).getOnlinePlayers();
|
||||
RecipeLifecycle lifecycle = new RecipeLifecycle(
|
||||
server, plugin, recipes, Logger.getAnonymousLogger());
|
||||
|
||||
assertTrue(lifecycle.enable());
|
||||
ArgumentCaptor<Listener> listener = ArgumentCaptor.forClass(Listener.class);
|
||||
verify(events).registerEvents(listener.capture(), org.mockito.ArgumentMatchers.eq(plugin));
|
||||
((RecipeDiscovery) listener.getValue()).onJoin(
|
||||
new PlayerJoinEvent(player("Joining", joiningKnown), "joined"));
|
||||
|
||||
assertEquals(new LinkedHashSet<>(keys), onlineKnown);
|
||||
assertEquals(new LinkedHashSet<>(keys), joiningKnown);
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known) {
|
||||
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();
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -59,4 +62,96 @@ final class SortingStickRecipeRegistrarTest {
|
||||
keys.getAllValues().stream().map(NamespacedKey::getKey).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableRemovesBothCurrentLifecycleRecipesAndNothingElse() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
|
||||
when(server.removeRecipe(any(NamespacedKey.class))).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
registrar.unregister();
|
||||
|
||||
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_chest"));
|
||||
verify(server).removeRecipe(new NamespacedKey("test", "sorting_stick_recipe_barrel"));
|
||||
verify(server, times(2)).removeRecipe(any(NamespacedKey.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void conflictingVariantRollsBackOnlyTheRecipeAddedByThisLifecycle() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
|
||||
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(chestRecipe)).thenReturn(true);
|
||||
when(server.getRecipe(barrelKey)).thenReturn(mock(org.bukkit.inventory.Recipe.class));
|
||||
when(server.removeRecipe(chestKey)).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertFalse(registrar.register(server));
|
||||
|
||||
verify(server).removeRecipe(chestKey);
|
||||
verify(server, never()).removeRecipe(barrelKey);
|
||||
verify(server, never()).addRecipe(barrelRecipe);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRemovalRetainsOwnershipForLaterRetry() {
|
||||
Server server = mock(Server.class);
|
||||
SortingStickItemFactory items = mock(SortingStickItemFactory.class);
|
||||
ItemStack result = mock(ItemStack.class);
|
||||
ShapedRecipe chestRecipe = mock(ShapedRecipe.class);
|
||||
ShapedRecipe barrelRecipe = mock(ShapedRecipe.class);
|
||||
SortingStickRecipeRegistrar.RecipeFactory recipes =
|
||||
mock(SortingStickRecipeRegistrar.RecipeFactory.class);
|
||||
NamespacedKey chestKey = new NamespacedKey("test", "sorting_stick_recipe_chest");
|
||||
NamespacedKey barrelKey = new NamespacedKey("test", "sorting_stick_recipe_barrel");
|
||||
when(items.create()).thenReturn(result);
|
||||
when(recipes.create(any(NamespacedKey.class), any(ItemStack.class)))
|
||||
.thenReturn(chestRecipe, barrelRecipe);
|
||||
when(server.addRecipe(any(ShapedRecipe.class))).thenReturn(true);
|
||||
when(server.removeRecipe(chestKey)).thenReturn(false, true);
|
||||
when(server.removeRecipe(barrelKey)).thenReturn(true);
|
||||
SortingStickRecipeRegistrar registrar = new SortingStickRecipeRegistrar(
|
||||
new NamespacedKey("test", "sorting_stick_recipe"),
|
||||
items,
|
||||
SortingStickRecipeSpec.defaultRecipe(),
|
||||
recipes
|
||||
);
|
||||
|
||||
assertTrue(registrar.register(server));
|
||||
assertThrows(IllegalStateException.class, registrar::unregister);
|
||||
registrar.unregister();
|
||||
|
||||
verify(server, times(2)).removeRecipe(chestKey);
|
||||
verify(server).removeRecipe(barrelKey);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user