Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5298543d0 | ||
|
|
990028c728 | ||
|
|
09bc15e979 | ||
|
|
a08754a3aa | ||
|
|
b4613d2abf | ||
|
|
1cd894f980 | ||
|
|
de045fb458 | ||
|
|
24a220ae4a | ||
|
|
ae899926fe |
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, automatically replaces broken tools with equivalent tools from the player's inventory, and adds a stackable-component Dispenser recipe.
|
||||
A Purpur 26.2 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils.
|
||||
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
|
||||
|
||||
@@ -10,6 +10,7 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25 or newer
|
||||
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
|
||||
|
||||
## Build
|
||||
|
||||
@@ -25,8 +26,9 @@ verifies SHA-256
|
||||
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
|
||||
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
|
||||
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
|
||||
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
|
||||
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
|
||||
The narrowly scoped anvil-generation adapter also uses this checksum-pinned native runtime as a
|
||||
compile-only dependency. Server/API and ProtocolLib classes are not bundled.
|
||||
CraftServer, CraftPlayer, and client boundaries are doubled; byte I/O is a controlled port. Anvil tests execute actual ProtocolLib forwarding, one registered local Netty pipeline/event loop, the declared library scheduler with a queued main-task port, and the real native packet queue, including final cancellation/replacement, skipped handling, owner disable and late-forward retirement. This is not evidence of live client rendering, search interaction, socket/installed-stack packet transport, or a full server lifecycle. Those remain
|
||||
non-blocking live checks.
|
||||
|
||||
## Sorting Stick recipe
|
||||
@@ -49,6 +51,22 @@ existing manual crafting and inventory behavior remain available.
|
||||
|
||||
One ordinary Dropper + Tripwire Hook produces one ordinary Dispenser through a shapeless recipe in the recipe book's Redstone category. The alternative is universally discovered for online and joining players, supports native autofill and batch completion, and leaves Minecraft's Bow-based Dispenser recipe unchanged. If the alternative cannot register safely, existing Inventory Helper behavior remains available and discovery retries after a later successful registration.
|
||||
|
||||
## Powder Snow recipe
|
||||
|
||||
One ordinary Snow Block + empty Bucket produces one ordinary Powder Snow Bucket through a
|
||||
shapeless recipe in both the personal 2×2 and crafting-table 3×3 grids. All players discover it
|
||||
in the recipe book's `MISC` category after successful registration. Native autofill and ordinary
|
||||
or shift/batch crafting consume one Snow Block and one Bucket per output, with **no additional
|
||||
empty Bucket remainder**. The filled Bucket is unstackable; native output-space and cancellation
|
||||
rules remain authoritative. Placement, recovery and Powder Snow hazards are unchanged.
|
||||
|
||||
The recipe owns only `spigotinventoryhelper:snow_powder_snow` and its discovery listener.
|
||||
Conflicts/failures withhold its discovery without disabling other features; discovery can retry
|
||||
on later joins and successful re-enable. Disable removes owned registration/listeners, not native
|
||||
recipe-book knowledge or unrelated recipes. Native automated tests cover both grids, actual
|
||||
registered events/packet completion, metadata/quantity conservation and failure boundaries;
|
||||
real-client visuals and live placement/recovery remain unperformed non-blocking follow-ups.
|
||||
|
||||
## Sorting inventories
|
||||
|
||||
Hold an authentic Sorting Stick and right-click:
|
||||
@@ -63,6 +81,50 @@ 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. Enhanced combining applies **only when the combined enchantments exceed a normal maximum**: equal levels become one higher; unequal levels keep the higher. Compatible applicable enchantments carry over. Enhanced conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
|
||||
|
||||
Within-limit combinations (for example Sharpness I + I → II or IV + IV → V) stay entirely vanilla, including normal XP/prior-work costs, naming, repair and refusals. Removing advanced inputs and inserting normal items switches back immediately; **no closing/reopening or confirmation is required**. Old advanced clicks cannot take a newly prepared normal result.
|
||||
|
||||
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
|
||||
|
||||
Enhanced merges charge **no XP** and have no "Too Expensive" restriction. The private action bar displays the result and exact success/failure chances; private chat explains that taking the result commits the attempt. Each accepted attempt consumes **one item from each input**. Success delivers one result; failure destroys both consumed items and causes an **explosion** that can hurt players and damage blocks. Native protections remain authoritative, but a protected/refused explosion does not refund the inputs.
|
||||
|
||||
Default risk uses the **largest resulting enchantment excess above its normal maximum**:
|
||||
|
||||
| Excess | Failure chance |
|
||||
| --- | --- |
|
||||
| 0 | Native combining; no enhanced roll |
|
||||
| 1 | 20% |
|
||||
| 2 | 25% |
|
||||
| 3 | 35% |
|
||||
| 4 | 50% |
|
||||
| 5 | 70% |
|
||||
| 6 | 95% |
|
||||
| 7 or more | 99% |
|
||||
|
||||
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.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 stays native and never performs an enhanced roll. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
|
||||
|
||||
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, real declared-library forwarding/local Netty pipeline/native queue dispatch, item serialization, result-taking, native packet limits, stale client generations and protected explosion boundaries. Pipeline installation on a live server, real-client transport/rendering and installed-stack behavior are not established by these controlled fixtures. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
|
||||
|
||||
## Releases
|
||||
|
||||
Gitea Actions checks pushes and pull requests and stores a development JAR. Pull requests validate conventional commits. Main-branch conventional commits drive semantic releases when the repository defines a `RELEASE_TOKEN` with contents-write permission.
|
||||
@@ -27,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"))
|
||||
@@ -77,6 +79,7 @@ val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime"
|
||||
}
|
||||
val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
@@ -87,6 +90,8 @@ dependencies {
|
||||
val nativeRuntimeJars = files(fileTree(inventoryRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareInventoryRuntime)
|
||||
// US-009's narrowly pinned generation adapter is compile-only native linkage, never bundled.
|
||||
sourceSets.main.get().compileClasspath += nativeRuntimeJars
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeInventoryTest = tasks.register<Test>("nativeInventoryTest") {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Main-thread destination reservation: no overflow drops, no overwrite of unrelated items. */
|
||||
final class AdvancedAnvilDelivery {
|
||||
private final Player player;
|
||||
private final ItemStack cursorBefore;
|
||||
private final ItemStack cursorAfter;
|
||||
private final ItemStack[] storageBefore;
|
||||
private final ItemStack[] storageAfter;
|
||||
private final ItemStack offhandBefore;
|
||||
private final ItemStack offhandAfter;
|
||||
private final boolean cursorDelivery;
|
||||
private final boolean offhandDelivery;
|
||||
|
||||
private AdvancedAnvilDelivery(Player player, ItemStack cursorBefore, ItemStack cursorAfter,
|
||||
ItemStack[] storageBefore, ItemStack[] storageAfter, ItemStack offhandBefore,
|
||||
ItemStack offhandAfter, boolean cursorDelivery, boolean offhandDelivery) {
|
||||
this.player = player;
|
||||
this.cursorBefore = cursorBefore;
|
||||
this.cursorAfter = cursorAfter;
|
||||
this.storageBefore = storageBefore;
|
||||
this.storageAfter = storageAfter;
|
||||
this.offhandBefore = offhandBefore;
|
||||
this.offhandAfter = offhandAfter;
|
||||
this.cursorDelivery = cursorDelivery;
|
||||
this.offhandDelivery = offhandDelivery;
|
||||
}
|
||||
|
||||
static AdvancedAnvilDelivery reserve(InventoryClickEvent event, ItemStack output) {
|
||||
if (!(event.getWhoClicked() instanceof Player player) || event.getRawSlot() != 2) return null;
|
||||
ItemStack cursor = copy(player.getItemOnCursor());
|
||||
switch (event.getAction()) {
|
||||
case PICKUP_ALL, PICKUP_HALF, PICKUP_ONE, PICKUP_SOME -> {
|
||||
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT) return null;
|
||||
ItemStack next = copy(output);
|
||||
if (cursor != null) {
|
||||
if (!cursor.isSimilar(output) || cursor.getAmount() >= cursor.getMaxStackSize()) return null;
|
||||
next = cursor.clone();
|
||||
next.setAmount(next.getAmount() + 1);
|
||||
}
|
||||
return new AdvancedAnvilDelivery(player, cursor, next, null, null, null, null, true, false);
|
||||
}
|
||||
case MOVE_TO_OTHER_INVENTORY -> {
|
||||
ItemStack[] before = copies(player.getInventory().getStorageContents());
|
||||
ItemStack[] after = copies(before);
|
||||
if (!allocate(after, output)) return null;
|
||||
return new AdvancedAnvilDelivery(player, cursor, null, before, after, null, null, false, false);
|
||||
}
|
||||
case HOTBAR_SWAP, HOTBAR_MOVE_AND_READD -> {
|
||||
int target = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
|
||||
if ((target < 0 || target > 8) && target != 40) return null;
|
||||
ItemStack[] before = copies(player.getInventory().getStorageContents());
|
||||
ItemStack[] after = copies(before);
|
||||
boolean offhand = target == 40;
|
||||
ItemStack old = offhand ? copy(player.getInventory().getItemInOffHand()) : copy(before[target]);
|
||||
if (!offhand) after[target] = output.clone();
|
||||
if (old != null && !allocate(after, old)) return null;
|
||||
return new AdvancedAnvilDelivery(player, cursor, null, before, after,
|
||||
offhand ? old : null, offhand ? output.clone() : null, false, offhand);
|
||||
}
|
||||
default -> { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
boolean stillValid() {
|
||||
return Objects.equals(cursorBefore, copy(player.getItemOnCursor()))
|
||||
&& (storageBefore == null || Arrays.equals(storageBefore,
|
||||
copies(player.getInventory().getStorageContents())))
|
||||
&& (!offhandDelivery || Objects.equals(offhandBefore,
|
||||
copy(player.getInventory().getItemInOffHand())));
|
||||
}
|
||||
|
||||
void deliver() {
|
||||
if (cursorDelivery) player.setItemOnCursor(copy(cursorAfter));
|
||||
if (storageAfter != null) {
|
||||
for (int slot = 0; slot < storageAfter.length; slot++) {
|
||||
if (!Objects.equals(storageBefore[slot], storageAfter[slot])) {
|
||||
player.getInventory().setItem(slot, copy(storageAfter[slot]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offhandDelivery) player.getInventory().setItemInOffHand(copy(offhandAfter));
|
||||
}
|
||||
|
||||
/** Native anvil quick-move searches hotbar8..0 then storage35..9, merge pass before empty pass. */
|
||||
private static boolean allocate(ItemStack[] storage, ItemStack source) {
|
||||
ItemStack remaining = source.clone();
|
||||
int[] order = new int[36];
|
||||
for (int i = 0; i < 9; i++) order[i] = 8 - i;
|
||||
for (int i = 9; i < 36; i++) order[i] = 44 - i;
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
for (int slot : order) {
|
||||
ItemStack present = storage[slot];
|
||||
if (pass == 0 && present != null && present.isSimilar(remaining)) {
|
||||
int moved = Math.min(remaining.getAmount(), present.getMaxStackSize() - present.getAmount());
|
||||
if (moved > 0) {
|
||||
present.setAmount(present.getAmount() + moved);
|
||||
remaining.setAmount(remaining.getAmount() - moved);
|
||||
}
|
||||
} else if (pass == 1 && present == null) {
|
||||
int moved = Math.min(remaining.getAmount(), remaining.getMaxStackSize());
|
||||
storage[slot] = remaining.asQuantity(moved);
|
||||
remaining.setAmount(remaining.getAmount() - moved);
|
||||
}
|
||||
if (remaining.getAmount() == 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ItemStack copy(ItemStack item) {
|
||||
return item == null || item.isEmpty() ? null : item.clone();
|
||||
}
|
||||
|
||||
private static ItemStack[] copies(ItemStack[] items) {
|
||||
return Arrays.stream(items).map(AdvancedAnvilDelivery::copy).toArray(ItemStack[]::new);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,374 @@
|
||||
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) return; // Normal output/XP/cancellation stay native, including after enhanced use.
|
||||
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
|
||||
var admission = protocol.consume(view, preview, event);
|
||||
if (duplicate || admission == null) {
|
||||
event.setCancelled(true);
|
||||
if (preview != null) rerender(view);
|
||||
return;
|
||||
}
|
||||
boolean stale = !matches(view, preview)
|
||||
|| !Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2));
|
||||
AdvancedAnvilDelivery delivery = preview.proposal().output() == null ? null
|
||||
: AdvancedAnvilDelivery.reserve(event, preview.proposal().output());
|
||||
Attempt attempt = new Attempt(preview, event, delivery, admission,
|
||||
stale || event.isCancelled() || delivery == null);
|
||||
pending.put(view, attempt);
|
||||
// Do not self-cancel accepted intents: continuation can observe the final native denial.
|
||||
// Empty output prevents native onTake, bulk loops and collection from producing/payment.
|
||||
view.getTopInventory().setItem(2, null);
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt));
|
||||
} catch (RuntimeException unavailable) {
|
||||
pending.remove(view);
|
||||
event.setCancelled(true);
|
||||
rerender(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(AnvilView view, Attempt attempt) {
|
||||
if (closed || pending.get(view) != attempt) return;
|
||||
pending.remove(view);
|
||||
if (attempt.invalid || attempt.event.isCancelled() || !attempt.admission.valid()
|
||||
|| previews.get(view) != attempt.preview
|
||||
|| !current(view) || !matches(view, attempt.preview)
|
||||
|| !attempt.delivery.stillValid()) {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
org.bukkit.Location origin = view.getTopInventory().getLocation();
|
||||
if (origin == null || !isAnvil(origin.getBlock().getType())) {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void protocolHookReady(AnvilView view) {
|
||||
if (!closed && current(view) && !pending.containsKey(view)) recompute(view);
|
||||
}
|
||||
|
||||
void protocolRejected(AnvilView view) {
|
||||
// Exact native dispatch ended without a Bukkit admission (expiry/refusal/revocation).
|
||||
// Correct client prediction only for this still-current window, never a reopened one.
|
||||
if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory();
|
||||
}
|
||||
|
||||
Object protocolPreview(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,112 @@
|
||||
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.");
|
||||
}
|
||||
int excess = levels.entrySet().stream()
|
||||
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
|
||||
.max().orElse(0);
|
||||
if (excess == 0) return null; // Native XP, rename, repair and conflict decisions stay native.
|
||||
ItemStack output = left.clone();
|
||||
output.setAmount(1);
|
||||
var enchantments = new ArrayList<>(levels.keySet());
|
||||
for (int index = 0; index < enchantments.size(); index++) {
|
||||
Enchantment enchantment = enchantments.get(index);
|
||||
if (levels.get(enchantment) <= 0 || !enchantment.canEnchantItem(output)) {
|
||||
return refused("An enchantment is not applicable to this item.");
|
||||
}
|
||||
for (int other = index + 1; other < enchantments.size(); other++) {
|
||||
Enchantment candidate = enchantments.get(other);
|
||||
if (enchantment.conflictsWith(candidate) || candidate.conflictsWith(enchantment)) {
|
||||
return refused("The resulting enchantments conflict.");
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
output.addUnsafeEnchantments(levels);
|
||||
repair(output, right);
|
||||
// Unsafe API setters may clamp silently; persistence can independently reject a result.
|
||||
// Inspect the actual native representation instead of imposing a lower gameplay cap.
|
||||
if (!output.getEnchantments().equals(levels)) {
|
||||
return refused("Enchantment level cannot be represented by this server.");
|
||||
}
|
||||
ItemStack restored = ItemStack.deserializeBytes(output.serializeAsBytes());
|
||||
if (!restored.equals(output) || !restored.getEnchantments().equals(levels)) {
|
||||
return refused("Result cannot be represented exactly by this server.");
|
||||
}
|
||||
} catch (RuntimeException unsupported) {
|
||||
return refused("Result cannot be represented exactly by this server.");
|
||||
}
|
||||
return new Proposal(output, policy.failurePercent(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,328 @@
|
||||
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 {
|
||||
// SYNC invokes adapters on main, not native handling: ProtocolLib forwards via Netty and
|
||||
// Purpur queues back to main. Bound lost intents without racing the next scheduler tick.
|
||||
static final long ADMISSION_TICKS = 40L;
|
||||
static final class Admission {
|
||||
final PacketEvent event;
|
||||
final ServerboundContainerClickPacket packet;
|
||||
final Object preview;
|
||||
final java.util.function.BooleanSupplier available;
|
||||
BukkitTask expiry;
|
||||
volatile boolean sealed;
|
||||
volatile boolean revoked;
|
||||
// Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement).
|
||||
boolean wrapped;
|
||||
boolean consumed;
|
||||
final AnvilView view;
|
||||
final net.minecraft.server.network.ServerGamePacketListenerImpl nativeListener;
|
||||
java.util.function.BooleanSupplier dispatchable;
|
||||
Runnable completed;
|
||||
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
|
||||
java.util.function.BooleanSupplier available, AnvilView view) {
|
||||
this.view = view;
|
||||
this.nativeListener = NativeAnvilGeneration.player(view).connection;
|
||||
this.event = event;
|
||||
this.packet = packet;
|
||||
this.preview = preview;
|
||||
this.available = available;
|
||||
}
|
||||
boolean valid() {
|
||||
// Consumption removes the map ticket, but bridge loss must still revoke deferred payment.
|
||||
return !revoked && available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
|
||||
}
|
||||
}
|
||||
|
||||
private final Plugin plugin;
|
||||
private final ProtocolManager manager;
|
||||
private final AdvancedAnvilListener owner;
|
||||
private final Set<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<Player, NativeAnvilDispatch> dispatches = new IdentityHashMap<>();
|
||||
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
|
||||
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
|
||||
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
|
||||
}
|
||||
private final Map<AnvilView, Frame> frames = new IdentityHashMap<>();
|
||||
// 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;
|
||||
NativeAnvilDispatch dispatch = dispatches.get(player);
|
||||
if (dispatch != null && !dispatch.attached()) {
|
||||
dispatches.remove(player);
|
||||
dispatch = null;
|
||||
}
|
||||
if (dispatch == null) {
|
||||
var nativePlayer = NativeAnvilGeneration.player(view);
|
||||
dispatch = new NativeAnvilDispatch(nativePlayer.connection.connection,
|
||||
nativePlayer.level().getServer().packetProcessor(), () -> {
|
||||
if (!closed && !plugin.getServer().isPrimaryThread()) plugin.getServer().getScheduler().runTask(plugin,
|
||||
() -> { if (!closed && !watched.contains(view)) owner.protocolHookReady(view); });
|
||||
}, () -> plugin.getServer().isPrimaryThread());
|
||||
dispatches.put(player, dispatch);
|
||||
}
|
||||
if (!dispatch.ready()) return false;
|
||||
watched.add(view);
|
||||
actors.add(player);
|
||||
invalidate(view);
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
|
||||
boolean watched(AnvilView view) { return watched.contains(view); }
|
||||
|
||||
void invalidate(AnvilView view) {
|
||||
Admission admission = admissions.remove(view);
|
||||
if (admission != null) {
|
||||
admission.revoked = true;
|
||||
// Retain expiry for a cancelled/non-forwarded record too; cancelling it here
|
||||
// would leak transport ownership when a newer intent replaces this admission.
|
||||
}
|
||||
}
|
||||
|
||||
private void send(PacketEvent event) {
|
||||
if (closed) return;
|
||||
if (!plugin.getServer().isPrimaryThread()) {
|
||||
if (actors.contains(event.getPlayer())) event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
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.
|
||||
NativeAnvilDispatch acquiredDispatch = null;
|
||||
Admission acquired = null;
|
||||
try {
|
||||
Object raw = event.getPacket().getHandle();
|
||||
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|
||||
|| !(raw instanceof ServerboundContainerClickPacket packet)
|
||||
|| packet.containerInput() == null || !currentFrame(view, packet)
|
||||
|| (NativeAnvilGeneration.take(packet) && !NativeAnvilGeneration.supportedTake(packet))
|
||||
|| !owner.protocolReady(view)
|
||||
|| (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) {
|
||||
refuse(event, view);
|
||||
return;
|
||||
}
|
||||
// Typed reconstruction changes ONLY stateId. Native classification and predictions
|
||||
// receive every original action/input/hash field, with the menu's untouched state.
|
||||
var translated = new ServerboundContainerClickPacket(packet.containerId(), frames.get(view).nativeState(),
|
||||
packet.slotNum(), packet.buttonNum(), packet.containerInput(), packet.changedSlots(), packet.carriedItem());
|
||||
event.setPacket(new PacketContainer(event.getPacketType(), translated));
|
||||
// Formerly enhanced windows retain only frame translation/refusal for normal
|
||||
// operations. No normal take depends on enhanced identity-budget/queue ownership.
|
||||
if (!NativeAnvilGeneration.take(translated) || owner.protocolPreview(view) == null) return;
|
||||
NativeAnvilDispatch dispatch = dispatches.get(event.getPlayer());
|
||||
Admission admission = new Admission(event, translated, owner.protocolPreview(view),
|
||||
() -> !closed && !manager.isClosed() && !versions.exhausted() && dispatch.ready(), view);
|
||||
admission.dispatchable = () -> admissions.get(view) == admission && watched.contains(view)
|
||||
&& NativeAnvilGeneration.current(view, admission.packet)
|
||||
&& owner.protocolPreview(view) == admission.preview && owner.protocolReady(view);
|
||||
admission.completed = () -> {
|
||||
if (admissions.get(view) == admission) admissions.remove(view);
|
||||
if (admission.expiry != null) admission.expiry.cancel();
|
||||
if (!admission.consumed && !closed && !admissions.containsKey(view)) owner.protocolRejected(view);
|
||||
};
|
||||
if (!dispatch.admit(admission)) { refuse(event, view); return; }
|
||||
acquiredDispatch = dispatch;
|
||||
acquired = admission;
|
||||
admissions.put(view, admission);
|
||||
// No owned post listener/marker: foreign markers remain entirely untouched.
|
||||
admission.expiry = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
|
||||
dispatch.revoke(admission);
|
||||
if (admissions.get(view) == admission) admissions.remove(view);
|
||||
// Unresolved raw ownership waits for the two-queue fence or exact raw arrival.
|
||||
// Already-wrapped ownership is safe to release even if native skips handle.
|
||||
}, ADMISSION_TICKS));
|
||||
} catch (RuntimeException | LinkageError unavailable) {
|
||||
if (acquired != null) {
|
||||
acquiredDispatch.revoke(acquired);
|
||||
if (admissions.get(view) == acquired) admissions.remove(view);
|
||||
}
|
||||
refuse(event, view);
|
||||
} finally {
|
||||
// Also fences a partially acquired, cancelled record after timer setup throws.
|
||||
// Cancellation inside a callback is never upfront proof of no raw forward.
|
||||
if (acquired != null && !acquiredDispatch.requestFence()) {
|
||||
invalidate(view);
|
||||
refuse(event, view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean currentFrame(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 = NativeAnvilDispatch.current();
|
||||
if (closed || admission == null || admission.view != view || admissions.get(view) != admission
|
||||
|| !admission.valid() || admission.preview != preview
|
||||
|| !NativeAnvilGeneration.current(view, admission.packet)
|
||||
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
|
||||
admissions.remove(view);
|
||||
admission.consumed = true;
|
||||
if (admission.expiry != null) admission.expiry.cancel();
|
||||
return admission;
|
||||
}
|
||||
|
||||
private static void refuse(PacketEvent event, 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);
|
||||
NativeAnvilDispatch dispatch = dispatches.get(player);
|
||||
if (dispatch != null) dispatch.retire();
|
||||
// Reuse a draining hook across window reuse, never install a second boundary.
|
||||
if (dispatch != null && !dispatch.ready()) dispatches.remove(player);
|
||||
}
|
||||
}
|
||||
|
||||
void prune() {
|
||||
for (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();
|
||||
}
|
||||
dispatches.values().forEach(NativeAnvilDispatch::retire);
|
||||
dispatches.clear();
|
||||
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,241 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.channel.ChannelPipeline;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.PacketProcessor;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.PacketType;
|
||||
import net.minecraft.network.protocol.PacketUtils;
|
||||
import net.minecraft.network.protocol.game.ServerGamePacketListener;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
|
||||
/** Exact record ownership behind ProtocolLib, retained through the native thread hop.
|
||||
* The synchronized ledger is transport-only; all Bukkit/policy validation runs on native main.
|
||||
*/
|
||||
final class NativeAnvilDispatch extends ChannelInboundHandlerAdapter {
|
||||
static final int MAX_OUTSTANDING = 32;
|
||||
private static final ThreadLocal<AdvancedAnvilProtocolGuard.Admission> CURRENT = new ThreadLocal<>();
|
||||
private final Connection connection;
|
||||
private final PacketProcessor processor;
|
||||
private final Runnable readyCallback;
|
||||
private final Map<ServerboundContainerClickPacket, AdvancedAnvilProtocolGuard.Admission> records =
|
||||
new IdentityHashMap<>();
|
||||
private final com.comphenix.protocol.scheduler.ProtocolScheduler scheduler;
|
||||
private final java.util.function.BooleanSupplier mainThread;
|
||||
private boolean mainFencePending;
|
||||
private volatile boolean unavailable;
|
||||
private volatile boolean ready;
|
||||
private volatile boolean removing;
|
||||
private boolean retiring;
|
||||
|
||||
NativeAnvilDispatch(Connection connection, PacketProcessor processor, Runnable readyCallback,
|
||||
java.util.function.BooleanSupplier mainThread) {
|
||||
// Link the public, library-owned main queue BEFORE enhanced output/hook installation.
|
||||
// DefaultScheduler.runTask runs after the executing library filters/posts return;
|
||||
// this is an ordering fence, not a promise to wait a strict server tick.
|
||||
scheduler = java.util.Objects.requireNonNull(com.comphenix.protocol.ProtocolLibrary.getScheduler(),
|
||||
"Missing ProtocolLib main scheduler");
|
||||
this.mainThread = java.util.Objects.requireNonNull(mainThread);
|
||||
NativeAnvilPacketLimits.link();
|
||||
this.connection = java.util.Objects.requireNonNull(connection);
|
||||
this.processor = java.util.Objects.requireNonNull(processor);
|
||||
this.readyCallback = readyCallback;
|
||||
ChannelPipeline pipeline = java.util.Objects.requireNonNull(connection.channel).pipeline();
|
||||
ChannelHandlerContext nativeContext = pipeline.context(connection);
|
||||
if (nativeContext == null) throw new IllegalStateException("Missing native connection boundary");
|
||||
// The pinned Connection handler must be last; ProtocolLib resumes fireChannelRead ahead
|
||||
// of it. Do not replace, reorder or remove any foreign handler.
|
||||
if (pipeline.last() != connection) throw new IllegalStateException("Unsupported connection pipeline");
|
||||
pipeline.addBefore(nativeContext.name(), null, this);
|
||||
}
|
||||
|
||||
boolean attached() { return !removing && connection.channel.pipeline().context(this) != null; }
|
||||
|
||||
boolean ready() {
|
||||
var pipeline = connection.channel.pipeline();
|
||||
var context = pipeline.context(this);
|
||||
return ready && !unavailable && !removing && connection.channel.isActive() && context != null
|
||||
&& pipeline.context(connection) != null && pipeline.last() == connection
|
||||
&& pipeline.names().indexOf(context.name()) + 1
|
||||
== pipeline.names().indexOf(pipeline.context(connection).name());
|
||||
}
|
||||
|
||||
@Override public void handlerAdded(ChannelHandlerContext context) {
|
||||
ready = true;
|
||||
readyCallback.run(); // Schedules owner recomputation; never accesses a view here.
|
||||
}
|
||||
|
||||
@Override public void handlerRemoved(ChannelHandlerContext context) {
|
||||
ready = false;
|
||||
synchronized (this) { records.values().forEach(admission -> admission.revoked = true); }
|
||||
}
|
||||
|
||||
synchronized boolean admit(AdvancedAnvilProtocolGuard.Admission admission) {
|
||||
if (!ready() || records.size() >= MAX_OUTSTANDING || records.containsKey(admission.packet)) return false;
|
||||
retiring = false;
|
||||
records.put(admission.packet, admission);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One pending library-main callback per dispatch, never one job per admission. */
|
||||
synchronized boolean requestFence() {
|
||||
if (unavailable) return false;
|
||||
if (mainFencePending) return true;
|
||||
mainFencePending = true;
|
||||
try {
|
||||
if (scheduler.runTask(this::mainFence) == null) {
|
||||
failBridge();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
failBridge();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void mainFence() {
|
||||
try {
|
||||
if (!mainThread.getAsBoolean()) {
|
||||
failBridge();
|
||||
return;
|
||||
}
|
||||
final java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot;
|
||||
synchronized (this) {
|
||||
mainFencePending = false;
|
||||
if (unavailable) return;
|
||||
// Snapshot HERE, not on Netty: later unfinished receives cannot be declared
|
||||
// NO_FORWARD by a barrier queued before their actual library raw forwards.
|
||||
snapshot = java.util.List.copyOf(records.values()); // bounded by MAX_OUTSTANDING
|
||||
}
|
||||
connection.channel.eventLoop().execute(() -> terminalFence(snapshot));
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
// Rejected submission is not evidence of NO_FORWARD.
|
||||
failBridge();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void terminalFence(java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot) {
|
||||
for (var admission : snapshot) {
|
||||
if (records.get(admission.packet) != admission) continue;
|
||||
if (!admission.wrapped) {
|
||||
// All actual raw forwards captured before mainFence are ahead of us on
|
||||
// this same event loop. Never infer acknowledgement from mutable events/posts.
|
||||
admission.revoked = true;
|
||||
records.remove(admission.packet);
|
||||
} else if (admission.revoked) records.remove(admission.packet);
|
||||
}
|
||||
// Transport-only completion: no Bukkit/map/policy callback on the socket loop.
|
||||
removeIfDrained();
|
||||
}
|
||||
|
||||
private synchronized void failBridge() {
|
||||
unavailable = true;
|
||||
mainFencePending = false;
|
||||
revokeRecords(); // Keep unresolved exact-raw tombstones; no false cleanup claim.
|
||||
removeIfDrained();
|
||||
}
|
||||
|
||||
static AdvancedAnvilProtocolGuard.Admission current() { return CURRENT.get(); }
|
||||
|
||||
@Override public void channelRead(ChannelHandlerContext context, Object message) {
|
||||
AdvancedAnvilProtocolGuard.Admission admission;
|
||||
synchronized (this) {
|
||||
admission = records.get(message);
|
||||
if (admission != null) {
|
||||
if (admission.wrapped) return; // Duplicate exact raw while still owned.
|
||||
admission.wrapped = true; // Sticky, even after ledger retirement.
|
||||
if (admission.revoked) complete(admission);
|
||||
}
|
||||
}
|
||||
if (admission == null) {
|
||||
context.fireChannelRead(message);
|
||||
} else {
|
||||
// Even a revoked owned record must traverse native connection admission/limits.
|
||||
// Its exact wrapper denies gameplay on main; do not hide accepted traffic from
|
||||
// aggregate or record-class native rate counters by dropping it ahead of Connection.
|
||||
context.fireChannelRead(new BoundPacket(admission));
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void complete(AdvancedAnvilProtocolGuard.Admission admission) {
|
||||
if (records.get(admission.packet) == admission) records.remove(admission.packet);
|
||||
removeIfDrained();
|
||||
}
|
||||
|
||||
synchronized void revoke(AdvancedAnvilProtocolGuard.Admission admission) {
|
||||
admission.revoked = true;
|
||||
// The wrapper retains irreversible denial even if native never calls its handle.
|
||||
if (admission.wrapped) complete(admission);
|
||||
}
|
||||
|
||||
private void revokeRecords() {
|
||||
records.values().removeIf(admission -> {
|
||||
admission.revoked = true;
|
||||
return admission.wrapped;
|
||||
});
|
||||
}
|
||||
|
||||
synchronized void retire() {
|
||||
retiring = true;
|
||||
revokeRecords();
|
||||
removeIfDrained();
|
||||
}
|
||||
|
||||
private void removeIfDrained() {
|
||||
if (retiring && records.isEmpty()) {
|
||||
var pipeline = connection.channel.pipeline();
|
||||
if (!removing && pipeline.context(this) != null) {
|
||||
removing = true; // Do not reactivate a hook whose event-loop removal is queued.
|
||||
pipeline.remove(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void channelInactive(ChannelHandlerContext context) {
|
||||
synchronized (this) {
|
||||
records.values().forEach(admission -> admission.revoked = true);
|
||||
records.clear();
|
||||
retiring = true;
|
||||
removeIfDrained();
|
||||
}
|
||||
context.fireChannelInactive();
|
||||
}
|
||||
|
||||
final class BoundPacket implements Packet<ServerGamePacketListener> {
|
||||
private final AdvancedAnvilProtocolGuard.Admission admission;
|
||||
private boolean nativeLimitChecked;
|
||||
BoundPacket(AdvancedAnvilProtocolGuard.Admission admission) { this.admission = admission; }
|
||||
@Override public PacketType<? extends Packet<ServerGamePacketListener>> type() { return admission.packet.type(); }
|
||||
@Override public void handle(ServerGamePacketListener listener) {
|
||||
if (!nativeLimitChecked) {
|
||||
nativeLimitChecked = true;
|
||||
if (NativeAnvilPacketLimits.refuse(connection, admission.packet)) {
|
||||
admission.revoked = true;
|
||||
complete(admission);
|
||||
// This refusal can occur on Netty: leave the main-thread view ledger to
|
||||
// its bounded expiry/new intent, never invoke its completion callback here.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Queue THIS wrapper, not the original record, so identity survives native PacketProcessor.
|
||||
PacketUtils.ensureRunningOnSameThread(this, listener, processor);
|
||||
var previous = CURRENT.get();
|
||||
try {
|
||||
if (ready() && admission.valid() && admission.dispatchable.getAsBoolean()
|
||||
&& listener == admission.nativeListener) {
|
||||
CURRENT.set(admission);
|
||||
admission.packet.handle(listener);
|
||||
}
|
||||
} finally {
|
||||
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
|
||||
complete(admission); // Includes native silent refusal before any Bukkit event.
|
||||
admission.completed.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
}
|
||||
|
||||
static net.minecraft.server.level.ServerPlayer player(AnvilView view) {
|
||||
if (!(view.getPlayer() instanceof org.bukkit.craftbukkit.entity.CraftPlayer player)) {
|
||||
throw new IllegalStateException("Unsupported native anvil actor");
|
||||
}
|
||||
return player.getHandle();
|
||||
}
|
||||
|
||||
/** Ensure CraftEventFactory's post-event broadcast emits the final installed result.
|
||||
* Quantity/risk-only changes may otherwise be skipped by native result diffing.
|
||||
* Invalidate only the owned result's remote prediction, never an actual item or input.
|
||||
*/
|
||||
static void requireResultFrame(AnvilView view, boolean emptyOutput) {
|
||||
var menu = menu(view);
|
||||
menu.setRemoteSlot(2, emptyOutput ? menu.getSlot(0).getItem().copy()
|
||||
: net.minecraft.world.item.ItemStack.EMPTY);
|
||||
}
|
||||
|
||||
/** Publish after native preparation has installed the final result, even when diffing skips it. */
|
||||
static void publish(AnvilView view) {
|
||||
menu(view).sendAllDataToRemote();
|
||||
}
|
||||
|
||||
static boolean current(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,87 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import io.papermc.paper.configuration.GlobalConfiguration;
|
||||
import io.papermc.paper.util.IntervalledCounter;
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Map;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
|
||||
/** Pinned Connection class-specific admission for a record carried by a dispatch wrapper.
|
||||
* Connection still performs its aggregate limit first. Use its SAME lock/counters/config, once
|
||||
* before the wrapper's native thread hop, not an independent plugin rate policy or counter.
|
||||
*/
|
||||
final class NativeAnvilPacketLimits {
|
||||
private static final Field LOCK = field("PACKET_LIMIT_LOCK", Object.class);
|
||||
private static final Field ALL = field("allPacketCounts", IntervalledCounter.class);
|
||||
private static final Field SPECIFIC = field("packetSpecificLimits", Map.class);
|
||||
private static final Field RECEIVED = field("receivedPackets", int.class);
|
||||
private static final MethodHandle PUT;
|
||||
private static final MethodHandle KICK;
|
||||
static {
|
||||
try {
|
||||
PUT = MethodHandles.lookup().findVirtual(Map.class, "put",
|
||||
MethodType.methodType(Object.class, Object.class, Object.class));
|
||||
var method = Connection.class.getDeclaredMethod("killForPacketSpam");
|
||||
method.setAccessible(true);
|
||||
KICK = MethodHandles.lookup().unreflect(method);
|
||||
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
|
||||
}
|
||||
private NativeAnvilPacketLimits() {}
|
||||
static void link() { /* Initialize/verify pinned linkage before any enhanced output. */ }
|
||||
|
||||
static boolean refuse(Connection connection, ServerboundContainerClickPacket record) {
|
||||
try {
|
||||
var configuration = GlobalConfiguration.get().packetLimiter;
|
||||
if (configuration == null) throw new IllegalStateException("Missing native packet limiter");
|
||||
// Match native fast-path: superclass overrides are inspected only if an aggregate
|
||||
// counter exists or the exact incoming record class has a configured entry.
|
||||
if (ALL.get(connection) == null && !configuration.overrides.containsKey(record.getClass())) return false;
|
||||
Object lock = LOCK.get(connection);
|
||||
if (lock == null || !(SPECIFIC.get(connection) instanceof Map<?, ?> counters)) {
|
||||
throw new IllegalStateException("Unsupported native packet counters");
|
||||
}
|
||||
long now = System.nanoTime();
|
||||
synchronized (lock) {
|
||||
for (Class<?> type = record.getClass(); type != Object.class; type = type.getSuperclass()) {
|
||||
var limit = configuration.overrides.get(type);
|
||||
if (limit == null || !limit.isEnabled()) continue;
|
||||
Object stored = counters.get(type);
|
||||
IntervalledCounter counter;
|
||||
if (stored == null) {
|
||||
counter = new IntervalledCounter((long) (limit.interval() * 1.0E9));
|
||||
PUT.invoke(counters, type, counter);
|
||||
} else if (stored instanceof IntervalledCounter existing) counter = existing;
|
||||
else throw new IllegalStateException("Unsupported native packet counter type");
|
||||
counter.updateAndAdd(1L, now);
|
||||
if (counter.getRate() < limit.maxPacketRate()) continue;
|
||||
if (limit.action() == GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK) {
|
||||
KICK.invoke(connection); // Native disconnect/cancellation path, not a custom kick.
|
||||
} else if (limit.action() != GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP) {
|
||||
continue;
|
||||
}
|
||||
// Native class refusal returns before receivedPackets++. The native outer
|
||||
// Connection will increment after this callback, so preserve that accounting.
|
||||
RECEIVED.setInt(connection, RECEIVED.getInt(connection) - 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Throwable failure) {
|
||||
if (failure instanceof Error error) throw error;
|
||||
throw new IllegalStateException("Unavailable native anvil packet-limit bridge", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Field field(String name, Class<?> type) {
|
||||
try {
|
||||
var field = Connection.class.getDeclaredField(name);
|
||||
if (field.getType() != type) throw new IllegalStateException("Unsupported native field " + name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** Independently owns the fixed Snow Block and empty Bucket conversion. */
|
||||
public final class PowderSnowRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey recipeKey;
|
||||
private Server registeredServer;
|
||||
private boolean owned;
|
||||
|
||||
public PowderSnowRecipeRegistrar(NamespacedKey recipeKey) {
|
||||
this.recipeKey = Objects.requireNonNull(recipeKey, "recipeKey");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean register(Server server) {
|
||||
Objects.requireNonNull(server, "server");
|
||||
if (owned) {
|
||||
return true;
|
||||
}
|
||||
registeredServer = server;
|
||||
if (server.getRecipe(recipeKey) != null) {
|
||||
return false;
|
||||
}
|
||||
ShapelessRecipe recipe = new ShapelessRecipe(recipeKey, new ItemStack(Material.POWDER_SNOW_BUCKET));
|
||||
recipe.addIngredient(Material.SNOW_BLOCK);
|
||||
recipe.addIngredient(Material.BUCKET);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException exception) {
|
||||
try {
|
||||
owned = server.getRecipe(recipeKey) != null;
|
||||
unregister();
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
exception.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NamespacedKey> recipeKeys() {
|
||||
return List.of(recipeKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unregister() {
|
||||
if (!owned || registeredServer == null) {
|
||||
return;
|
||||
}
|
||||
// A prior removal may have succeeded before its port threw. Retain ownership on
|
||||
// failure, but let a later cleanup confirm absence instead of stranding the lifecycle.
|
||||
if (!registeredServer.removeRecipe(recipeKey) && registeredServer.getRecipe(recipeKey) != null) {
|
||||
throw new IllegalStateException("Could not remove owned recipe " + recipeKey);
|
||||
}
|
||||
owned = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Powder Snow crafting recipe";
|
||||
}
|
||||
}
|
||||
@@ -1,12 +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() {
|
||||
@@ -24,7 +63,20 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
new DispenserRecipeRegistrar(new NamespacedKey(this, "dropper_dispenser")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(List.of(sortingRecipes, dispenserRecipe));
|
||||
RecipeLifecycle cobwebRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new CobwebRecipeRegistrar(new NamespacedKey(this, "string_cobweb")),
|
||||
getLogger()
|
||||
);
|
||||
RecipeLifecycle powderSnowRecipe = new RecipeLifecycle(
|
||||
getServer(),
|
||||
this,
|
||||
new PowderSnowRecipeRegistrar(new NamespacedKey(this, "snow_powder_snow")),
|
||||
getLogger()
|
||||
);
|
||||
recipeLifecycles = new RecipeLifecycleGroup(
|
||||
List.of(sortingRecipes, dispenserRecipe, cobwebRecipe, powderSnowRecipe));
|
||||
recipeLifecycles.enableAll();
|
||||
InventorySortingService sorting = new InventorySortingService(new InventorySorter());
|
||||
getServer().getPluginManager().registerEvents(
|
||||
@@ -35,17 +87,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);
|
||||
// Wait for ACTUAL durable publication with the bounded admission expiry
|
||||
// still pending; policy invalidation, not elapsed time, must refuse the take.
|
||||
f.acknowledgeSave();
|
||||
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
|
||||
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
var right = f.menu.getBukkitView().getTopInventory().getItem(1).clone();
|
||||
packet.handler.handleContainerClick((net.minecraft.network.protocol.game.ServerboundContainerClickPacket)
|
||||
admitted.getPacket().getHandle());
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertTrue(packet.clicks.getFirst().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(right, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ticketCannotAuthorizeDifferentSlotButtonOrInputAndExpiresIfNativeDispatchIsLost() throws Exception {
|
||||
for (String mode : java.util.List.of("slot", "button", "input", "lost")) {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var input = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
|
||||
assertFalse(f.receive(raw(f, mode.equals("slot") ? 3 : 2, 0,
|
||||
mode.equals("slot") ? ContainerInput.PICKUP_ALL : ContainerInput.PICKUP)).isCancelled());
|
||||
if (mode.equals("lost")) {
|
||||
for (long tick = 0; tick < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; tick++) f.tick();
|
||||
}
|
||||
packet.handler.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) (mode.equals("button") ? 1 : 0),
|
||||
mode.equals("input") ? ContainerInput.QUICK_MOVE : ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
assertTrue(packet.clicks.getFirst().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(input, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void earlierAndFinalProtocolCancellationNeverAuthorizesPaymentEvenIfLaterListenerUncancels() throws Exception {
|
||||
for (String mode : java.util.List.of("earlier", "late", "uncancel", "after-bukkit")) {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory.resolve(mode), () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
|
||||
var event = f.clientEvent(raw);
|
||||
if (mode.equals("earlier") || mode.equals("uncancel")) event.setCancelled(true);
|
||||
if (mode.equals("late") || mode.equals("uncancel")) f.protocolListeners.add(
|
||||
new com.comphenix.protocol.events.PacketAdapter(f.plugin,
|
||||
com.comphenix.protocol.events.ListenerPriority.HIGHEST, PacketType.Play.Client.WINDOW_CLICK) {
|
||||
@Override public void onPacketReceiving(com.comphenix.protocol.events.PacketEvent callback) {
|
||||
callback.setCancelled(!mode.equals("uncancel"));
|
||||
}
|
||||
});
|
||||
f.dispatchProtocol(event);
|
||||
if (!event.isCancelled()) packet.handler.handleContainerClick(
|
||||
(net.minecraft.network.protocol.game.ServerboundContainerClickPacket) event.getPacket().getHandle());
|
||||
if (mode.equals("after-bukkit")) event.setCancelled(true);
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(0).getAmount());
|
||||
assertEquals(3, f.menu.getBukkitView().getTopInventory().getItem(1).getAmount());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void offThreadCallbackCancelsOnlyWatchedActorWithoutMutatingBukkit() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var event = f.clientEvent(raw(f, 2, 0, ContainerInput.PICKUP));
|
||||
org.mockito.Mockito.clearInvocations(f.player);
|
||||
org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(false);
|
||||
try {
|
||||
var thread = new Thread(() -> f.dispatchProtocol(event));
|
||||
thread.start();
|
||||
thread.join();
|
||||
assertTrue(event.isCancelled());
|
||||
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).getOpenInventory();
|
||||
org.mockito.Mockito.verify(f.player, org.mockito.Mockito.never()).updateInventory();
|
||||
assertTrue(f.nextTick.isEmpty());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
} finally { org.mockito.Mockito.when(f.server.isPrimaryThread()).thenReturn(true); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableManagerWithholdsNewEnhancedOutputAndRestoresOwnedBypass() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory); var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
org.mockito.Mockito.when(f.protocol.isClosed()).thenReturn(true);
|
||||
f.prepareAgain();
|
||||
assertNull(f.output());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
f.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void watchedEnhancedToVanillaRejectsOldTakeButFreshNormalBookOperationStaysNative() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
int oldState = f.clientState(f.menu);
|
||||
var left = NativeAdvancedAnvilTransactionTest.stackedSword(5, 1);
|
||||
var book = new org.bukkit.inventory.ItemStack(org.bukkit.Material.ENCHANTED_BOOK);
|
||||
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
|
||||
meta.addStoredEnchant(org.bukkit.enchantments.Enchantment.UNBREAKING, 3, false);
|
||||
book.setItemMeta(meta);
|
||||
f.inputs(left, book);
|
||||
var expected = f.output().clone();
|
||||
int cost = f.menu.getCost();
|
||||
assertTrue(cost > 0);
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertNotEquals(oldState, f.clientState(f.menu));
|
||||
packet.click(f.menu.containerId, oldState, 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(left, f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(book, f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
f.nativePlayer.experienceLevel = 100;
|
||||
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
|
||||
org.mockito.Mockito.when(random.nextFloat()).thenReturn(1F);
|
||||
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(packet.received.getLast().isCancelled());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled());
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
org.mockito.Mockito.verify(f.nativePlayer).giveExperienceLevels(-cost);
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableRetiresOwnedNativeWindowReturnsInputsAndOldPacketCannotBecomeVanillaTake() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
seed(f);
|
||||
var raw = raw(f, 2, 0, ContainerInput.PICKUP);
|
||||
assertFalse(f.receive(raw).isCancelled()); // A held, admitted raw packet has not reached native.
|
||||
f.plugin.setEnabled(false);
|
||||
assertSame(packet.inventoryMenu, f.nativePlayer.containerMenu);
|
||||
assertTrue(f.protocolListeners.isEmpty());
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
packet.handler.handleContainerClick(raw);
|
||||
f.tick();
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(6, NativeAdvancedAnvilBoundaryTest.count(f,
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(5, 1)));
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsupportedRuntimeFailsEnableBeforeAnyEnhancedOutputBypassOrProtocolRegistration() throws Exception {
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1,
|
||||
"Purpur 26.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,89 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
/** Native displayed risk and committed outcomes for every controlled roll, not domain-only math. */
|
||||
class NativeAdvancedAnvilRiskTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@ParameterizedTest @CsvSource({"0,0", "1,20", "2,25", "3,35", "4,50", "5,70", "6,95", "7,99", "20,99"})
|
||||
void largestResultExcessDefaultTableControlsEveryRollFromZeroThroughNinetyNine(int excess, int failure)
|
||||
throws Exception {
|
||||
AtomicInteger selectedRoll = new AtomicInteger();
|
||||
AtomicInteger rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
var effects = new NativeAnvilEffectsFixture(f);
|
||||
if (excess == 0) {
|
||||
// US-010 supersedes enhanced E0 routing: keep the 100 controls, but native
|
||||
// payment/output and absence of ANY enhanced roll/preview are authoritative.
|
||||
for (int roll = 0; roll < 100; roll++) {
|
||||
selectedRoll.set(roll);
|
||||
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
|
||||
f.player.setItemOnCursor(null);
|
||||
var ordinary = NativeAnvilCombiningRegressionTest.sword(4);
|
||||
ordinary.addUnsafeEnchantment(Enchantment.UNBREAKING, 2);
|
||||
f.inputs(ordinary, ordinary.clone());
|
||||
assertTrue(f.actionBars.isEmpty());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
int cost = f.menu.getCost();
|
||||
assertTrue(cost > 0);
|
||||
var expected = f.output().clone();
|
||||
assertEquals(5, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(3, expected.getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(effects.explosions.isEmpty());
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int roll = 0; roll < 100; roll++) {
|
||||
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(6), right);
|
||||
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
|
||||
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), sword(6));
|
||||
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(0, fixture.menu.getCost());
|
||||
fixture.inputs(sword(6), new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
}
|
||||
|
||||
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
|
||||
var left = sword(5);
|
||||
var meta = (Damageable) left.getItemMeta();
|
||||
meta.setDamage(1200);
|
||||
left.setItemMeta(meta);
|
||||
var right = sword(5);
|
||||
right.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
fixture.inputs(left, right);
|
||||
assertEquals(1013, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
}
|
||||
|
||||
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
|
||||
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
|
||||
fixture.inputs(sword(6), right);
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("conflict"));
|
||||
assertInputs(sword(6), right);
|
||||
var alreadyConflicting = sword(6);
|
||||
alreadyConflicting.addUnsafeEnchantment(Enchantment.SMITE, 4);
|
||||
fixture.inputs(alreadyConflicting, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertNull(fixture.output(), "Conflicts already present on left cannot be silently retained");
|
||||
}
|
||||
|
||||
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
|
||||
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
|
||||
fixture.inputs(sword(6), invalid);
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("applicable"));
|
||||
fixture.inputs(invalid, sword(6));
|
||||
assertNull(fixture.output());
|
||||
assertInputs(invalid, sword(6));
|
||||
}
|
||||
|
||||
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
|
||||
fixture.inputs(sword(254), sword(254));
|
||||
assertNotNull(fixture.output());
|
||||
assertEquals(255, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(fixture.output(), ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
|
||||
fixture.inputs(sword(255), sword(255));
|
||||
assertNull(fixture.output());
|
||||
assertTrue(lastAction().contains("represent"));
|
||||
assertInputs(sword(255), sword(255));
|
||||
assertNotNull(AdvancedAnvilMerge.prepare(sword(255), sword(255), AdvancedAnvilPolicy.defaults()).refusal());
|
||||
}
|
||||
|
||||
@Test void exactStyledUnicodeNameLorePdcAndLeftOnlyMetadataSurviveCraftRoundTrip() {
|
||||
var left = sword(5);
|
||||
left.setAmount(3);
|
||||
var meta = left.getItemMeta();
|
||||
meta.customName(Component.text("Épée 雪 🗡", NamedTextColor.AQUA).decorate(TextDecoration.BOLD));
|
||||
meta.lore(List.of(Component.text("Lore λ", NamedTextColor.GOLD), Component.empty()));
|
||||
meta.itemName(Component.text("Separate native item-name"));
|
||||
meta.setUnbreakable(true);
|
||||
meta.setMaxStackSize(3);
|
||||
meta.getPersistentDataContainer().set(key("left"), PersistentDataType.STRING, "雪");
|
||||
left.setItemMeta(meta);
|
||||
// Native slots clamp ordinary swords to one; a real stackable sword must also remove
|
||||
// MAX_DAMAGE, since the native codec rejects damageable + stackable components.
|
||||
left.unsetData(io.papermc.paper.datacomponent.DataComponentTypes.MAX_DAMAGE);
|
||||
assertEquals(left, ItemStack.deserializeBytes(left.serializeAsBytes()));
|
||||
var right = sword(5);
|
||||
var rightMeta = right.getItemMeta();
|
||||
rightMeta.customName(Component.text("DO NOT IMPORT"));
|
||||
rightMeta.lore(List.of(Component.text("foreign")));
|
||||
rightMeta.getPersistentDataContainer().set(key("right"), PersistentDataType.STRING, "foreign");
|
||||
right.setItemMeta(rightMeta);
|
||||
fixture.menu.itemName = "rename field ignored";
|
||||
fixture.inputs(left, right);
|
||||
var expected = left.clone();
|
||||
expected.setAmount(1);
|
||||
expected.addUnsafeEnchantment(Enchantment.SHARPNESS, 6);
|
||||
assertEquals(expected, fixture.output());
|
||||
assertEquals(expected, ItemStack.deserializeBytes(fixture.output().serializeAsBytes()));
|
||||
assertFalse(fixture.output().getItemMeta().getPersistentDataContainer().has(key("right")));
|
||||
assertEquals(0, fixture.menu.getBukkitView().getRepairCost());
|
||||
assertTrue(fixture.menu.getBukkitView().canBypassCost());
|
||||
assertTrue(fixture.menu.getSlot(2).mayPickup(fixture.nativePlayer));
|
||||
assertInputs(left, right);
|
||||
}
|
||||
|
||||
@Test void absentNameAndLoreStayAbsentAndEnhancedRenameDoesNotChangeThem() {
|
||||
fixture.menu.itemName = "Injected rename";
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
assertFalse(fixture.output().getItemMeta().hasCustomName());
|
||||
assertFalse(fixture.output().getItemMeta().hasLore());
|
||||
fixture.menu.setItemName("second rename");
|
||||
fixture.prepareAgain();
|
||||
assertFalse(fixture.output().getItemMeta().hasCustomName());
|
||||
assertFalse(fixture.output().getItemMeta().hasLore());
|
||||
}
|
||||
|
||||
@Test void nativeNormalDurabilityRepairUsesTwelvePercentAndOnlyImprovesDamage() {
|
||||
var left = damaged(sword(5), 1500);
|
||||
var right = damaged(sword(5), 1500);
|
||||
fixture.inputs(left, right);
|
||||
int maximum = left.getType().getMaxDurability();
|
||||
int expected = Math.max(0, maximum - ((maximum - 1500) * 2 + maximum * 12 / 100));
|
||||
assertEquals(expected, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
assertInputs(left, right);
|
||||
fixture.inputs(damaged(sword(6), 2), damaged(sword(4), maximum - 1));
|
||||
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
}
|
||||
|
||||
@Test void customNativeMaximumDurabilityAndNonDamageableMaterialsRemainRepresentable() {
|
||||
var left = sword(5);
|
||||
var meta = (Damageable) left.getItemMeta();
|
||||
meta.setMaxDamage(1000);
|
||||
meta.setDamage(900);
|
||||
left.setItemMeta(meta);
|
||||
var right = damaged(sword(5), 1500);
|
||||
fixture.inputs(left, right);
|
||||
assertEquals(719, ((Damageable) fixture.output().getItemMeta()).getDamage());
|
||||
assertEquals(1000, ((Damageable) fixture.output().getItemMeta()).getMaxDamage());
|
||||
var helmet = enchanted(Material.DIAMOND_HELMET, Enchantment.PROTECTION, 4);
|
||||
fixture.inputs(helmet, helmet);
|
||||
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.PROTECTION));
|
||||
}
|
||||
|
||||
@Test void booksAndMismatchedMaterialsRemainNativeAndDoNotSendEnhancedWarnings() {
|
||||
var book = new ItemStack(Material.ENCHANTED_BOOK);
|
||||
var meta = (org.bukkit.inventory.meta.EnchantmentStorageMeta) book.getItemMeta();
|
||||
meta.addStoredEnchant(Enchantment.SHARPNESS, 5, true);
|
||||
book.setItemMeta(meta);
|
||||
fixture.inputs(sword(5), book);
|
||||
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
assertTrue(fixture.chats.isEmpty());
|
||||
assertTrue(fixture.actionBars.isEmpty());
|
||||
fixture.inputs(book, book);
|
||||
assertNotNull(fixture.output());
|
||||
assertEquals(5, ((org.bukkit.inventory.meta.EnchantmentStorageMeta) fixture.output().getItemMeta())
|
||||
.getStoredEnchantLevel(Enchantment.SHARPNESS));
|
||||
fixture.inputs(sword(5), enchanted(Material.NETHERITE_SWORD, Enchantment.SHARPNESS, 5));
|
||||
assertNull(fixture.output());
|
||||
assertTrue(fixture.chats.isEmpty());
|
||||
assertNull(AdvancedAnvilMerge.prepare(book, book, AdvancedAnvilPolicy.defaults()));
|
||||
}
|
||||
|
||||
@Test void zeroEnchantmentRepairAndRenameRemainNativeAfterEnhancedPreview() {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
int notices = fixture.chats.size();
|
||||
// Remove the enchanted right input before replacing the left, as a player can do.
|
||||
// Left-first replacement creates a genuine intermediate enhanced merge with the old right.
|
||||
fixture.inputs(sword(5), null);
|
||||
fixture.inputs(damaged(new ItemStack(Material.DIAMOND_SWORD), 1200),
|
||||
damaged(new ItemStack(Material.DIAMOND_SWORD), 1200));
|
||||
assertNotNull(fixture.output());
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
assertEquals(notices, fixture.chats.size());
|
||||
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), null);
|
||||
fixture.menu.setItemName("native rename");
|
||||
fixture.prepareAgain();
|
||||
assertEquals(Component.text("native rename"), fixture.output().getItemMeta().customName());
|
||||
assertTrue(fixture.menu.getCost() > 0);
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
}
|
||||
|
||||
@Test void exactPrivateRomanPreviewAndChangedOnlyWarningDoNotAlterItemsOrSpam() {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
assertEquals("Diamond Sword · Sharpness VI · Success 80% · Failure 20% — explosion", lastAction());
|
||||
assertEquals(1, fixture.chats.size());
|
||||
assertTrue(plain(fixture.chats.getFirst()).contains("Failure destroys both items and causes an explosion"));
|
||||
for (int i = 0; i < 5; i++) { fixture.prepareAgain(); fixture.tick(); }
|
||||
assertEquals(1, fixture.chats.size());
|
||||
fixture.inputs(sword(6), sword(6));
|
||||
assertEquals(2, fixture.chats.size());
|
||||
assertTrue(lastAction().contains("Sharpness VII · Success 75% · Failure 25%"));
|
||||
fixture.inputs(sword(10), sword(10));
|
||||
assertTrue(lastAction().contains("Sharpness XI · Success 5% · Failure 95%"));
|
||||
verify(fixture.server, never()).broadcast(any(Component.class));
|
||||
}
|
||||
|
||||
@Test void policyChangeRerendersCurrentViewAndCloseDisableReleasePreviewLifecycle() throws Exception {
|
||||
fixture.inputs(sword(5), sword(5));
|
||||
var admin = mock(org.bukkit.command.CommandSender.class);
|
||||
when(admin.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
|
||||
assertTrue(fixture.commands.dispatch(admin,
|
||||
"inventoryhelperadmin config advanced-anvil-base-failure-percent 45"));
|
||||
var acknowledgment = fixture.nextTick.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(acknowledgment, "Real durable save must schedule acknowledgment");
|
||||
assertTrue(lastAction().contains("Success 80% · Failure 20%"), "No off-thread publication");
|
||||
acknowledgment.run();
|
||||
assertTrue(lastAction().contains("Success 55% · Failure 45%"));
|
||||
var preview = java.util.Arrays.stream(PrepareAnvilEvent.getHandlerList().getRegisteredListeners())
|
||||
.map(org.bukkit.plugin.RegisteredListener::getListener)
|
||||
.filter(AdvancedAnvilListener.class::isInstance).map(AdvancedAnvilListener.class::cast)
|
||||
.findFirst().orElseThrow();
|
||||
int chats = fixture.chats.size();
|
||||
preview.policyChanged();
|
||||
assertEquals(chats, fixture.chats.size());
|
||||
fixture.events.callEvent(new InventoryCloseEvent(fixture.menu.getBukkitView()));
|
||||
int bars = fixture.actionBars.size();
|
||||
fixture.tick();
|
||||
assertEquals(bars, fixture.actionBars.size());
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertTrue(fixture.repeating.isEmpty());
|
||||
assertFalse(fixture.menu.bypassCost);
|
||||
}
|
||||
|
||||
private void assertInputs(ItemStack left, ItemStack right) {
|
||||
assertEquals(left, fixture.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(right, fixture.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, fixture.nativePlayer.experienceLevel);
|
||||
fixture.assertNoEventErrors();
|
||||
}
|
||||
|
||||
private String lastAction() { assertFalse(fixture.actionBars.isEmpty()); return plain(fixture.actionBars.getLast()); }
|
||||
private static String plain(Component message) { return PlainTextComponentSerializer.plainText().serialize(message); }
|
||||
private static NamespacedKey key(String key) { return new NamespacedKey("test", key); }
|
||||
private static ItemStack sword(int level) { return enchanted(Material.DIAMOND_SWORD, Enchantment.SHARPNESS, level); }
|
||||
private static ItemStack enchanted(Material material, Enchantment enchantment, int level) {
|
||||
var item = new ItemStack(material);
|
||||
item.addUnsafeEnchantment(enchantment, level);
|
||||
return item;
|
||||
}
|
||||
private static ItemStack damaged(ItemStack item, int damage) {
|
||||
var meta = (Damageable) item.getItemMeta();
|
||||
meta.setDamage(damage);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Ordinary and over-limit combining through registered native result-taking paths. */
|
||||
class NativeAnvilCombiningRegressionTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 4})
|
||||
void withinLimitCombiningRetainsNativeExperienceAndResultTaking(int level) throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
|
||||
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
|
||||
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
|
||||
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
|
||||
f.nativePlayer.experienceLevel = 20;
|
||||
var left = sword(level); var right = sword(level);
|
||||
f.inputs(left, right);
|
||||
int cost = f.menu.getCost();
|
||||
assertTrue(cost > 0, "Within-limit merging must retain its actual native XP cost");
|
||||
assertFalse(f.menu.getBukkitView().canBypassCost(), "No advanced cost bypass for an ordinary merge");
|
||||
assertTrue(f.actionBars.isEmpty(), "An ordinary merge must not acquire an advanced risk preview");
|
||||
assertEquals(level + 1, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(20 - cost, f.nativePlayer.experienceLevel);
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get(), "Native merging performs no enhanced roll or deferred payment");
|
||||
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void queuedDeclaredLibraryTakeSurvivesSchedulerBeforeNativeDispatch() throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
assertEquals(6, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
transport.receive(raw);
|
||||
assertEquals(1, transport.received.size());
|
||||
assertFalse(transport.received.getFirst().isCancelled());
|
||||
assertTrue(packet.clicks.isEmpty(), "ProtocolLib queues forwarding rather than native inline dispatch");
|
||||
f.tick(); // Scheduler can run before the Netty forward/native-main handoff.
|
||||
assertEquals(0, rolls.get());
|
||||
transport.forwardToNativeQueue();
|
||||
assertTrue(packet.clicks.isEmpty(), "Native PacketUtils must queue off-thread dispatch");
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors();
|
||||
f.assertNoEventErrors();
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled(), "An unchanged admitted generation remains takeable across real queues");
|
||||
f.tick();
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void earlierForwardedCancellationCannotBorrowALaterIdenticalAdmission() throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
transport.receive(raw);
|
||||
transport.forwardToNativeQueue(); // First exact wrapper is already in native PacketProcessor.
|
||||
transport.receive(raw);
|
||||
transport.forwardToNativeQueue();
|
||||
assertEquals(2, transport.received.size());
|
||||
assertFalse(transport.received.getFirst().isCancelled());
|
||||
assertFalse(transport.received.getLast().isCancelled());
|
||||
// ProtocolLib has already scheduled both forwards. The first event is cancelled
|
||||
// before its native event; the later admission must not authorize the first event.
|
||||
transport.received.getFirst().setCancelled(true);
|
||||
transport.forwardToNativeQueue();
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors();
|
||||
f.assertNoEventErrors();
|
||||
assertTrue(packet.clicks.isEmpty(), "Each native take must use its OWN final protocol cancellation: revoked wrapper is dropped before Bukkit");
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertTrue(transport.processor.executeSinglePacket(), "Second exact wrapper still owns its admission");
|
||||
assertEquals(1, packet.clicks.size());
|
||||
assertFalse(packet.clicks.getFirst().isCancelled());
|
||||
f.tick();
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realConnectionClickSpecificNativeRefusalMustStillApplyToOwnedDispatch() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory)) {
|
||||
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
|
||||
var previousLimiter = config.packetLimiter;
|
||||
try {
|
||||
var limiter = config.new PacketLimiter();
|
||||
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
|
||||
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
|
||||
limiter.overrides = new java.util.HashMap<>();
|
||||
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
|
||||
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
|
||||
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP));
|
||||
config.packetLimiter = limiter;
|
||||
// Actual pinned Connection, not the ordinary fixture's controlled handler port.
|
||||
var nativeConnection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
|
||||
nativeConnection.channel = f.channel;
|
||||
NativeAnvilFixture.field(net.minecraft.network.Connection.class, nativeConnection, "packetListener", f.transport);
|
||||
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
|
||||
f.transport, "connection", nativeConnection);
|
||||
f.channel.pipeline().replace(f.connection, "packet_handler", nativeConnection);
|
||||
var handled = new AtomicInteger();
|
||||
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
|
||||
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
|
||||
f.inputs(sword(5), sword(5));
|
||||
var nativeRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
f.dispatchInbound(nativeRaw);
|
||||
assertEquals(0, handled.get(), "Positive control: real native click-specific limiter refuses the original record");
|
||||
var wireRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
var admitted = f.receive(wireRaw);
|
||||
assertFalse(admitted.isCancelled());
|
||||
f.dispatchInbound(admitted.getPacket().getHandle());
|
||||
assertEquals(0, handled.get(), "Owned identity wrapper must not bypass native click-specific refusal");
|
||||
} finally { config.packetLimiter = previousLimiter; }
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"shared-drop", "class-kick"})
|
||||
void ownedDispatchUsesSharedNativeClassCountersAndNativeViolationAction(String mode) throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory)) {
|
||||
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
|
||||
var previous = config.packetLimiter;
|
||||
try {
|
||||
var limiter = config.new PacketLimiter();
|
||||
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
|
||||
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
|
||||
limiter.overrides = new java.util.HashMap<>();
|
||||
var action = mode.equals("class-kick")
|
||||
? io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK
|
||||
: io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP;
|
||||
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
|
||||
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
|
||||
1, mode.equals("class-kick") ? 0.1 : 2.0, action));
|
||||
config.packetLimiter = limiter;
|
||||
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
|
||||
connection.channel = f.channel;
|
||||
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
|
||||
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
|
||||
f.transport, "connection", connection);
|
||||
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
|
||||
var handled = new AtomicInteger();
|
||||
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
|
||||
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
|
||||
f.inputs(sword(5), sword(5));
|
||||
if (mode.equals("shared-drop")) {
|
||||
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
f.dispatchInbound(original);
|
||||
assertEquals(1, handled.get(), "Native first class-rate count permits the original record");
|
||||
}
|
||||
var admitted = f.receive(raw(f));
|
||||
assertFalse(admitted.isCancelled());
|
||||
f.dispatchInbound(admitted.getPacket().getHandle());
|
||||
assertEquals(mode.equals("shared-drop") ? 1 : 0, handled.get());
|
||||
var received = net.minecraft.network.Connection.class.getDeclaredField("receivedPackets");
|
||||
received.setAccessible(true);
|
||||
assertEquals(mode.equals("shared-drop") ? 1 : 0, received.getInt(connection),
|
||||
"Native class-refused packets must not increment successful receive accounting");
|
||||
if (mode.equals("class-kick")) {
|
||||
var stopped = net.minecraft.network.Connection.class.getDeclaredField("stopReadingPackets");
|
||||
stopped.setAccessible(true);
|
||||
assertTrue(stopped.getBoolean(connection), "Invoke the native kick/read-stop action");
|
||||
}
|
||||
} finally { config.packetLimiter = previous; }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void removingAdvancedInputsAndPlacingNormalItemsViaNativePacketsImmediatelyRestoresVanilla() throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
nativeExperienceAndWear(f, 100);
|
||||
f.inputs(sword(5), sword(5));
|
||||
int oldFrame = f.clientState(f.menu);
|
||||
packet.click(0, 0, ContainerInput.PICKUP);
|
||||
packet.click(3, 0, ContainerInput.PICKUP); // Return left to native player slot9.
|
||||
packet.click(1, 0, ContainerInput.PICKUP);
|
||||
packet.click(4, 0, ContainerInput.PICKUP);
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(f.menu.bypassCost);
|
||||
f.realInventory.setItem(11, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
|
||||
f.realInventory.setItem(12, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
|
||||
f.menu.broadcastChanges();
|
||||
packet.click(5, 0, ContainerInput.PICKUP);
|
||||
packet.click(0, 0, ContainerInput.PICKUP);
|
||||
packet.click(6, 0, ContainerInput.PICKUP);
|
||||
packet.click(1, 0, ContainerInput.PICKUP);
|
||||
assertSame(f.menu, f.nativePlayer.containerMenu, "No closing/reopening or advanced mode");
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertEquals(5, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(net.kyori.adventure.text.Component.empty(), f.actionBars.getLast());
|
||||
int cost = f.menu.getCost();
|
||||
assertTrue(cost > 0);
|
||||
int eventsBefore = packet.clicks.size();
|
||||
packet.click(f.menu.containerId, oldFrame, 2, 0, ContainerInput.PICKUP);
|
||||
assertTrue(packet.received.getLast().isCancelled());
|
||||
assertEquals(eventsBefore, packet.clicks.size());
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
assertEquals(5, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
|
||||
assertEquals(sword(5), f.player.getInventory().getItem(9));
|
||||
assertEquals(sword(5), f.player.getInventory().getItem(10));
|
||||
f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void normalRenameRepairPriorWorkAndRefusalRemainNative(boolean previouslyAdvanced) throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
packet.survival();
|
||||
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
|
||||
var left = sword(1);
|
||||
var meta = (org.bukkit.inventory.meta.Damageable) left.getItemMeta();
|
||||
meta.setDamage(1500);
|
||||
((org.bukkit.inventory.meta.Repairable) meta).setRepairCost(8);
|
||||
left.setItemMeta(meta);
|
||||
var right = left.clone();
|
||||
f.inputs(left, right);
|
||||
f.menu.setItemName("ordinary rename");
|
||||
f.prepareAgain();
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertTrue(f.menu.getCost() > 16, "Prior work plus native merge/repair/naming XP retained");
|
||||
assertEquals(net.kyori.adventure.text.Component.text("ordinary rename"), f.output().getItemMeta().customName());
|
||||
assertTrue(((org.bukkit.inventory.meta.Damageable) f.output().getItemMeta()).getDamage() < 1500);
|
||||
assertEquals(17, ((org.bukkit.inventory.meta.Repairable) f.output().getItemMeta()).getRepairCost());
|
||||
var expensive = sword(4);
|
||||
var prior = (org.bukkit.inventory.meta.Repairable) expensive.getItemMeta();
|
||||
prior.setRepairCost(80); expensive.setItemMeta(prior);
|
||||
f.inputs(expensive, sword(4));
|
||||
assertFalse(f.menu.bypassCost);
|
||||
assertNull(f.output(), "Native Too Expensive refusal must not inherit enhanced bypass");
|
||||
assertTrue(f.menu.getCost() >= 40);
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void normalInsufficientExperienceAndFinalCancellationSpendNothing(boolean previouslyAdvanced) throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
nativeExperienceAndWear(f, 0);
|
||||
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
|
||||
f.inputs(sword(1), sword(1));
|
||||
assertTrue(f.menu.getCost() > 0);
|
||||
assertFalse(f.menu.getSlot(2).mayPickup(f.nativePlayer));
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
f.tick();
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
nativeExperienceAndWear(f, 100);
|
||||
packet.listen(org.bukkit.event.EventPriority.MONITOR, event -> event.setCancelled(true));
|
||||
packet.click(2, 0, ContainerInput.PICKUP);
|
||||
f.tick();
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
assertEquals(100, f.nativePlayer.experienceLevel);
|
||||
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(1));
|
||||
assertEquals(0, rolls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"expiry", "inputs", "policy", "close", "shutdown", "native-refusal"})
|
||||
void queuedRetiredOrRefusedIntentsNeverDebitAndCannotAuthorizeLaterTakes(String mode) throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
transport.receive(raw(f));
|
||||
transport.forwardToNativeQueue();
|
||||
switch (mode) {
|
||||
case "expiry" -> { for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick(); }
|
||||
case "inputs" -> f.inputs(sword(6), sword(6));
|
||||
case "policy" -> { f.admin(f.console, "advanced-anvil-base-failure-percent", 80); f.acknowledgeSave(); }
|
||||
case "close" -> packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
|
||||
case "shutdown" -> f.plugin.setEnabled(false);
|
||||
case "native-refusal" -> org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(true);
|
||||
default -> throw new AssertionError(mode);
|
||||
}
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
if (mode.equals("close") || mode.equals("shutdown")) {
|
||||
assertEquals(2, NativeAdvancedAnvilBoundaryTest.count(f, sword(5)));
|
||||
assertEquals(1, f.channel.pipeline().names().stream().filter(n -> n.equals("packet_handler")).count());
|
||||
} else {
|
||||
org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(false);
|
||||
var expected = f.output().clone();
|
||||
transport.receive(raw(f)); transport.forwardToNativeQueue();
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
|
||||
assertEquals(1, rolls.get(), "A refused old dispatch must not strand or lend a ticket to the fresh take");
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void alreadyQueuedOldIdentityCannotTakeFromAReusedNativeWindow(boolean newAdvanced) throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
nativeExperienceAndWear(f, 100);
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
transport.receive(raw(f)); transport.forwardToNativeQueue();
|
||||
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
|
||||
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
|
||||
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
|
||||
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
f.nativePlayer.containerMenu = reopened;
|
||||
reopened.setSynchronizer(packet.synchronizer);
|
||||
var input = sword(newAdvanced ? 5 : 4);
|
||||
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
|
||||
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
|
||||
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
|
||||
int cost = reopened.getCost();
|
||||
assertEquals(newAdvanced, reopened.bypassCost);
|
||||
transport.receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
reopened.containerId, f.clientState(reopened), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
transport.forwardToNativeQueue();
|
||||
assertTrue(transport.processor.executeSinglePacket()); // Retired exact old-window wrapper.
|
||||
transport.assertNoErrors();
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
|
||||
assertEquals(input, reopened.getBukkitView().getTopInventory().getItem(0));
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
|
||||
assertEquals(expected, f.player.getItemOnCursor());
|
||||
assertEquals(newAdvanced ? 1 : 0, rolls.get());
|
||||
assertEquals(newAdvanced ? 100 : 100 - cost, f.nativePlayer.experienceLevel);
|
||||
assertTrue(reopened.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(reopened.getSlot(1).getItem().isEmpty());
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void outstandingIdentityBudgetRefusesOverflowAndRecoversOnlyAfterExactRetirement() throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
var held = new java.util.ArrayList<Object>();
|
||||
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
|
||||
var admitted = f.receive(raw(f));
|
||||
assertFalse(admitted.isCancelled());
|
||||
held.add(admitted.getPacket().getHandle());
|
||||
}
|
||||
assertTrue(f.receive(raw(f)).isCancelled(), "Bound outstanding exact records instead of replacing their identities");
|
||||
for (var record : held) f.dispatchInbound(record);
|
||||
f.tick();
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertEquals(0, rolls.get());
|
||||
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
|
||||
assertEquals(1, rolls.get());
|
||||
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void hookPassesUnrelatedTrafficAndRemovesOnlyItselfAfterShutdownDrain() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory);
|
||||
var packet = new NativeAnvilPacketFixture(f)) {
|
||||
var forwarded = new java.util.ArrayList<Object>();
|
||||
var probe = new io.netty.channel.ChannelInboundHandlerAdapter() {
|
||||
@Override public void channelRead(io.netty.channel.ChannelHandlerContext context, Object message) {
|
||||
forwarded.add(message); context.fireChannelRead(message);
|
||||
}
|
||||
};
|
||||
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
|
||||
f.inputs(sword(5), sword(5));
|
||||
var baseline = f.channel.pipeline().names();
|
||||
var message = new Object();
|
||||
// Replace only the fixture's controlled transport port behavior for non-packets.
|
||||
org.mockito.Mockito.doAnswer(inv -> null).when(f.connection).channelRead(
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.same(message));
|
||||
f.channel.pipeline().fireChannelRead(message);
|
||||
assertEquals(java.util.List.of(message), forwarded);
|
||||
var admitted = f.receive(raw(f));
|
||||
assertFalse(admitted.isCancelled());
|
||||
f.plugin.setEnabled(false);
|
||||
f.dispatchInbound(admitted.getPacket().getHandle());
|
||||
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
|
||||
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
|
||||
assertEquals(baseline.size() - 1, f.channel.pipeline().names().size(), "Remove only drained owned hook");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(NativeAnvilFixture f) {
|
||||
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
}
|
||||
|
||||
static void nativeExperienceAndWear(NativeAnvilFixture f, int xp) {
|
||||
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
|
||||
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
|
||||
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
|
||||
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
|
||||
f.nativePlayer.experienceLevel = xp;
|
||||
}
|
||||
|
||||
static ItemStack sword(int sharpness) {
|
||||
var item = new ItemStack(Material.DIAMOND_SWORD);
|
||||
item.addUnsafeEnchantment(Enchantment.SHARPNESS, sharpness);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import net.minecraft.world.level.ServerExplosion;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.craftbukkit.block.data.CraftBlockData;
|
||||
import org.bukkit.event.entity.EntityExplodeEvent;
|
||||
import org.bukkit.ExplosionResult;
|
||||
|
||||
/** Real Craft explosion overload and ServerExplosion; world/protection execution is the external port. */
|
||||
final class NativeAnvilEffectsFixture {
|
||||
final NativeAnvilFixture anvil;
|
||||
final Block block;
|
||||
final AtomicReference<BlockData> data = new AtomicReference<>(
|
||||
CraftBlockData.createData(net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState()));
|
||||
final List<ServerExplosion> explosions = new ArrayList<>();
|
||||
final List<EntityExplodeEvent> protectionEvents = new ArrayList<>();
|
||||
final List<net.minecraft.world.level.Level.ExplosionInteraction> requestedInteractions = new ArrayList<>();
|
||||
final List<Boolean> requestedFire = new ArrayList<>();
|
||||
Runnable duringExplosion = () -> {};
|
||||
boolean cancelExplosion;
|
||||
|
||||
NativeAnvilEffectsFixture(NativeAnvilFixture anvil) throws Exception {
|
||||
this.anvil = anvil;
|
||||
block = anvil.world.getBlockAt(10, 65, -9);
|
||||
when(block.getType()).thenAnswer(ignored -> data.get().getMaterial());
|
||||
when(block.getBlockData()).thenAnswer(ignored -> data.get().clone());
|
||||
when(block.getLocation()).thenAnswer(ignored -> anvil.menu.getBukkitView().getTopInventory().getLocation());
|
||||
when(anvil.server.createBlockData(any(Material.class)))
|
||||
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null));
|
||||
when(anvil.server.createBlockData(any(Material.class), nullable(String.class)))
|
||||
.thenAnswer(inv -> CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), inv.getArgument(1)));
|
||||
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class));
|
||||
doAnswer(inv -> { data.set(inv.getArgument(0)); return null; }).when(block).setBlockData(any(BlockData.class), anyBoolean());
|
||||
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
|
||||
.when(block).setType(any(Material.class));
|
||||
doAnswer(inv -> { data.set(CraftBlockData.fromString(inv.<Material>getArgument(0).asBlockType(), null)); return null; })
|
||||
.when(block).setType(any(Material.class), anyBoolean());
|
||||
when(anvil.level.getBlockState(anvil.position)).thenAnswer(ignored -> ((CraftBlockData) data.get()).getState());
|
||||
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
|
||||
worldField.setAccessible(true);
|
||||
worldField.set(anvil.world, anvil.level);
|
||||
var sources = mock(net.minecraft.world.damagesource.DamageSources.class);
|
||||
var damage = mock(net.minecraft.world.damagesource.DamageSource.class);
|
||||
when(anvil.level.damageSources()).thenReturn(sources);
|
||||
when(sources.explosion(anvil.nativePlayer, anvil.nativePlayer)).thenReturn(damage);
|
||||
when(anvil.level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(),
|
||||
anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
|
||||
// Capture requested native flags before the external world adapter maps block behavior.
|
||||
requestedInteractions.add(inv.getArgument(8));
|
||||
requestedFire.add(inv.getArgument(7));
|
||||
var explosion = new ServerExplosion(anvil.level, inv.getArgument(0), inv.getArgument(1), null,
|
||||
new Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
|
||||
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
|
||||
java.util.function.Consumer<ServerExplosion> configure = inv.getArgument(13);
|
||||
if (configure != null) configure.accept(explosion);
|
||||
var event = new EntityExplodeEvent(anvil.player, anvil.menu.getBukkitView().getTopInventory()
|
||||
.getLocation().clone().add(.5, .5, .5), new ArrayList<>(), 1F, ExplosionResult.DESTROY);
|
||||
event.setCancelled(cancelExplosion);
|
||||
anvil.events.callEvent(event);
|
||||
explosion.wasCanceled = event.isCancelled();
|
||||
protectionEvents.add(event);
|
||||
explosions.add(explosion);
|
||||
duringExplosion.run();
|
||||
return explosion;
|
||||
});
|
||||
doCallRealMethod().when(anvil.world).createExplosion(any(org.bukkit.entity.Entity.class),
|
||||
any(org.bukkit.Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.inventory.AnvilMenu;
|
||||
import net.minecraft.world.inventory.ContainerLevelAccess;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.command.PluginCommandYamlParser;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPluginLoader;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.purpurmc.purpur.PurpurWorldConfig;
|
||||
|
||||
/** Real menu/inventory/events/items; only server, world, network and scheduler boundaries are ports. */
|
||||
final class NativeAnvilFixture implements AutoCloseable {
|
||||
static {
|
||||
try {
|
||||
NativeRuntime.bootstrap();
|
||||
} catch (Exception failure) {
|
||||
throw new ExceptionInInitializerError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
|
||||
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
|
||||
final net.minecraft.server.MinecraftServer nativeServer = mock(net.minecraft.server.MinecraftServer.class);
|
||||
final net.minecraft.network.PacketProcessor processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
|
||||
final net.minecraft.network.Connection connection;
|
||||
final net.minecraft.server.network.ServerGamePacketListenerImpl transport =
|
||||
mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||
io.netty.channel.Channel channel;
|
||||
private java.util.function.Consumer<net.minecraft.network.protocol.game.ServerboundContainerClickPacket> legacyClick;
|
||||
final List<Object> unrelatedInbound = new ArrayList<>();
|
||||
private final io.papermc.paper.configuration.GlobalConfiguration previousConfiguration;
|
||||
final CraftPlayer player = mock(CraftPlayer.class);
|
||||
final ServerLevel level = mock(ServerLevel.class);
|
||||
final CraftWorld world = mock(CraftWorld.class);
|
||||
final BlockPos position = new BlockPos(10, 65, -9);
|
||||
final net.minecraft.world.entity.player.Inventory realInventory =
|
||||
new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment());
|
||||
final SimpleCommandMap commands = new SimpleCommandMap(server, new HashMap<>());
|
||||
final PaperPluginManagerImpl events = new PaperPluginManagerImpl(server, commands, null);
|
||||
final List<Component> actionBars = new ArrayList<>();
|
||||
final List<Component> chats = new ArrayList<>();
|
||||
final List<String> adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
final java.util.concurrent.BlockingQueue<Runnable> nextTick = new java.util.concurrent.LinkedBlockingQueue<>();
|
||||
private long tickCount;
|
||||
RuntimeException admissionSchedulingFailure;
|
||||
RuntimeException librarySchedulingFailure;
|
||||
final java.util.Queue<Runnable> libraryTasks = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
final org.bukkit.plugin.Plugin libraryPlugin = mock(org.bukkit.plugin.Plugin.class);
|
||||
private final Object previousLibraryScheduler;
|
||||
private final Object previousLibraryPlugin;
|
||||
private int taskId;
|
||||
private final Map<Long, java.util.Collection<Runnable>> delayed = new java.util.TreeMap<>();
|
||||
final List<Runnable> repeating = new ArrayList<>();
|
||||
final List<BukkitTask> tasks = new ArrayList<>();
|
||||
final List<Throwable> eventErrors = new ArrayList<>();
|
||||
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
final List<com.comphenix.protocol.events.PacketListener> protocolListeners = new ArrayList<>();
|
||||
private final Map<Integer, Integer> displayedStates = new HashMap<>();
|
||||
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, boolean queuedTransport) throws Exception {
|
||||
this(directory, () -> 99, () -> 1.0, "Purpur 26.2.build.2618-stable", queuedTransport);
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
|
||||
this(directory, risk, wear, "Purpur 26.2.build.2618-stable", queuedTransport);
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear, String version) throws Exception {
|
||||
this(directory, risk, wear, version, false);
|
||||
}
|
||||
|
||||
private NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear, String version, boolean queuedTransport) throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousConfiguration = io.papermc.paper.configuration.GlobalConfiguration.get();
|
||||
if (previousConfiguration == null) {
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
config.misc = config.new Misc();
|
||||
config.packetLimiter = config.new PacketLimiter();
|
||||
config.unsupportedSettings = config.new UnsupportedSettings();
|
||||
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", config);
|
||||
}
|
||||
connection = mock(net.minecraft.network.Connection.class);
|
||||
when(level.getServer()).thenReturn(nativeServer);
|
||||
when(nativeServer.packetProcessor()).thenReturn(processor);
|
||||
when(transport.shouldHandleMessage(any())).thenReturn(true);
|
||||
doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; })
|
||||
.when(transport).onPacketError(any(), any());
|
||||
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "connection", connection);
|
||||
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "server", nativeServer);
|
||||
doAnswer(inv -> {
|
||||
Object message = inv.getArgument(1);
|
||||
try {
|
||||
if (message instanceof net.minecraft.network.protocol.Packet<?> raw) dispatchPacket(raw);
|
||||
else unrelatedInbound.add(message);
|
||||
} catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
|
||||
// Actual PacketUtils enqueued the wrapper or unrelated native packet.
|
||||
}
|
||||
return null;
|
||||
}).when(connection).channelRead(any(), any());
|
||||
channel = queuedTransport ? new NativeAnvilTestChannel() : new io.netty.channel.embedded.EmbeddedChannel();
|
||||
channel.pipeline().addLast("packet_handler", connection);
|
||||
connection.channel = channel;
|
||||
nativePlayer.connection = transport;
|
||||
doAnswer(inv -> {
|
||||
if (legacyClick == null) throw new AssertionError("Unconfigured native click port");
|
||||
legacyClick.accept(inv.getArgument(0));
|
||||
return null;
|
||||
}).when(transport).handleContainerClick(any());
|
||||
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; })
|
||||
.when(protocol).addPacketListener(any());
|
||||
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; })
|
||||
.when(protocol).removePacketListener(any());
|
||||
previous = Bukkit.getServer();
|
||||
setServer(server);
|
||||
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
var mainThread = Thread.currentThread();
|
||||
when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread);
|
||||
when(server.getVersion()).thenReturn("Purpur 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 -> {
|
||||
if (inv.getArgument(0) == libraryPlugin) {
|
||||
if (!libraryPlugin.isEnabled()) throw new IllegalStateException("Required library disabled");
|
||||
if (librarySchedulingFailure != null) throw librarySchedulingFailure;
|
||||
return schedule(inv.getArgument(1), libraryTasks);
|
||||
}
|
||||
return schedule(inv.getArgument(1), nextTick);
|
||||
});
|
||||
when(libraryPlugin.getServer()).thenReturn(server);
|
||||
when(libraryPlugin.isEnabled()).thenReturn(true);
|
||||
var schedulerField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("scheduler");
|
||||
schedulerField.setAccessible(true);
|
||||
previousLibraryScheduler = schedulerField.get(null);
|
||||
var pluginField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("plugin");
|
||||
pluginField.setAccessible(true);
|
||||
previousLibraryPlugin = pluginField.get(null);
|
||||
schedulerField.set(null, new com.comphenix.protocol.scheduler.DefaultScheduler(libraryPlugin));
|
||||
pluginField.set(null, libraryPlugin);
|
||||
when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(inv -> {
|
||||
if (admissionSchedulingFailure != null) throw admissionSchedulingFailure;
|
||||
long delay = inv.getArgument(2);
|
||||
if (delay < 1) throw new AssertionError("Unexpected non-positive delayed task");
|
||||
return schedule(inv.getArgument(1), delayed.computeIfAbsent(tickCount + delay, ignored -> new ArrayList<>()));
|
||||
});
|
||||
doAnswer(ignored -> { List.copyOf(tasks).forEach(BukkitTask::cancel); return null; })
|
||||
.when(scheduler).cancelTasks(any(org.bukkit.plugin.Plugin.class));
|
||||
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
|
||||
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.getName()).thenReturn("AnvilPlayer");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
|
||||
when(nativePlayer.hasInfiniteMaterials()).thenReturn(true);
|
||||
doCallRealMethod().when(player).getItemOnCursor();
|
||||
doCallRealMethod().when(player).setItemOnCursor(any());
|
||||
when(player.getInventory()).thenReturn(new CraftInventoryPlayer(realInventory));
|
||||
when(player.hasPermission(any(String.class))).thenReturn(false);
|
||||
when(console.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
|
||||
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
|
||||
.when(console).sendMessage(any(String.class));
|
||||
doAnswer(inv -> { adminMessages.add(inv.getArgument(0)); return null; })
|
||||
.when(player).sendMessage(any(String.class));
|
||||
doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; }).when(player).sendActionBar(any(Component.class));
|
||||
doAnswer(inv -> { chats.add(inv.getArgument(0)); return null; }).when(player).sendMessage(any(Component.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.getInventory()).thenReturn(realInventory);
|
||||
when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
when(level.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
|
||||
when(level.getWorld()).thenReturn(world);
|
||||
when(level.getBlockState(position)).thenReturn(Blocks.ANVIL.defaultBlockState());
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
when(block.getType()).thenReturn(org.bukkit.Material.ANVIL);
|
||||
when(world.getBlockAt(any(org.bukkit.Location.class))).thenReturn(block);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var configField = net.minecraft.world.level.Level.class.getDeclaredField("purpurConfig");
|
||||
configField.setAccessible(true);
|
||||
configField.set(level, mock(PurpurWorldConfig.class));
|
||||
menu = new AnvilMenu(7, realInventory, ContainerLevelAccess.create(level, position));
|
||||
menu.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
nativePlayer.containerMenu = menu;
|
||||
when(player.getOpenInventory()).thenAnswer(ignored -> nativePlayer.containerMenu.getBukkitView());
|
||||
plugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(server),
|
||||
description(), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile(), risk, wear, protocol,
|
||||
() -> version);
|
||||
commands.registerAll(plugin.getName(), PluginCommandYamlParser.parse(plugin));
|
||||
plugin.getLogger().addHandler(new java.util.logging.Handler() {
|
||||
@Override public void publish(java.util.logging.LogRecord record) {
|
||||
if (record.getThrown() != null) eventErrors.add(record.getThrown());
|
||||
}
|
||||
@Override public void flush() {}
|
||||
@Override public void close() {}
|
||||
});
|
||||
if (plugin.getServer() != server || plugin.getCommand("inventoryhelperadmin") == null) {
|
||||
throw new AssertionError("Explicit native command wiring failed: server=" + (plugin.getServer() == server)
|
||||
+ ", command=" + commands.getCommand("inventoryhelperadmin")
|
||||
+ ", owner=" + (commands.getCommand("inventoryhelperadmin") instanceof PluginCommand command
|
||||
&& command.getPlugin() == plugin));
|
||||
}
|
||||
plugin.setEnabled(true);
|
||||
// Even event-level fixtures must echo a genuinely published client frame, never the
|
||||
// server counter. Reuse the pinned native synchronizer with only its transport mocked.
|
||||
doAnswer(inv -> { send(inv.getArgument(0)); return null; })
|
||||
.when(transport).send(any(net.minecraft.network.protocol.Packet.class));
|
||||
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
|
||||
.getDeclaredConstructor(ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
Object created = constructor.newInstance(nativePlayer);
|
||||
if (!(created instanceof net.minecraft.world.inventory.ContainerSynchronizer synchronizer)) {
|
||||
throw new IllegalStateException("Unexpected native synchronizer");
|
||||
}
|
||||
var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(realInventory, true, nativePlayer);
|
||||
var inventoryField = net.minecraft.world.entity.player.Player.class.getDeclaredField("inventoryMenu");
|
||||
if (inventoryField.getType() != net.minecraft.world.inventory.InventoryMenu.class) {
|
||||
throw new IllegalStateException("Unexpected inventory-menu port");
|
||||
}
|
||||
inventoryField.setAccessible(true);
|
||||
inventoryField.set(nativePlayer, inventoryMenu);
|
||||
inventoryMenu.setSynchronizer(synchronizer);
|
||||
menu.setSynchronizer(synchronizer);
|
||||
}
|
||||
|
||||
void inputs(ItemStack left, ItemStack right) {
|
||||
// setItem triggers SimpleContainer.setChanged -> slotsChanged -> actual result event dispatch.
|
||||
menu.getSlot(0).set(CraftItemStack.asNMSCopy(left));
|
||||
menu.getSlot(1).set(CraftItemStack.asNMSCopy(right));
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
void admin(org.bukkit.command.CommandSender sender, String key, int value) {
|
||||
var command = plugin.getCommand("inventoryhelperadmin");
|
||||
if (command == null || command != commands.getCommand("inventoryhelperadmin")) {
|
||||
throw new AssertionError("Missing actual registered administrator command");
|
||||
}
|
||||
if (!command.execute(sender, "inventoryhelperadmin", new String[] {"config", key, Integer.toString(value)})) {
|
||||
throw new AssertionError("Registered administrator command refused execution");
|
||||
}
|
||||
}
|
||||
|
||||
void acknowledgeSave() throws InterruptedException {
|
||||
Runnable acknowledgment = nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (acknowledgment == null) throw new AssertionError("Durable save did not schedule acknowledgment");
|
||||
acknowledgment.run();
|
||||
assertNoEventErrors();
|
||||
if (adminMessages.stream().noneMatch(message -> message.endsWith("(saved)"))) {
|
||||
throw new AssertionError("No durable success acknowledgment: " + adminMessages);
|
||||
}
|
||||
}
|
||||
|
||||
void prepareAgain() {
|
||||
menu.slotsChanged(menu.getSlot(0).container);
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
org.bukkit.event.inventory.InventoryClickEvent click(int rawSlot,
|
||||
org.bukkit.event.inventory.ClickType click, org.bukkit.event.inventory.InventoryAction action,
|
||||
int button, net.minecraft.world.inventory.ContainerInput input) {
|
||||
var event = new org.bukkit.event.inventory.InventoryClickEvent(menu.getBukkitView(),
|
||||
rawSlot == 2 ? org.bukkit.event.inventory.InventoryType.SlotType.RESULT
|
||||
: org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
rawSlot, click, action, button);
|
||||
var packet = receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
menu.containerId, clientState(menu), (short) rawSlot, (byte) button, input,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
|
||||
event.setCancelled(true); // A dropped/revoked transport record never reaches Bukkit.
|
||||
if (!packet.isCancelled()) {
|
||||
legacyClick = translated -> {
|
||||
event.setCancelled(false);
|
||||
events.callEvent(event);
|
||||
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
|
||||
};
|
||||
try { dispatchInbound(packet.getPacket().getHandle()); }
|
||||
finally { legacyClick = null; }
|
||||
}
|
||||
assertNoEventErrors();
|
||||
return event;
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
|
||||
var event = clientEvent(payload);
|
||||
dispatchProtocol(event);
|
||||
if (!event.isCancelled()) {
|
||||
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
|
||||
if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event));
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
void dispatchInbound(Object record) {
|
||||
channel.pipeline().fireChannelRead(record);
|
||||
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.checkException();
|
||||
}
|
||||
|
||||
private void dispatchPacket(net.minecraft.network.protocol.Packet<?> raw) {
|
||||
if (raw instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(transport);
|
||||
else if (raw instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(transport);
|
||||
else throw new AssertionError("Unsupported native game packet port: " + raw.getClass());
|
||||
}
|
||||
|
||||
static void field(Class<?> owner, Object target, String name, Object value) throws ReflectiveOperationException {
|
||||
var field = owner.getDeclaredField(name);
|
||||
if (value != null && !field.getType().isInstance(value)) throw new IllegalStateException("Unexpected native field: " + field);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent clientEvent(Object payload) {
|
||||
var container = new com.comphenix.protocol.events.PacketContainer(
|
||||
com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, payload);
|
||||
return com.comphenix.protocol.events.PacketEvent.fromClient(protocol, container, player);
|
||||
}
|
||||
|
||||
void dispatchProtocol(com.comphenix.protocol.events.PacketEvent event) {
|
||||
List.copyOf(protocolListeners).stream()
|
||||
.filter(listener -> listener.getReceivingWhitelist().getTypes().contains(event.getPacketType()))
|
||||
.sorted(java.util.Comparator.comparingInt(listener -> listener.getReceivingWhitelist().getPriority().getSlot()))
|
||||
.forEach(listener -> listener.onPacketReceiving(event));
|
||||
}
|
||||
|
||||
int clientState(net.minecraft.world.inventory.AbstractContainerMenu active) {
|
||||
Integer state = displayedStates.get(active.containerId);
|
||||
if (state == null) throw new AssertionError("Menu has no displayed frame: " + active.containerId);
|
||||
return state;
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw) {
|
||||
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 pumpLibraryTasks() {
|
||||
if (!server.isPrimaryThread()) throw new AssertionError("Library callbacks must run on main");
|
||||
Runnable task;
|
||||
while ((task = libraryTasks.poll()) != null) task.run();
|
||||
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks();
|
||||
}
|
||||
|
||||
void tick() {
|
||||
pumpLibraryTasks();
|
||||
var due = delayed.remove(++tickCount);
|
||||
if (due != null) List.copyOf(due).forEach(Runnable::run);
|
||||
var ready = List.copyOf(nextTick);
|
||||
nextTick.clear();
|
||||
ready.forEach(Runnable::run);
|
||||
List.copyOf(repeating).forEach(Runnable::run);
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
// Paper disables the plugin before cancelling its scheduler tasks. Do not let a fixture
|
||||
// silently supply the expiry callbacks that real disable removes.
|
||||
void disableAndCancelTasks() {
|
||||
plugin.setEnabled(false);
|
||||
server.getScheduler().cancelTasks(plugin);
|
||||
}
|
||||
|
||||
void assertNoEventErrors() {
|
||||
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
|
||||
}
|
||||
|
||||
private BukkitTask schedule(Runnable runnable, java.util.Collection<Runnable> queue) {
|
||||
queue.add(runnable);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
|
||||
when(task.getTaskId()).thenReturn(++taskId);
|
||||
// Only owning-plugin tasks belong to realistic owner-disable cancellation.
|
||||
if (queue != libraryTasks) tasks.add(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.finishAndReleaseAll();
|
||||
else ((NativeAnvilTestChannel) channel).finish();
|
||||
field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", previousLibraryScheduler);
|
||||
field(com.comphenix.protocol.ProtocolLibrary.class, null, "plugin", previousLibraryPlugin);
|
||||
processor.close();
|
||||
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", previousConfiguration);
|
||||
setServer(previous);
|
||||
} catch (Exception failure) {
|
||||
throw new AssertionError("Could not restore server", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static PluginDescriptionFile description() throws java.io.IOException, org.bukkit.plugin.InvalidDescriptionException {
|
||||
// Declared ProtocolLib also ships plugin.yml. Select the actual generated owning metadata,
|
||||
// not whichever dependency happens to be first on the native-test classpath.
|
||||
PluginDescriptionFile selected = null;
|
||||
var resources = SpigotInventoryHelperPlugin.class.getClassLoader().getResources("plugin.yml");
|
||||
while (resources.hasMoreElements()) {
|
||||
try (var stream = resources.nextElement().openStream()) {
|
||||
var description = new PluginDescriptionFile(stream);
|
||||
if (description.getMain().equals(SpigotInventoryHelperPlugin.class.getName())) {
|
||||
if (selected != null) throw new AssertionError("Duplicate owning plugin metadata");
|
||||
selected = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selected == null) throw new AssertionError("Missing generated owning plugin metadata");
|
||||
return selected;
|
||||
}
|
||||
|
||||
static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
+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());
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.raw;
|
||||
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.sword;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.network.HashedStack;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Terminal ownership through actual local ProtocolLib forwarding/pipeline/native queue.
|
||||
* No sockets, live server, or arbitrary gameplay-plugin compatibility claim.
|
||||
*/
|
||||
class NativeAnvilLifecycleRegressionTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
|
||||
void finalFilterNoForwardOfAdmittedHandleRestoresCapacityAtExpiry(String mode) throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
var foreign = filter(f, event -> {
|
||||
if (mode.equals("cancel")) event.setCancelled(true);
|
||||
else event.setPacket(new PacketContainer(event.getPacketType(), inert(f)));
|
||||
});
|
||||
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) transport.receive(raw(f));
|
||||
for (var event : transport.received) assertEquals(mode.equals("cancel"), event.isCancelled());
|
||||
expire(f);
|
||||
transport.forwardToNativeQueue(); // Resolve queued fence before asserting capacity.
|
||||
f.protocol.removePacketListener(foreign);
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getLast().isCancelled(),
|
||||
"Final filter never forwarded admitted A: expiry must restore its capacity");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledPacketDisableDoesNotRelyOnPluginExpiryAndPreservesForeignHandler() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
var probe = new io.netty.channel.ChannelInboundHandlerAdapter();
|
||||
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
filter(f, event -> event.setCancelled(true));
|
||||
int before = f.channel.pipeline().names().size();
|
||||
transport.receive(raw(f));
|
||||
assertTrue(transport.received.getFirst().isCancelled());
|
||||
f.disableAndCancelTasks();
|
||||
expire(f);
|
||||
transport.forwardToNativeQueue();
|
||||
assertFalse(transport.processor.executeSinglePacket(), "Cancelled means no native forward");
|
||||
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
|
||||
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
|
||||
assertEquals(before - 1, f.channel.pipeline().names().size(),
|
||||
"Resolved no-forward shutdown removes only owned hook without a plugin timer");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void admissionTimerAcquisitionFailureRollsBackCapacity() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
f.admissionSchedulingFailure = new IllegalStateException("Controlled timer acquisition failure");
|
||||
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
|
||||
transport.receive(raw(f));
|
||||
assertTrue(transport.received.getLast().isCancelled());
|
||||
}
|
||||
transport.forwardToNativeQueue();
|
||||
f.admissionSchedulingFailure = null;
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getLast().isCancelled(),
|
||||
"Failed acquisition must not fill the exact-identity budget");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeQueueSkipWithoutWrapperHandleRestoresCapacityAtExpiry() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getLast().isCancelled());
|
||||
}
|
||||
transport.forwardToNativeQueue();
|
||||
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
|
||||
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
}
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
assertTrue(packet.clicks.isEmpty(), "Native queue really skipped wrapper.handle");
|
||||
expire(f);
|
||||
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getLast().isCancelled(),
|
||||
"Revoked already-wrapped ownership can retire without handle/finally");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aggregateNativeRefusalWithoutWrapperHandleRetiresHookAtExpiryAndClose() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
|
||||
var previous = config.packetLimiter;
|
||||
try {
|
||||
var limiter = config.new PacketLimiter();
|
||||
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
|
||||
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK);
|
||||
limiter.overrides = new java.util.HashMap<>();
|
||||
config.packetLimiter = limiter;
|
||||
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
|
||||
connection.channel = f.channel;
|
||||
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
|
||||
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
|
||||
f.transport, "connection", connection);
|
||||
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
|
||||
// Keep the inspected frame-synchronizer port: changing the listener's
|
||||
// connection must not bypass the fixture's actual ProtocolLib frame publication.
|
||||
doAnswer(inv -> { f.connection.send(inv.getArgument(0), inv.getArgument(1), true); return null; })
|
||||
.when(f.transport).send(any(net.minecraft.network.protocol.Packet.class), any());
|
||||
var disconnects = new java.util.concurrent.CopyOnWriteArrayList<Object>();
|
||||
// Controlled outbound port: hold completion of the real native disconnect
|
||||
// write so the channel stays active; do not silently let disconnect clear ownership.
|
||||
var foreign = new io.netty.channel.ChannelOutboundHandlerAdapter() {
|
||||
@Override public void write(io.netty.channel.ChannelHandlerContext context, Object message,
|
||||
io.netty.channel.ChannelPromise promise) { disconnects.add(message); }
|
||||
};
|
||||
f.channel.pipeline().addFirst("held-disconnect-write", foreign);
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
int before = f.channel.pipeline().names().size();
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getFirst().isCancelled());
|
||||
transport.forwardToNativeQueue();
|
||||
assertFalse(transport.processor.executeSinglePacket(), "Aggregate refusal happens before wrapper.handle");
|
||||
assertTrue(disconnects.stream().anyMatch(message -> message instanceof
|
||||
net.minecraft.network.protocol.common.ClientboundDisconnectPacket), "Native kick path actually executed");
|
||||
assertTrue(f.channel.isActive(), "Retirement proof must not rely on channelInactive");
|
||||
expire(f);
|
||||
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
|
||||
assertSame(foreign, f.channel.pipeline().get("held-disconnect-write"));
|
||||
assertSame(connection, f.channel.pipeline().get("packet_handler"));
|
||||
assertEquals(before - 1, f.channel.pipeline().names().size(),
|
||||
"Already-wrapped aggregate refusal must not strand retired owned hook");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
} finally { config.packetLimiter = previous; }
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"filter", "post"})
|
||||
void reentrantDisableKeepsUnresolvedOrScheduledExactRawSafeBeyondExpiryAndWindowReuse(String phase)
|
||||
throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
filter(f, event -> {
|
||||
if (phase.equals("filter")) {
|
||||
// Cancellation is NOT terminal inside a filter: the same callback can
|
||||
// still restore the captured exact raw before ProtocolLib schedules it.
|
||||
event.setCancelled(true);
|
||||
f.disableAndCancelTasks();
|
||||
event.setCancelled(false);
|
||||
} else {
|
||||
post(f, event, forwarded -> {
|
||||
// ProtocolLib has already captured/scheduled the exact raw. A
|
||||
// post cancellation is not proof that this raw will never arrive.
|
||||
forwarded.setReadOnly(false);
|
||||
forwarded.setCancelled(true);
|
||||
f.disableAndCancelTasks();
|
||||
});
|
||||
}
|
||||
});
|
||||
transport.receive(raw(f));
|
||||
assertEquals(phase.equals("post"), transport.received.getFirst().isCancelled());
|
||||
expire(f); f.tick(); // Raw exact forward is still held in the real library event loop.
|
||||
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
|
||||
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
|
||||
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
f.nativePlayer.containerMenu = reopened;
|
||||
reopened.setSynchronizer(packet.synchronizer);
|
||||
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
|
||||
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
|
||||
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
|
||||
transport.forwardToNativeQueue();
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors(); f.tick();
|
||||
assertTrue(packet.clicks.isEmpty(), "Late revoked exact raw must not fall through to vanilla");
|
||||
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
|
||||
assertEquals(sword(4), reopened.getBukkitView().getTopInventory().getItem(0));
|
||||
assertEquals(100, f.nativePlayer.experienceLevel);
|
||||
assertEquals(0, rolls.get());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
|
||||
void postHandleMutationCannotEraseCapturedForwardAndRetiredWrapperDenial(String mode) throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
int before = f.channel.pipeline().names().size();
|
||||
filter(f, event -> post(f, event, forwarded -> {
|
||||
forwarded.setReadOnly(false);
|
||||
if (mode.equals("cancel")) forwarded.setCancelled(true);
|
||||
else forwarded.setPacket(new PacketContainer(forwarded.getPacketType(), inert(f)));
|
||||
f.disableAndCancelTasks();
|
||||
}));
|
||||
transport.receive(raw(f));
|
||||
assertEquals(1, NetworkMarker.getNetworkMarker(transport.received.getFirst()).getPostListeners().size(),
|
||||
"Only the foreign marker listener exists; ownership never adds an acknowledgement");
|
||||
expire(f);
|
||||
assertEquals(before, f.channel.pipeline().names().size(), "Captured exact raw is still unresolved");
|
||||
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
|
||||
transport.forwardToNativeQueue();
|
||||
assertEquals(before - 1, f.channel.pipeline().names().size(),
|
||||
"Late raw is wrapped revoked and releases the retired hook without wrapper.handle");
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty(), "Skipped revoked wrapper delivers nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void mainFenceSnapshotExcludesSubsequentReceiveWhoseRawIsBehindBarrier() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
var cancellation = filter(f, event -> event.setCancelled(true));
|
||||
transport.receive(raw(f));
|
||||
transport.pumpLibraryTasks(); // First snapshot/barrier precedes the next raw forward.
|
||||
f.protocol.removePacketListener(cancellation);
|
||||
transport.receive(raw(f));
|
||||
assertFalse(transport.received.getLast().isCancelled());
|
||||
transport.forwardToNativeQueue();
|
||||
assertTrue(transport.processor.executeSinglePacket());
|
||||
transport.assertNoErrors();
|
||||
assertEquals(1, packet.clicks.size(), "Later admitted raw must still reach the exact native wrapper");
|
||||
f.tick();
|
||||
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailablePublicLibrarySchedulerWithholdsEnhancedOutputBeforeHookInstallation() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
var scheduler = com.comphenix.protocol.ProtocolLibrary.getScheduler();
|
||||
int before = f.channel.pipeline().names().size();
|
||||
try {
|
||||
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", null);
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeLoop();
|
||||
assertNull(f.output(), "No library-main fence port means no enhanced bypass/output");
|
||||
assertEquals(before, f.channel.pipeline().names().size());
|
||||
} finally {
|
||||
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", scheduler);
|
||||
}
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
assertNotNull(f.output(), "A failed constructor did not leave an owned boundary behind");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedLibraryFenceKeepsUnresolvedTombstoneRatherThanClaimingNoForward() throws Exception {
|
||||
try (var f = new NativeAnvilFixture(directory, true);
|
||||
var packet = new NativeAnvilPacketFixture(f);
|
||||
var transport = new NativeAnvilTransportFixture(f, packet)) {
|
||||
f.inputs(sword(5), sword(5));
|
||||
transport.acknowledgeHook();
|
||||
int before = f.channel.pipeline().names().size();
|
||||
f.librarySchedulingFailure = new IllegalStateException("Controlled library fence rejection");
|
||||
transport.receive(raw(f));
|
||||
assertTrue(transport.received.getFirst().isCancelled());
|
||||
f.disableAndCancelTasks();
|
||||
expire(f);
|
||||
transport.forwardToNativeQueue();
|
||||
assertFalse(transport.processor.executeSinglePacket());
|
||||
assertEquals(before, f.channel.pipeline().names().size(),
|
||||
"Failed fence cannot prove NO_FORWARD; unresolved ownership stays until raw/disconnect");
|
||||
assertTrue(packet.clicks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static void post(NativeAnvilFixture f, PacketEvent event, Consumer<PacketEvent> callback) {
|
||||
// A foreign post listener owns its own marker; production no longer creates one.
|
||||
var marker = NetworkMarker.getNetworkMarker(event);
|
||||
if (marker == null) {
|
||||
marker = new NetworkMarker(ConnectionSide.CLIENT_SIDE, event.getPacketType());
|
||||
event.setNetworkMarker(marker);
|
||||
}
|
||||
marker.addPostListener(new PacketPostListener() {
|
||||
@Override public org.bukkit.plugin.Plugin getPlugin() { return f.plugin; }
|
||||
@Override public void onPostEvent(PacketEvent forwarded) { callback.accept(forwarded); }
|
||||
});
|
||||
}
|
||||
|
||||
private static PacketAdapter filter(NativeAnvilFixture f, Consumer<PacketEvent> callback) {
|
||||
var listener = new PacketAdapter(f.plugin, ListenerPriority.MONITOR, PacketType.Play.Client.WINDOW_CLICK) {
|
||||
@Override public void onPacketReceiving(PacketEvent event) { callback.accept(event); }
|
||||
};
|
||||
f.protocol.addPacketListener(listener);
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static ServerboundContainerClickPacket inert(NativeAnvilFixture f) {
|
||||
return new ServerboundContainerClickPacket(f.menu.containerId, f.menu.getStateId(), (short) -999,
|
||||
(byte) 0, ContainerInput.PICKUP, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
|
||||
}
|
||||
|
||||
private static void expire(NativeAnvilFixture f) {
|
||||
for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.papermc.paper.configuration.GlobalConfiguration;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.HashedStack;
|
||||
import net.minecraft.network.PacketProcessor;
|
||||
import net.minecraft.network.protocol.Packet;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.network.ServerCommonPacketListenerImpl;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.world.entity.player.Abilities;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.inventory.ContainerInput;
|
||||
import net.minecraft.world.inventory.ContainerSynchronizer;
|
||||
import net.minecraft.world.inventory.InventoryMenu;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import org.bukkit.craftbukkit.CraftServer;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
|
||||
/** Real packet classification/event dispatch/native clicked; explicit world/thread/network ports only. */
|
||||
final class NativeAnvilPacketFixture implements AutoCloseable {
|
||||
final NativeAnvilFixture anvil;
|
||||
final MinecraftServer nativeServer;
|
||||
final PacketProcessor processor;
|
||||
// Final external CraftServer port only; retain the suite's subclass maker everywhere else.
|
||||
final CraftServer craftServer = mock(CraftServer.class,
|
||||
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
final Connection connection;
|
||||
final ServerGamePacketListenerImpl handler;
|
||||
private final GlobalConfiguration previousConfiguration;
|
||||
final Abilities abilities = new Abilities();
|
||||
final InventoryMenu inventoryMenu;
|
||||
final ContainerSynchronizer synchronizer;
|
||||
final List<Packet<?>> packets = new ArrayList<>();
|
||||
final java.util.Map<Packet<?>, String> privateRiskAtSend = new java.util.IdentityHashMap<>();
|
||||
final List<InventoryClickEvent> clicks = new ArrayList<>();
|
||||
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
|
||||
final List<net.minecraft.world.item.ItemStack> drops = new ArrayList<>();
|
||||
|
||||
NativeAnvilPacketFixture(NativeAnvilFixture anvil) throws Exception {
|
||||
this.anvil = anvil;
|
||||
previousConfiguration = GlobalConfiguration.get();
|
||||
if (previousConfiguration == null) {
|
||||
// Connection static initialization and the packet equipment guard need default config.
|
||||
// No file/configuration I/O; restore the singleton at this fixture's boundary.
|
||||
var config = new GlobalConfiguration();
|
||||
config.misc = config.new Misc();
|
||||
config.unsupportedSettings = config.new UnsupportedSettings();
|
||||
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
|
||||
}
|
||||
connection = anvil.connection;
|
||||
handler = anvil.transport;
|
||||
nativeServer = anvil.nativeServer;
|
||||
processor = anvil.processor;
|
||||
when(anvil.level.getServer()).thenReturn(nativeServer);
|
||||
when(anvil.level.getCraftServer()).thenReturn(craftServer);
|
||||
when(anvil.nativePlayer.isAlive()).thenReturn(true);
|
||||
doCallRealMethod().when(anvil.nativePlayer).doCloseContainer();
|
||||
doCallRealMethod().when(anvil.nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
|
||||
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean());
|
||||
when(nativeServer.packetProcessor()).thenReturn(processor);
|
||||
when(craftServer.getPluginManager()).thenReturn(anvil.events);
|
||||
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
|
||||
when(anvil.nativePlayer.getAbilities()).thenReturn(abilities);
|
||||
when(anvil.nativePlayer.registryAccess()).thenReturn(
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
survival();
|
||||
inventoryMenu = new InventoryMenu(anvil.realInventory, true, anvil.nativePlayer);
|
||||
field(Player.class, anvil.nativePlayer, "inventoryMenu", InventoryMenu.class, inventoryMenu);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "server", MinecraftServer.class, nativeServer);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "cserver", CraftServer.class, craftServer);
|
||||
field(ServerCommonPacketListenerImpl.class, handler, "connection", Connection.class, connection);
|
||||
field(ServerGamePacketListenerImpl.class, handler, "player", net.minecraft.server.level.ServerPlayer.class,
|
||||
anvil.nativePlayer);
|
||||
anvil.nativePlayer.connection = handler;
|
||||
doAnswer(inv -> {
|
||||
Packet<?> sent = inv.getArgument(0);
|
||||
var outgoing = anvil.send(sent);
|
||||
if (outgoing != null) {
|
||||
if (outgoing.isCancelled()) return null;
|
||||
Object wire = outgoing.getPacket().getHandle();
|
||||
if (!(wire instanceof Packet<?> translated)) throw new AssertionError("Unsupported wire payload");
|
||||
sent = translated;
|
||||
}
|
||||
packets.add(sent);
|
||||
privateRiskAtSend.put(sent, anvil.actionBars.isEmpty() ? ""
|
||||
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
|
||||
.serialize(anvil.actionBars.getLast()));
|
||||
return null;
|
||||
}).when(connection).send(any(Packet.class), any(), anyBoolean());
|
||||
doCallRealMethod().when(handler).send(any(Packet.class));
|
||||
doCallRealMethod().when(handler).send(any(Packet.class), any());
|
||||
doCallRealMethod().when(handler).handleContainerClick(any(ServerboundContainerClickPacket.class));
|
||||
// Reuse the runtime's cheap native synchronizer, including actual hashed remote slots and packets.
|
||||
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
|
||||
.getDeclaredConstructor(net.minecraft.server.level.ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
Object created = constructor.newInstance(anvil.nativePlayer);
|
||||
if (!(created instanceof ContainerSynchronizer nativeSynchronizer)) {
|
||||
throw new IllegalStateException("Unexpected native player synchronizer: " + created.getClass());
|
||||
}
|
||||
synchronizer = nativeSynchronizer;
|
||||
field(net.minecraft.server.level.ServerPlayer.class, anvil.nativePlayer, "containerSynchronizer",
|
||||
ContainerSynchronizer.class, synchronizer);
|
||||
inventoryMenu.setSynchronizer(synchronizer);
|
||||
anvil.menu.setSynchronizer(synchronizer);
|
||||
doCallRealMethod().when(anvil.player).updateInventory();
|
||||
doCallRealMethod().when(anvil.player).closeInventory();
|
||||
doCallRealMethod().when(anvil.player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
// An observer of the registered event, never a replacement event supplied to production.
|
||||
listen(EventPriority.MONITOR, clicks::add);
|
||||
}
|
||||
|
||||
void survival() {
|
||||
abilities.instabuild = false;
|
||||
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(false);
|
||||
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.SURVIVAL);
|
||||
}
|
||||
|
||||
void creative() {
|
||||
abilities.instabuild = true;
|
||||
when(anvil.player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
|
||||
when(anvil.nativePlayer.hasInfiniteMaterials()).thenReturn(true);
|
||||
when(anvil.nativePlayer.gameMode()).thenReturn(GameType.CREATIVE);
|
||||
}
|
||||
|
||||
void listen(EventPriority priority, Consumer<InventoryClickEvent> observer) {
|
||||
anvil.events.registerEvent(InventoryClickEvent.class, new Listener() {}, priority,
|
||||
(ignored, event) -> {
|
||||
if (!(event instanceof InventoryClickEvent click)) {
|
||||
throw new IllegalStateException("Unexpected native click event: " + event.getClass());
|
||||
}
|
||||
observer.accept(click);
|
||||
}, anvil.plugin, false);
|
||||
}
|
||||
|
||||
void click(int slot, int button, ContainerInput input) {
|
||||
click(anvil.nativePlayer.containerMenu.containerId, anvil.clientState(anvil.nativePlayer.containerMenu),
|
||||
slot, button, input);
|
||||
}
|
||||
|
||||
void click(int containerId, int stateId, int slot, int button, ContainerInput input) {
|
||||
var raw = new ServerboundContainerClickPacket(containerId, stateId,
|
||||
(short) slot, (byte) button, input, new Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
|
||||
var event = anvil.receive(raw);
|
||||
received.add(event);
|
||||
if (!event.isCancelled()) {
|
||||
Object admitted = event.getPacket().getHandle();
|
||||
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
|
||||
anvil.dispatchInbound(translated);
|
||||
}
|
||||
anvil.assertNoEventErrors();
|
||||
}
|
||||
|
||||
void closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason reason) {
|
||||
anvil.nativePlayer.closeContainer(reason);
|
||||
anvil.assertNoEventErrors();
|
||||
}
|
||||
|
||||
void dieAndClose() {
|
||||
// The world/damage boundary reports death; native menu removal owns remainder drops.
|
||||
when(anvil.nativePlayer.isAlive()).thenReturn(false);
|
||||
when(anvil.nativePlayer.isDeadOrDying()).thenReturn(true);
|
||||
when(anvil.player.isDead()).thenReturn(true);
|
||||
closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.DEATH);
|
||||
}
|
||||
|
||||
void inactiveView() {
|
||||
anvil.nativePlayer.containerMenu = inventoryMenu;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
try {
|
||||
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, previousConfiguration);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError("Could not restore native config port", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void field(Class<?> owner, Object target, String name, Class<T> type, T value)
|
||||
throws ReflectiveOperationException {
|
||||
var field = owner.getDeclaredField(name);
|
||||
if (field.getType() != type) throw new IllegalStateException("Unexpected native field type: " + field);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import io.netty.channel.*;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Real registered local pipeline/event loop, with no byte transport or sockets. */
|
||||
final class NativeAnvilTestChannel extends AbstractChannel {
|
||||
final DefaultEventLoop loop = new DefaultEventLoop();
|
||||
private final ChannelConfig config = new DefaultChannelConfig(this);
|
||||
final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
private volatile boolean open = true;
|
||||
|
||||
NativeAnvilTestChannel() throws InterruptedException {
|
||||
super(null);
|
||||
loop.register(this).sync();
|
||||
}
|
||||
|
||||
void acknowledge() throws Exception { loop.submit(() -> {}).get(10, TimeUnit.SECONDS); }
|
||||
|
||||
void finish() throws InterruptedException {
|
||||
close().sync();
|
||||
loop.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync();
|
||||
}
|
||||
|
||||
@Override public ChannelConfig config() { return config; }
|
||||
@Override public ChannelMetadata metadata() { return new ChannelMetadata(false); }
|
||||
@Override public boolean isOpen() { return open; }
|
||||
@Override public boolean isActive() { return open && isRegistered(); }
|
||||
@Override protected boolean isCompatible(EventLoop candidate) { return candidate == loop; }
|
||||
@Override protected java.net.SocketAddress localAddress0() { return null; }
|
||||
@Override protected java.net.SocketAddress remoteAddress0() { return null; }
|
||||
@Override protected void doBind(java.net.SocketAddress address) { throw new UnsupportedOperationException("No sockets"); }
|
||||
@Override protected void doDisconnect() { doClose(); }
|
||||
@Override protected void doClose() { open = false; }
|
||||
@Override protected void doBeginRead() { }
|
||||
@Override protected void doWrite(ChannelOutboundBuffer buffer) { while (buffer.current() != null) buffer.remove(); }
|
||||
@Override protected AbstractUnsafe newUnsafe() {
|
||||
return new AbstractUnsafe() {
|
||||
@Override public void connect(java.net.SocketAddress remote, java.net.SocketAddress local,
|
||||
ChannelPromise promise) { promise.setFailure(new UnsupportedOperationException("No sockets")); }
|
||||
};
|
||||
}
|
||||
@Override protected DefaultChannelPipeline newChannelPipeline() {
|
||||
return new DefaultChannelPipeline(this) {
|
||||
@Override protected void onUnhandledInboundException(Throwable failure) {
|
||||
if (!(failure instanceof net.minecraft.server.RunningOnDifferentThreadException)) failures.add(failure);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.injector.ListenerManager;
|
||||
import com.comphenix.protocol.injector.netty.channel.NettyChannelInjector;
|
||||
import io.netty.channel.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import net.minecraft.network.PacketProcessor;
|
||||
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
|
||||
|
||||
/** Actual library forwarding, one registered real event loop/pipeline, and real native queue.
|
||||
* Only byte I/O, packet lookup/filter invocation and external player/world ports are controlled.
|
||||
* No sockets/server startup. Handler readiness and library-main callbacks require explicit pumping.
|
||||
*/
|
||||
final class NativeAnvilTransportFixture implements AutoCloseable {
|
||||
final NativeAnvilPacketFixture packet;
|
||||
final PacketProcessor processor;
|
||||
private final NativeAnvilFixture f;
|
||||
private final DefaultEventLoop loop;
|
||||
private final CountDownLatch forward = new CountDownLatch(1);
|
||||
private boolean held;
|
||||
private boolean receiving;
|
||||
private final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
private final NettyChannelInjector injector;
|
||||
private final ChannelHandlerContext context;
|
||||
final List<PacketEvent> received = new ArrayList<>();
|
||||
|
||||
NativeAnvilTransportFixture(NativeAnvilFixture f, NativeAnvilPacketFixture packet) throws Exception {
|
||||
this.f = f;
|
||||
this.packet = packet;
|
||||
this.processor = f.processor;
|
||||
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
|
||||
doAnswer(inv -> { failures.add(inv.getArgument(1)); return null; }).when(packet.handler).onPacketError(any(), any());
|
||||
if (!(f.channel instanceof NativeAnvilTestChannel channel)) {
|
||||
throw new AssertionError("Transport must start with its real channel; native handlers are not sharable");
|
||||
}
|
||||
loop = channel.loop;
|
||||
var listeners = mock(ListenerManager.class);
|
||||
when(listeners.hasMainThreadListener(PacketType.Play.Client.WINDOW_CLICK)).thenReturn(true);
|
||||
doAnswer(inv -> {
|
||||
PacketEvent event = inv.getArgument(0);
|
||||
received.add(event);
|
||||
f.dispatchProtocol(event);
|
||||
return null;
|
||||
}).when(listeners).invokeInboundPacketListeners(any());
|
||||
injector = new NettyChannelInjector(f.player, packet.connection, channel, listeners,
|
||||
mock(com.comphenix.protocol.injector.netty.channel.InjectionFactory.class),
|
||||
mock(com.comphenix.protocol.error.ErrorReporter.class));
|
||||
channel.pipeline().addFirst("protocollib-boundary", new ChannelInboundHandlerAdapter());
|
||||
acknowledgeLoop();
|
||||
context = channel.pipeline().context("protocollib-boundary");
|
||||
if (context.channel() != packet.connection.channel || context.executor() != channel.eventLoop()
|
||||
|| channel.eventLoop() != loop) throw new AssertionError("Library and native loop must be identical");
|
||||
}
|
||||
|
||||
void acknowledgeHook() throws Exception {
|
||||
acknowledgeLoop(); // handlerAdded on the actual event loop, not an inline readiness mock
|
||||
f.tick(); // queued owner readiness/recompute callback on main
|
||||
acknowledgeLoop();
|
||||
if (f.output() == null) throw new AssertionError("Readiness callback did not publish enhanced output");
|
||||
}
|
||||
|
||||
void receive(ServerboundContainerClickPacket raw) throws Exception {
|
||||
if (!held) {
|
||||
held = true;
|
||||
loop.execute(() -> {
|
||||
try { if (!forward.await(10, TimeUnit.SECONDS)) failures.add(new AssertionError("Forward gate timed out")); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); failures.add(failure); }
|
||||
});
|
||||
}
|
||||
var method = NettyChannelInjector.class.getDeclaredMethod("processInboundInternal",
|
||||
ChannelHandlerContext.class, PacketContainer.class);
|
||||
method.setAccessible(true);
|
||||
receiving = true;
|
||||
try { method.invoke(injector, context, new PacketContainer(PacketType.Play.Client.WINDOW_CLICK, raw)); }
|
||||
finally { receiving = false; }
|
||||
}
|
||||
|
||||
void pumpLibraryTasks() {
|
||||
if (receiving) throw new AssertionError("Fence main callback must run after library callback returns");
|
||||
f.pumpLibraryTasks();
|
||||
}
|
||||
|
||||
void forwardToNativeQueue() throws Exception {
|
||||
pumpLibraryTasks(); // queues barriers behind captured raw tasks, never inline with filters/posts
|
||||
forward.countDown();
|
||||
acknowledgeLoop();
|
||||
}
|
||||
|
||||
void acknowledgeLoop() throws Exception {
|
||||
loop.submit(() -> {}).get(10, TimeUnit.SECONDS);
|
||||
failures.addAll(((NativeAnvilTestChannel) f.channel).failures);
|
||||
assertNoErrors();
|
||||
}
|
||||
|
||||
void assertNoErrors() {
|
||||
if (!failures.isEmpty()) throw new AssertionError("Transport/native dispatch failed", failures.getFirst());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
forward.countDown();
|
||||
try { ((NativeAnvilTestChannel) f.channel).acknowledge(); }
|
||||
catch (Exception failure) { throw new AssertionError("Transport shutdown acknowledgment failed", failure); }
|
||||
assertNoErrors();
|
||||
}
|
||||
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
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("snow_powder_snow"), key("dropper_dispenser"),
|
||||
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
@TempDir Path directory;
|
||||
private Map<NamespacedKey, Recipe> registry;
|
||||
private PaperPluginManagerImpl events;
|
||||
private SimpleCommandMap commands;
|
||||
private Logger logger;
|
||||
private List<LogRecord> logs;
|
||||
private Handler logHandler;
|
||||
private SpigotInventoryHelperPlugin createdPlugin;
|
||||
private com.comphenix.protocol.ProtocolManager protocol;
|
||||
private List<com.comphenix.protocol.events.PacketListener> protocolListeners;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
// ProtocolLib derives CraftBukkit's package from this external server's concrete class.
|
||||
serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class,
|
||||
org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
protocolListeners = new ArrayList<>();
|
||||
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
|
||||
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
when(serverUnderTest.getVersion()).thenReturn("Purpur 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.craftbukkit.scheduler.CraftScheduler.class);
|
||||
when(serverUnderTest.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(any(), any(Runnable.class),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()))
|
||||
.thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class));
|
||||
events = new PaperPluginManagerImpl(serverUnderTest, commands, null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void removeLogHandler() {
|
||||
logger.removeHandler(logHandler);
|
||||
if (createdPlugin != null) {
|
||||
createdPlugin.getLogger().removeHandler(logHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger onlineGrants = new AtomicInteger();
|
||||
AtomicInteger joiningGrants = new AtomicInteger();
|
||||
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
|
||||
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(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,187 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import net.minecraft.network.protocol.game.ClientboundRecipeBookAddPacket;
|
||||
import net.minecraft.stats.ServerRecipeBook;
|
||||
import net.minecraft.world.item.crafting.RecipeBookCategories;
|
||||
import net.minecraft.world.item.crafting.display.ShapelessCraftingRecipeDisplay;
|
||||
import net.minecraft.world.item.crafting.display.SlotDisplay;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerRecipeDiscoverEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real native book/display codecs and actual registered join handlers, not a set-based book fake. */
|
||||
class NativePowderSnowBookTest {
|
||||
@TempDir Path directory;
|
||||
private static final NamespacedKey KEY = NativePowderSnowRecipeTest.KEY;
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
|
||||
private static long addedEntries(List<net.minecraft.network.protocol.Packet<?>> packets) {
|
||||
return packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
|
||||
.map(ClientboundRecipeBookAddPacket.class::cast).mapToLong(packet -> packet.entries().size()).sum();
|
||||
}
|
||||
|
||||
private static boolean powderEntry(ClientboundRecipeBookAddPacket.Entry entry) {
|
||||
return entry.contents().display().result() instanceof SlotDisplay.ItemStackSlotDisplay result
|
||||
&& result.stack().create().is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET);
|
||||
}
|
||||
|
||||
private static void assertPackedEquals(ServerRecipeBook.Packed expected, ServerRecipeBook.Packed actual) {
|
||||
assertEquals(ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, expected).getOrThrow(),
|
||||
ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, actual).getOrThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlineDiscoverySendsOrdinarySearchableMiscDisplayWithOneNotificationAndHighlight() throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 2)) {
|
||||
var recipe = f.powder().value();
|
||||
assertEquals(RecipeBookCategories.CRAFTING_MISC, recipe.recipeBookCategory());
|
||||
var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, recipe.display().getFirst());
|
||||
var context = new net.minecraft.util.context.ContextMap.Builder()
|
||||
.withParameter(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
|
||||
.create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
|
||||
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
|
||||
assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList());
|
||||
assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream()
|
||||
.map(items -> CraftItemStack.asBukkitCopy(items.getFirst()).getType()).toList());
|
||||
var result = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, display.result()).stack().create();
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(result).getType());
|
||||
assertFalse(CraftItemStack.asBukkitCopy(result).hasItemMeta());
|
||||
assertEquals(1, result.getCount());
|
||||
// Search presentation is supplied by the ordinary native translated output, not a custom name.
|
||||
assertEquals("item.minecraft.powder_snow_bucket", assertInstanceOf(
|
||||
net.minecraft.network.chat.contents.TranslatableContents.class, result.getHoverName().getContents()).getKey());
|
||||
var displays = new java.util.ArrayList<net.minecraft.world.item.crafting.display.RecipeDisplayEntry>();
|
||||
f.manager.listDisplaysForRecipe(f.powder().id(), displays::add);
|
||||
var id = displays.getFirst().id();
|
||||
var powderPackets = f.packets.packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
|
||||
.map(ClientboundRecipeBookAddPacket.class::cast)
|
||||
.filter(packet -> packet.entries().stream().anyMatch(NativePowderSnowBookTest::powderEntry)).toList();
|
||||
assertEquals(1, powderPackets.size());
|
||||
var packet = powderPackets.getFirst();
|
||||
var entry = packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).findFirst().orElseThrow();
|
||||
assertEquals(id, entry.contents().id());
|
||||
assertEquals(1, packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).count());
|
||||
assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category());
|
||||
assertTrue(entry.notification());
|
||||
assertTrue(entry.highlight());
|
||||
assertEquals(2, entry.contents().craftingRequirements().orElseThrow().size());
|
||||
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, packet);
|
||||
var decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer);
|
||||
assertEquals(packet, decoded);
|
||||
assertFalse(buffer.isReadable());
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
var powderEvents = f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).toList();
|
||||
assertEquals(1, powderEvents.size());
|
||||
assertTrue(powderEvents.getFirst().shouldShowNotification());
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(f.actor.player, "repeat"));
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).count());
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertTrue(f.menu.craftSlots.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeKnownHighlightCodecJoinReconnectDisableAndActualReenableRetainUnrelatedState() throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 3)) {
|
||||
var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA);
|
||||
f.book.add(vanillaKey);
|
||||
f.book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
var joined = f.joiningPlayer(null);
|
||||
joined.book().add(vanillaKey);
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "join"));
|
||||
assertTrue(joined.book().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
assertTrue(joined.book().contains(vanillaKey));
|
||||
assertEquals(5, addedEntries(joined.sent()));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "idempotent"));
|
||||
assertEquals(5, addedEntries(joined.sent()));
|
||||
var packed = f.book.pack();
|
||||
assertTrue(packed.highlight().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
var nbt = ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, packed).getOrThrow();
|
||||
var decoded = ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, nbt).getOrThrow();
|
||||
var reconnected = f.joiningPlayer(decoded);
|
||||
assertPackedEquals(packed, reconnected.book().pack());
|
||||
assertTrue(reconnected.book().isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(reconnected.book().isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(reconnected.player(), "reconnected"));
|
||||
assertTrue(reconnected.sent().isEmpty(), "No second discovery/notification for known recipe");
|
||||
f.actor.plugin.setEnabled(false);
|
||||
assertTrue(f.registered.isEmpty());
|
||||
assertPackedEquals(packed, f.book.pack());
|
||||
assertPackedEquals(packed, reconnected.book().pack());
|
||||
var disabledJoiner = f.joiningPlayer(null);
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "disabled"));
|
||||
assertTrue(disabledJoiner.book().pack().known().isEmpty());
|
||||
doReturn(List.of(f.actor.player, reconnected.player())).when(f.actor.server).getOnlinePlayers();
|
||||
f.actor.plugin.setEnabled(true);
|
||||
assertEquals(5, f.registered.size());
|
||||
assertTrue(reconnected.sent().isEmpty());
|
||||
assertPackedEquals(packed, f.book.pack());
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY) && e.getPlayer() == f.actor.player).count());
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "enabled"));
|
||||
assertTrue(disabledJoiner.book().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK)));
|
||||
f.actor.realInventory.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.BUCKET)));
|
||||
f.autofill(false);
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET, CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType());
|
||||
f.actor.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeDiscoveryCancellationAndPerPlayerPortFailureAreIsolatedAndRetryOnRealJoin() throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 2)) {
|
||||
var cancelled = f.joiningPlayer(null);
|
||||
var failed = f.joiningPlayer(null);
|
||||
var healthy = f.joiningPlayer(null);
|
||||
var cancel = new AtomicBoolean(true);
|
||||
f.actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.HIGHEST,
|
||||
(ignored, raw) -> {
|
||||
var event = (PlayerRecipeDiscoverEvent) raw;
|
||||
if (event.getPlayer() == cancelled.player() && event.getRecipe().equals(KEY)) event.setCancelled(cancel.get());
|
||||
}, f.actor.plugin, false);
|
||||
var injected = new IllegalStateException("injected player transport failure");
|
||||
doThrow(injected).when(failed.player()).discoverRecipes(List.of(KEY));
|
||||
for (var player : List.of(cancelled, failed, healthy)) f.actor.events.callEvent(new PlayerJoinEvent(player.player(), "join"));
|
||||
var key = NativePowderSnowFixture.nativeKey(KEY);
|
||||
assertFalse(cancelled.book().contains(key));
|
||||
assertFalse(failed.book().contains(key));
|
||||
assertTrue(healthy.book().contains(key));
|
||||
assertEquals(4, cancelled.book().pack().known().size());
|
||||
assertEquals(4, failed.book().pack().known().size());
|
||||
assertEquals(5, healthy.book().pack().known().size());
|
||||
assertEquals(4, addedEntries(cancelled.sent()));
|
||||
cancel.set(false);
|
||||
doCallRealMethod().when(failed.player()).discoverRecipes(any());
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry"));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry"));
|
||||
assertTrue(cancelled.book().contains(key));
|
||||
assertTrue(failed.book().contains(key));
|
||||
assertEquals(5, addedEntries(cancelled.sent()));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "known"));
|
||||
assertEquals(5, addedEntries(cancelled.sent()));
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertEquals(List.of(injected), f.actor.eventErrors, "Only the deliberately injected discovery failure is logged");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.stats.ServerRecipeBook;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.inventory.AbstractCraftingMenu;
|
||||
import net.minecraft.world.inventory.ContainerLevelAccess;
|
||||
import net.minecraft.world.inventory.CraftingMenu;
|
||||
import net.minecraft.world.item.crafting.CraftingRecipe;
|
||||
import net.minecraft.world.item.crafting.RecipeHolder;
|
||||
import net.minecraft.world.item.crafting.RecipeManager;
|
||||
import net.minecraft.world.item.crafting.RecipeMap;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftNamespacedKey;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerRecipeDiscoverEvent;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
|
||||
/** Reuses strict actor/packet ports. Native recipe manager/book/menu/result/event code stays real.
|
||||
* The Server.addRecipe port converts the exact recipe submitted by actual onEnable, following
|
||||
* pinned CraftShaped/ShapelessRecipe conversion (including MaterialChoice's native matching).
|
||||
* No worlds or clients run; packets are captured by the existing native synchronizer port.
|
||||
*/
|
||||
final class NativePowderSnowFixture implements AutoCloseable {
|
||||
final NativeAnvilFixture actor;
|
||||
final NativeAnvilPacketFixture packets;
|
||||
final RecipeManager manager;
|
||||
final ServerRecipeBook book;
|
||||
final Map<NamespacedKey, Recipe> registered = new HashMap<>();
|
||||
final Map<NamespacedKey, RecipeHolder<CraftingRecipe>> holders = new HashMap<>();
|
||||
final List<PlayerRecipeDiscoverEvent> discoveries = new ArrayList<>();
|
||||
final AbstractCraftingMenu menu;
|
||||
|
||||
NativePowderSnowFixture(Path directory, int width) throws Exception {
|
||||
actor = new NativeAnvilFixture(directory);
|
||||
packets = new NativeAnvilPacketFixture(actor);
|
||||
actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR,
|
||||
(ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false);
|
||||
actor.plugin.setEnabled(false);
|
||||
manager = new RecipeManager(CraftRegistry.getMinecraftRegistry());
|
||||
when(packets.nativeServer.getRecipeManager()).thenReturn(manager);
|
||||
when(actor.level.recipeAccess()).thenReturn(manager);
|
||||
when(actor.level.getGameRules()).thenReturn(
|
||||
new net.minecraft.world.level.gamerules.GameRules(FeatureFlags.VANILLA_SET));
|
||||
book = new ServerRecipeBook(manager::listDisplaysForRecipe);
|
||||
var bookField = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook");
|
||||
assertEquals(ServerRecipeBook.class, bookField.getType());
|
||||
bookField.setAccessible(true);
|
||||
bookField.set(actor.nativePlayer, book);
|
||||
when(actor.nativePlayer.getRecipeBook()).thenReturn(book);
|
||||
when(actor.nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
doCallRealMethod().when(actor.nativePlayer).awardRecipes(any());
|
||||
doCallRealMethod().when(actor.player).hasDiscoveredRecipe(any());
|
||||
doCallRealMethod().when(actor.player).discoverRecipes(any());
|
||||
when(actor.server.getRecipe(any(NamespacedKey.class))).thenAnswer(inv -> registered.get(inv.getArgument(0)));
|
||||
when(actor.server.addRecipe(any(Recipe.class))).thenAnswer(inv -> {
|
||||
Recipe bukkit = inv.getArgument(0);
|
||||
NamespacedKey key = ((Keyed) bukkit).getKey();
|
||||
if (registered.putIfAbsent(key, bukkit) != null) return false;
|
||||
holders.put(key, new RecipeHolder<>(nativeKey(key), convert(bukkit)));
|
||||
refreshManager();
|
||||
return true;
|
||||
});
|
||||
when(actor.server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> {
|
||||
NamespacedKey key = inv.getArgument(0);
|
||||
holders.remove(key);
|
||||
refreshManager();
|
||||
return registered.remove(key) != null;
|
||||
});
|
||||
doReturn(List.of(actor.player)).when(actor.server).getOnlinePlayers();
|
||||
actor.plugin.setEnabled(true);
|
||||
assertTrue(book.contains(nativeKey(NativePowderSnowRecipeTest.KEY)));
|
||||
assertEquals(5, registered.size());
|
||||
assertEquals(5, discoveries.size());
|
||||
assertEquals(0, count(actor.realInventory, org.bukkit.Material.POWDER_SNOW_BUCKET));
|
||||
if (width == 2) {
|
||||
menu = packets.inventoryMenu;
|
||||
} else if (width == 3) {
|
||||
when(actor.level.getBlockState(actor.position)).thenReturn(
|
||||
net.minecraft.world.level.block.Blocks.CRAFTING_TABLE.defaultBlockState());
|
||||
menu = new CraftingMenu(8, actor.realInventory, ContainerLevelAccess.create(actor.level, actor.position));
|
||||
menu.setTitle(net.minecraft.network.chat.Component.literal("Crafting"));
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported grid width " + width);
|
||||
}
|
||||
actor.nativePlayer.containerMenu = menu;
|
||||
menu.setSynchronizer(packets.synchronizer);
|
||||
actor.assertNoEventErrors();
|
||||
}
|
||||
|
||||
/** A later connection has a real native book, with only player/world/network ports doubled. */
|
||||
BookPlayer joiningPlayer(ServerRecipeBook.Packed saved) throws Exception {
|
||||
var nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
|
||||
var recipeBook = new ServerRecipeBook(manager::listDisplaysForRecipe);
|
||||
if (saved != null) recipeBook.loadUntrusted(saved, key -> true);
|
||||
var field = net.minecraft.server.level.ServerPlayer.class.getDeclaredField("recipeBook");
|
||||
assertEquals(ServerRecipeBook.class, field.getType());
|
||||
field.setAccessible(true);
|
||||
field.set(nativePlayer, recipeBook);
|
||||
when(nativePlayer.getRecipeBook()).thenReturn(recipeBook);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.level()).thenReturn(actor.level);
|
||||
when(nativePlayer.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getName()).thenReturn("JoiningPowderPlayer");
|
||||
doCallRealMethod().when(nativePlayer).awardRecipes(any());
|
||||
doCallRealMethod().when(player).hasDiscoveredRecipe(any());
|
||||
doCallRealMethod().when(player).discoverRecipes(any());
|
||||
nativePlayer.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||
var sent = new ArrayList<net.minecraft.network.protocol.Packet<?>>();
|
||||
doAnswer(inv -> { sent.add(inv.getArgument(0)); return null; })
|
||||
.when(nativePlayer.connection).send(any(net.minecraft.network.protocol.Packet.class));
|
||||
return new BookPlayer(player, nativePlayer, recipeBook, sent);
|
||||
}
|
||||
|
||||
record BookPlayer(org.bukkit.craftbukkit.entity.CraftPlayer player,
|
||||
net.minecraft.server.level.ServerPlayer nativePlayer, ServerRecipeBook book,
|
||||
List<net.minecraft.network.protocol.Packet<?>> sent) {}
|
||||
|
||||
RecipeHolder<CraftingRecipe> powder() { return holders.get(NativePowderSnowRecipeTest.KEY); }
|
||||
|
||||
net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction autofill(boolean batch) {
|
||||
var action = menu.handlePlacement(batch, false, powder(), actor.level, actor.realInventory);
|
||||
actor.assertNoEventErrors();
|
||||
return action;
|
||||
}
|
||||
|
||||
List<org.bukkit.inventory.ItemStack> gridSnapshot() {
|
||||
return menu.craftSlots.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
}
|
||||
|
||||
static long count(net.minecraft.world.Container container, org.bukkit.Material material) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize()).mapToObj(container::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material)
|
||||
.mapToInt(org.bukkit.inventory.ItemStack::getAmount).sum();
|
||||
}
|
||||
|
||||
static ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(NamespacedKey key) {
|
||||
return CraftNamespacedKey.toResourceKey(Registries.RECIPE, key);
|
||||
}
|
||||
|
||||
private void refreshManager() {
|
||||
manager.recipes = RecipeMap.create(new ArrayList<RecipeHolder<?>>(holders.values()));
|
||||
manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
}
|
||||
|
||||
private static CraftingRecipe convert(Recipe recipe) {
|
||||
if (recipe instanceof ShapelessRecipe shapeless) {
|
||||
return new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shapeless.getCategory()), shapeless.getGroup()),
|
||||
CraftItemStack.asTemplate(shapeless.getResult()), shapeless.getChoiceList().stream()
|
||||
.map(choice -> CraftRecipe.toIngredient(choice, true)).toList());
|
||||
}
|
||||
if (recipe instanceof ShapedRecipe shaped) {
|
||||
Map<Character, net.minecraft.world.item.crafting.Ingredient> ingredients = new HashMap<>();
|
||||
shaped.getChoiceMap().forEach((symbol, choice) -> {
|
||||
if (choice != null) ingredients.put(symbol, CraftRecipe.toIngredient(choice, true));
|
||||
});
|
||||
return new net.minecraft.world.item.crafting.ShapedRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new CraftingRecipe.CraftingBookInfo(CraftRecipe.getCategory(shaped.getCategory()), shaped.getGroup()),
|
||||
net.minecraft.world.item.crafting.ShapedRecipePattern.of(ingredients, shaped.getShape()),
|
||||
CraftItemStack.asTemplate(shaped.getResult()));
|
||||
}
|
||||
throw new AssertionError("Unexpected registered recipe " + recipe.getClass());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
packets.close();
|
||||
actor.close();
|
||||
}
|
||||
}
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
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.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.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 NativePowderSnowPluginWiringTest {
|
||||
private static final NamespacedKey POWDER = key("snow_powder_snow");
|
||||
private static final Set<NamespacedKey> EXISTING = Set.of(key("string_cobweb"), key("dropper_dispenser"),
|
||||
key("sorting_stick_recipe_chest"), key("sorting_stick_recipe_barrel"));
|
||||
private static final NamespacedKey VANILLA = new NamespacedKey("minecraft", "stick");
|
||||
private static Server previousServer;
|
||||
private static Server serverUnderTest;
|
||||
@TempDir Path directory;
|
||||
private Map<NamespacedKey, Recipe> registry;
|
||||
private PaperPluginManagerImpl events;
|
||||
private SimpleCommandMap commands;
|
||||
private Logger logger;
|
||||
private List<LogRecord> logs;
|
||||
private Handler logHandler;
|
||||
private SpigotInventoryHelperPlugin createdPlugin;
|
||||
private com.comphenix.protocol.ProtocolManager protocol;
|
||||
private List<com.comphenix.protocol.events.PacketListener> protocolListeners;
|
||||
|
||||
@BeforeAll
|
||||
static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap();
|
||||
previousServer = Bukkit.getServer();
|
||||
// ProtocolLib derives CraftBukkit's package from this external server's concrete class.
|
||||
serverUnderTest = mock(org.bukkit.craftbukkit.CraftServer.class,
|
||||
org.mockito.Mockito.withSettings().mockMaker(org.mockito.MockMakers.INLINE));
|
||||
setServer(serverUnderTest);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void configureServer() {
|
||||
reset(serverUnderTest);
|
||||
protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
protocolListeners = new ArrayList<>();
|
||||
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; }).when(protocol).addPacketListener(any());
|
||||
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; }).when(protocol).removePacketListener(any());
|
||||
when(serverUnderTest.getItemFactory()).thenAnswer(invocation -> CraftItemFactory.instance());
|
||||
when(serverUnderTest.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(serverUnderTest.isPrimaryThread()).thenReturn(true);
|
||||
when(serverUnderTest.getVersion()).thenReturn("Purpur 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.craftbukkit.scheduler.CraftScheduler.class);
|
||||
when(serverUnderTest.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(any(), any(Runnable.class),
|
||||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()))
|
||||
.thenAnswer(ignored -> mock(org.bukkit.scheduler.BukkitTask.class));
|
||||
events = new PaperPluginManagerImpl(serverUnderTest, commands, null);
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(events);
|
||||
doReturn(List.of()).when(serverUnderTest).getOnlinePlayers();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void removeLogHandler() {
|
||||
logger.removeHandler(logHandler);
|
||||
if (createdPlugin != null) {
|
||||
createdPlugin.getLogger().removeHandler(logHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void restoreServer() throws Exception {
|
||||
setServer(previousServer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableDiscoversOnlineAndJoinersIdempotentlyAndReenablesWithoutLosingKnowledge() {
|
||||
Set<NamespacedKey> onlineKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> joiningKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger onlineGrants = new AtomicInteger();
|
||||
AtomicInteger joiningGrants = new AtomicInteger();
|
||||
Player online = player("Online", onlineKnown, onlineGrants, new AtomicBoolean());
|
||||
Player joining = player("Joining", joiningKnown, joiningGrants, new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(POWDER));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
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(POWDER));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void powderSnowKeyConflictPreservesForeignRecipeAndExistingFeaturesWithoutDiscovery() {
|
||||
Recipe conflict = mock(Recipe.class);
|
||||
registry.put(POWDER, conflict);
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicInteger grants = new AtomicInteger();
|
||||
doReturn(List.of(player("Online", known, grants, new AtomicBoolean())))
|
||||
.when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertSame(conflict, registry.get(POWDER));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(POWDER));
|
||||
assertEquals(0, grants.get());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA, POWDER), registry.keySet());
|
||||
assertSame(conflict, registry.get(POWDER));
|
||||
|
||||
registry.remove(POWDER);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(POWDER));
|
||||
assertTrue(known.contains(POWDER));
|
||||
assertEquals(1, grants.get());
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected key conflict is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void onePlayersDiscoveryFailureDoesNotBlockOthersAndCanRetryOnJoin() {
|
||||
Set<NamespacedKey> failedKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Set<NamespacedKey> healthyKnown = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
AtomicBoolean fail = new AtomicBoolean(true);
|
||||
AtomicInteger failedGrants = new AtomicInteger();
|
||||
Player failed = player("Failed", failedKnown, failedGrants, fail);
|
||||
Player healthy = player("Healthy", healthyKnown, new AtomicInteger(), new AtomicBoolean());
|
||||
doReturn(List.of(failed, healthy)).when(serverUnderTest).getOnlinePlayers();
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(failedKnown.contains(POWDER));
|
||||
assertEquals(expectedKnown(), healthyKnown);
|
||||
assertTrue(failedKnown.containsAll(EXISTING));
|
||||
fail.set(false);
|
||||
events.callEvent(new PlayerJoinEvent(failed, "retry"));
|
||||
assertEquals(expectedKnown(), failedKnown);
|
||||
assertEquals(1, failedGrants.get());
|
||||
assertNoSevereLogs();
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.WARNING).count());
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialDiscoveryListenerFailureRollsBackOnlyPowderSnowAndWithholdsJoinDiscovery() {
|
||||
PluginManager throwing = mock(PluginManager.class);
|
||||
AtomicInteger discoveries = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
Listener listener = invocation.getArgument(0);
|
||||
org.bukkit.plugin.Plugin owner = invocation.getArgument(1);
|
||||
events.registerEvents(listener, owner);
|
||||
if (listener instanceof RecipeDiscovery && discoveries.incrementAndGet() == 4) {
|
||||
throw new IllegalStateException("simulated post-listener-registration failure");
|
||||
}
|
||||
return null;
|
||||
}).when(throwing).registerEvents(any(), any());
|
||||
when(serverUnderTest.getPluginManager()).thenReturn(throwing);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(registry.containsKey(POWDER));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
Set<NamespacedKey> known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
events.callEvent(new PlayerJoinEvent(player("Joining", known, new AtomicInteger(),
|
||||
new AtomicBoolean()), "joined"));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertFalse(known.contains(POWDER));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count(),
|
||||
"Only the deliberately injected listener failure is severe");
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"refused", "before-insert", "after-insert", "lookup"})
|
||||
void registrationPortFailuresPreserveOtherFeaturesAndRetryThroughActualEnable(String failure) {
|
||||
var fail = new AtomicBoolean(true);
|
||||
when(serverUnderTest.getRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
NamespacedKey requested = invocation.getArgument(0);
|
||||
if (requested.equals(POWDER) && failure.equals("lookup") && fail.get())
|
||||
throw new IllegalStateException("injected lookup failure");
|
||||
return registry.get(requested);
|
||||
});
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
var key = ((Keyed) recipe).getKey();
|
||||
if (key.equals(POWDER) && fail.get()) {
|
||||
if (failure.equals("refused")) return false;
|
||||
if (failure.equals("before-insert")) throw new IllegalStateException("injected pre-insert failure");
|
||||
if (failure.equals("after-insert")) {
|
||||
registry.put(key, recipe);
|
||||
throw new IllegalStateException("injected post-insert failure");
|
||||
}
|
||||
}
|
||||
return registry.putIfAbsent(key, recipe) == null;
|
||||
});
|
||||
var known = new LinkedHashSet<>(Set.of(VANILLA));
|
||||
Player online = player("Online", known, new AtomicInteger(), new AtomicBoolean());
|
||||
doReturn(List.of(online)).when(serverUnderTest).getOnlinePlayers();
|
||||
Recipe vanilla = registry.get(VANILLA);
|
||||
var plugin = plugin();
|
||||
try {
|
||||
plugin.setEnabled(true);
|
||||
assertFalse(registry.containsKey(POWDER));
|
||||
assertFalse(known.contains(POWDER));
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(known.containsAll(EXISTING));
|
||||
assertSame(vanilla, registry.get(VANILLA));
|
||||
// Sorting/admin/native-anvil registration is not withheld by a failed independent recipe.
|
||||
assertNotNull(plugin.sortingSticks());
|
||||
assertFalse(protocolListeners.isEmpty());
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA), registry.keySet());
|
||||
assertSame(vanilla, registry.get(VANILLA));
|
||||
fail.set(false);
|
||||
plugin.setEnabled(true);
|
||||
assertTrue(registry.containsKey(POWDER));
|
||||
assertTrue(known.contains(POWDER));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"false", "throw"})
|
||||
void realLifecycleRemovalFailureRetainsOwnershipForRetryWithoutListenersOrOtherRecipeLoss(String failure) {
|
||||
var plugin = plugin();
|
||||
plugin.setEnabled(true);
|
||||
var separateKey = key("powder_removal_probe");
|
||||
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
new PowderSnowRecipeRegistrar(separateKey), plugin.getLogger());
|
||||
var fail = new AtomicBoolean(true);
|
||||
try {
|
||||
assertTrue(lifecycle.enable());
|
||||
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
NamespacedKey requested = invocation.getArgument(0);
|
||||
if (requested.equals(separateKey) && fail.get()) {
|
||||
if (failure.equals("false")) return false;
|
||||
throw new IllegalStateException("injected removal failure");
|
||||
}
|
||||
return registry.remove(requested) != null;
|
||||
});
|
||||
assertThrows(IllegalStateException.class, lifecycle::disable);
|
||||
assertEquals(before, registry.keySet());
|
||||
var known = new LinkedHashSet<NamespacedKey>();
|
||||
events.callEvent(new PlayerJoinEvent(player("After failed disable", known,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertFalse(known.contains(separateKey), "Unregistered discovery must not grant the stranded recipe");
|
||||
assertTrue(known.contains(POWDER), "Other independently active listeners remain usable");
|
||||
fail.set(false);
|
||||
lifecycle.disable();
|
||||
before.remove(separateKey);
|
||||
assertEquals(before, registry.keySet());
|
||||
assertTrue(lifecycle.enable());
|
||||
assertTrue(registry.containsKey(separateKey));
|
||||
lifecycle.disable();
|
||||
assertEquals(before, registry.keySet());
|
||||
} finally {
|
||||
fail.set(false);
|
||||
lifecycle.disable();
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exceptionAfterSuccessfulRemovalCanRetryCleanupAndRegisterAgain() {
|
||||
var plugin = plugin();
|
||||
plugin.setEnabled(true);
|
||||
var key = key("powder_post_removal_probe");
|
||||
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
new PowderSnowRecipeRegistrar(key), plugin.getLogger());
|
||||
try {
|
||||
assertTrue(lifecycle.enable());
|
||||
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
NamespacedKey requested = invocation.getArgument(0);
|
||||
boolean removed = registry.remove(requested) != null;
|
||||
if (requested.equals(key) && removed)
|
||||
throw new IllegalStateException("injected exception after successful removal");
|
||||
return removed;
|
||||
});
|
||||
assertThrows(IllegalStateException.class, lifecycle::disable);
|
||||
before.remove(key);
|
||||
assertEquals(before, registry.keySet());
|
||||
assertDoesNotThrow(lifecycle::disable, "Absent owned registration must permit cleanup retry");
|
||||
assertTrue(lifecycle.enable());
|
||||
assertTrue(registry.containsKey(key));
|
||||
} finally {
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> registry.remove(invocation.getArgument(0)) != null);
|
||||
// A deliberately stranded absent registration must not mask the RED assertion in cleanup.
|
||||
if (registry.containsKey(key)) lifecycle.disable();
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postInsertionAndRollbackRemovalFailureKeepRealLifecycleRetryable() {
|
||||
var plugin = plugin();
|
||||
plugin.setEnabled(true);
|
||||
var key = key("powder_insertion_probe");
|
||||
var lifecycle = new RecipeLifecycle(serverUnderTest, plugin,
|
||||
new PowderSnowRecipeRegistrar(key), plugin.getLogger());
|
||||
var failRemoval = new AtomicBoolean(true);
|
||||
try {
|
||||
Set<NamespacedKey> before = new LinkedHashSet<>(registry.keySet());
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
var requested = ((Keyed) recipe).getKey();
|
||||
registry.put(requested, recipe);
|
||||
if (requested.equals(key)) throw new IllegalStateException("injected post-insert failure");
|
||||
return true;
|
||||
});
|
||||
when(serverUnderTest.removeRecipe(any(NamespacedKey.class))).thenAnswer(invocation -> {
|
||||
NamespacedKey requested = invocation.getArgument(0);
|
||||
if (requested.equals(key) && failRemoval.get())
|
||||
throw new IllegalStateException("injected rollback removal failure");
|
||||
return registry.remove(requested) != null;
|
||||
});
|
||||
assertFalse(lifecycle.enable());
|
||||
assertTrue(registry.containsKey(key), "Failed cleanup honestly retains ownership");
|
||||
assertTrue(registry.keySet().containsAll(before));
|
||||
var known = new LinkedHashSet<NamespacedKey>();
|
||||
events.callEvent(new PlayerJoinEvent(player("During failure", known,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertFalse(known.contains(key));
|
||||
failRemoval.set(false);
|
||||
lifecycle.disable();
|
||||
assertEquals(before, registry.keySet());
|
||||
when(serverUnderTest.addRecipe(any(Recipe.class))).thenAnswer(invocation -> {
|
||||
Recipe recipe = invocation.getArgument(0);
|
||||
return registry.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
|
||||
});
|
||||
assertTrue(lifecycle.enable());
|
||||
events.callEvent(new PlayerJoinEvent(player("Retry", known,
|
||||
new AtomicInteger(), new AtomicBoolean()), "joined"));
|
||||
assertTrue(known.contains(key));
|
||||
assertEquals(1, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
} finally {
|
||||
failRemoval.set(false);
|
||||
lifecycle.disable();
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
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 powderSnowGrants, AtomicBoolean failPowderSnow) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getName()).thenReturn(name);
|
||||
when(player.hasDiscoveredRecipe(any(NamespacedKey.class))).thenAnswer(
|
||||
invocation -> known.contains(invocation.getArgument(0)));
|
||||
when(player.discoverRecipes(any())).thenAnswer(invocation -> {
|
||||
List<NamespacedKey> requested = ((Collection<?>) invocation.getArgument(0)).stream()
|
||||
.map(NamespacedKey.class::cast).toList();
|
||||
if (requested.contains(POWDER)) {
|
||||
if (failPowderSnow.get()) {
|
||||
throw new IllegalStateException("simulated player discovery failure");
|
||||
}
|
||||
powderSnowGrants.incrementAndGet();
|
||||
}
|
||||
known.addAll(requested);
|
||||
return requested.size();
|
||||
});
|
||||
return player;
|
||||
}
|
||||
|
||||
private void assertNoSevereLogs() {
|
||||
assertEquals(0, logs.stream().filter(r -> r.getLevel() == Level.SEVERE).count());
|
||||
}
|
||||
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(POWDER);
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) {
|
||||
return new NamespacedKey("spigotinventoryhelper", name);
|
||||
}
|
||||
|
||||
private static void setServer(Server server) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server");
|
||||
field.setAccessible(true);
|
||||
field.set(null, server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Actual plugin registration, pinned native crafting engine; no replacement plugin logic. */
|
||||
class NativePowderSnowRecipeTest {
|
||||
static final NamespacedKey KEY = new NamespacedKey("spigotinventoryhelper", "snow_powder_snow");
|
||||
@TempDir Path directory;
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void discoveredNativeAutofillAndActualPacketTakePayOnceInBothMenus(int width) throws Exception {
|
||||
try (var fixture = new NativePowderSnowFixture(directory, width)) {
|
||||
var inventory = fixture.actor.realInventory;
|
||||
inventory.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
|
||||
new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK, 2)));
|
||||
inventory.setItem(1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(
|
||||
new org.bukkit.inventory.ItemStack(Material.BUCKET, 2)));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, fixture.autofill(false));
|
||||
assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.SNOW_BLOCK));
|
||||
assertEquals(1, NativePowderSnowFixture.count(fixture.menu.craftSlots, Material.BUCKET));
|
||||
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET,
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getResultSlot().getItem()).getType());
|
||||
fixture.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, fixture.packets.clicks.getLast());
|
||||
assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey());
|
||||
assertFalse(event.isCancelled());
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET,
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(fixture.menu.getCarried()).getType());
|
||||
assertTrue(fixture.menu.craftSlots.isEmpty());
|
||||
assertEquals(1, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK));
|
||||
assertEquals(1, NativePowderSnowFixture.count(inventory, Material.BUCKET));
|
||||
fixture.actor.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void shapelessNativeMatchingRequiresExactlyBothMaterialsInEveryPairOfPositions(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
var recipe = f.powder().value();
|
||||
for (int snow = 0; snow < width * width; snow++) {
|
||||
for (int bucket = 0; bucket < width * width; bucket++) {
|
||||
if (snow == bucket) continue;
|
||||
var items = new java.util.ArrayList<>(java.util.Collections.nCopies(width * width,
|
||||
net.minecraft.world.item.ItemStack.EMPTY));
|
||||
items.set(snow, nms(Material.SNOW_BLOCK, 2));
|
||||
items.set(bucket, nms(Material.BUCKET, 3));
|
||||
assertTrue(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
items.set(snow, net.minecraft.world.item.ItemStack.EMPTY);
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.DIRT)) {
|
||||
items.set(snow, nms(wrong, 1));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
}
|
||||
items.set(snow, nms(Material.SNOW_BLOCK, 2));
|
||||
items.set(bucket, net.minecraft.world.item.ItemStack.EMPTY);
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
for (Material wrong : List.of(Material.SNOWBALL, Material.SNOW, Material.WATER_BUCKET, Material.POWDER_SNOW_BUCKET)) {
|
||||
items.set(bucket, nms(wrong, 1));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
}
|
||||
items.set(bucket, nms(Material.BUCKET, 1));
|
||||
int extra = java.util.stream.IntStream.range(0, items.size())
|
||||
.filter(i -> items.get(i).isEmpty()).findFirst().orElseThrow();
|
||||
for (Material material : List.of(Material.BUCKET, Material.SNOW_BLOCK, Material.DIRT)) {
|
||||
items.set(extra, nms(material, 1));
|
||||
assertFalse(recipe.matches(net.minecraft.world.item.crafting.CraftingInput.of(width, width, items), f.actor.level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"2,1", "2,3", "2,16", "3,1", "3,3", "3,16"})
|
||||
void nativeBatchPacketConservesEachBucketAndSnowWithUnstackableOutput(int width, int crafts) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
var inventory = f.actor.realInventory;
|
||||
var keep = marked(Material.DIAMOND, 7);
|
||||
inventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep));
|
||||
inventory.setItem(0, nms(Material.SNOW_BLOCK, crafts));
|
||||
inventory.setItem(1, nms(Material.BUCKET, crafts));
|
||||
assertEquals(16, nms(Material.BUCKET, 1).getMaxStackSize());
|
||||
assertEquals(1, nms(Material.POWDER_SNOW_BUCKET, 1).getMaxStackSize());
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(true));
|
||||
assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
|
||||
assertEquals(crafts, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
|
||||
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
|
||||
var event = assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertFalse(event.isCancelled());
|
||||
assertEquals(KEY, ((org.bukkit.Keyed) event.getRecipe()).getKey());
|
||||
assertEquals(crafts, NativePowderSnowFixture.count(inventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.BUCKET));
|
||||
assertEquals(0, NativePowderSnowFixture.count(inventory, Material.SNOW_BLOCK));
|
||||
assertTrue(f.menu.craftSlots.isEmpty());
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
for (var item : inventory.getNonEquipmentItems()) {
|
||||
if (item.is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET)) ordinaryOutput(item);
|
||||
}
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Native empty-stack drop calls award nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void ordinaryPacketPreservesRemainingIngredientMetadataAndAwardsNoExtraBucket(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
var snow = marked(Material.SNOW_BLOCK, 3);
|
||||
var buckets = marked(Material.BUCKET, 3);
|
||||
var keep = marked(Material.DIAMOND, 7);
|
||||
f.actor.realInventory.setItem(20, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(keep));
|
||||
f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(snow));
|
||||
f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(buckets));
|
||||
var before = f.gridSnapshot();
|
||||
ordinaryOutput(f.menu.getResultSlot().getItem());
|
||||
assertEquals(before, f.gridSnapshot(), "Preview is not payment");
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
ordinaryOutput(f.menu.getCarried());
|
||||
assertEquals(marked(Material.SNOW_BLOCK, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(0)));
|
||||
assertEquals(marked(Material.BUCKET, 2), org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.craftSlots.getItem(width * width - 1)));
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET));
|
||||
assertEquals(keep, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(20)));
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Native empty-stack drop calls award nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"2,PICKUP", "2,QUICK_MOVE", "3,PICKUP", "3,QUICK_MOVE"})
|
||||
void actualNativeCraftItemCancellationSpendsNothingAndLaterRetrySucceeds(int width, net.minecraft.world.inventory.ContainerInput input) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
|
||||
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
|
||||
f.autofill(true);
|
||||
var grid = f.gridSnapshot();
|
||||
var inventory = snapshot(f.actor.realInventory);
|
||||
var cancel = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
f.packets.listen(org.bukkit.event.EventPriority.HIGHEST, event -> {
|
||||
if (event instanceof org.bukkit.event.inventory.CraftItemEvent) event.setCancelled(cancel.get());
|
||||
});
|
||||
f.packets.click(0, 0, input);
|
||||
assertTrue(assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast()).isCancelled());
|
||||
assertEquals(grid, f.gridSnapshot());
|
||||
assertEquals(inventory, snapshot(f.actor.realInventory));
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Native empty-stack drop calls award nothing");
|
||||
cancel.set(false);
|
||||
f.packets.click(0, 0, input);
|
||||
assertFalse(f.packets.clicks.getLast().isCancelled());
|
||||
long outputs = NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET)
|
||||
+ (f.menu.getCarried().isEmpty() ? 0 : f.menu.getCarried().getCount());
|
||||
assertEquals(input == net.minecraft.world.inventory.ContainerInput.PICKUP ? 1 : 3, outputs);
|
||||
assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
|
||||
assertEquals(3 - outputs, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void fullInventoryIncompatibleCursorAndWrongContainerRefuseNativeCompletion(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
|
||||
f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3));
|
||||
f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3));
|
||||
var grid = f.gridSnapshot();
|
||||
var inventory = snapshot(f.actor.realInventory);
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
|
||||
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertEquals(grid, f.gridSnapshot());
|
||||
assertEquals(inventory, snapshot(f.actor.realInventory));
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
f.menu.setCarried(nms(Material.DIAMOND, 1));
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertEquals(grid, f.gridSnapshot());
|
||||
assertEquals(inventory, snapshot(f.actor.realInventory));
|
||||
assertEquals(Material.DIAMOND, org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(f.menu.getCarried()).getType());
|
||||
int events = f.packets.clicks.size();
|
||||
f.packets.click(f.menu.containerId + 99, 0, 0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertEquals(events, f.packets.clicks.size());
|
||||
assertEquals(grid, f.gridSnapshot());
|
||||
assertEquals(inventory, snapshot(f.actor.realInventory));
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Native empty-stack drop calls award nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void ghostAndAutofillRelocationNeverAwardOrLoseItems(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE, f.autofill(false));
|
||||
assertEquals(3, NativePowderSnowFixture.count(f.actor.realInventory, Material.SNOW_BLOCK));
|
||||
assertTrue(f.menu.craftSlots.isEmpty());
|
||||
assertTrue(f.menu.resultSlots.isEmpty());
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
assertFalse(f.packets.clicks.getLast() instanceof org.bukkit.event.inventory.CraftItemEvent);
|
||||
var displaced = marked(Material.DIRT, 2);
|
||||
f.menu.craftSlots.setItem(width * width - 1, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(displaced));
|
||||
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false));
|
||||
assertEquals(2, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT));
|
||||
assertTrue(snapshot(f.actor.realInventory).contains(displaced));
|
||||
ordinaryOutput(f.menu.getResultSlot().getItem());
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Native empty-stack drop calls award nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void batchWithOnlyOneOutputSlotPaysOnceAndRetainsAllUncraftedInputs(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
for (int slot = 0; slot < 35; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
|
||||
f.menu.craftSlots.setItem(0, nms(Material.SNOW_BLOCK, 3));
|
||||
f.menu.craftSlots.setItem(1, nms(Material.BUCKET, 3));
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
|
||||
assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertEquals(1, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.SNOW_BLOCK));
|
||||
assertEquals(2, NativePowderSnowFixture.count(f.menu.craftSlots, Material.BUCKET));
|
||||
assertEquals(35L * 64, NativePowderSnowFixture.count(f.actor.realInventory, Material.DIRT));
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.BUCKET));
|
||||
var remaining = f.gridSnapshot();
|
||||
f.packets.click(0, 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE);
|
||||
assertEquals(remaining, f.gridSnapshot(), "Now-full output space cannot spend again");
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty));
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {2, 3})
|
||||
void autofillCannotDiscardMetadataWhenExistingGridHasNoRelocationSpace(int width) throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, width)) {
|
||||
for (int slot = 0; slot < 36; slot++) f.actor.realInventory.setItem(slot, nms(Material.DIRT, 64));
|
||||
f.actor.realInventory.setItem(0, nms(Material.SNOW_BLOCK, 3));
|
||||
f.actor.realInventory.setItem(1, nms(Material.BUCKET, 3));
|
||||
f.menu.craftSlots.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(marked(Material.DIRT, 2)));
|
||||
var before = f.gridSnapshot();
|
||||
var inventory = snapshot(f.actor.realInventory);
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, f.autofill(false));
|
||||
assertEquals(before, f.gridSnapshot());
|
||||
assertEquals(inventory, snapshot(f.actor.realInventory));
|
||||
assertTrue(f.menu.resultSlots.isEmpty());
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
assertTrue(f.packets.drops.stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<org.bukkit.inventory.ItemStack> snapshot(net.minecraft.world.Container container) {
|
||||
return java.util.stream.IntStream.range(0, container.getContainerSize())
|
||||
.mapToObj(i -> org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(container.getItem(i))).toList();
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.ItemStack nms(Material material, int count) {
|
||||
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(material, count));
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.ItemStack marked(Material material, int count) {
|
||||
var item = new org.bukkit.inventory.ItemStack(material, count);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Keep metadata"));
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("acceptance", "keep"),
|
||||
org.bukkit.persistence.PersistentDataType.STRING, "unchanged");
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void ordinaryOutput(net.minecraft.world.item.ItemStack item) {
|
||||
var bukkit = org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(item);
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET, bukkit.getType());
|
||||
assertEquals(1, bukkit.getAmount());
|
||||
assertFalse(bukkit.hasItemMeta());
|
||||
}
|
||||
|
||||
@Test
|
||||
void actualEnableRegistersExactlySnowBlockAndEmptyBucketForOneOrdinaryMiscOutput() throws Exception {
|
||||
try (var actor = new NativeAnvilFixture(directory)) {
|
||||
var recipe = assertInstanceOf(ShapelessRecipe.class, actor.server.getRecipe(KEY));
|
||||
assertEquals(KEY, recipe.getKey());
|
||||
assertEquals(List.of(List.of(Material.SNOW_BLOCK), List.of(Material.BUCKET)),
|
||||
recipe.getChoiceList().stream().map(choice ->
|
||||
assertInstanceOf(RecipeChoice.MaterialChoice.class, choice).getChoices()).toList());
|
||||
assertEquals(CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(Material.POWDER_SNOW_BUCKET, recipe.getResult().getType());
|
||||
assertEquals(1, recipe.getResult().getAmount());
|
||||
assertFalse(recipe.getResult().hasItemMeta());
|
||||
actor.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,22 @@ 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/PowderSnowRecipeRegistrar.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilListener.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/NativeAnvilGeneration.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilEffects.class"));
|
||||
var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals("spigotinventoryhelper", description.namespace(), "Persistent sorting-stick namespace must survive distribution renaming");
|
||||
assertNotNull(jar.getJarEntry(
|
||||
@@ -34,7 +47,8 @@ class PurpurDistributionTest {
|
||||
assertNotNull(jar.getJarEntry(
|
||||
"games/dmg/spigotinventoryhelper/DispenserRecipeRegistrar.class"));
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().startsWith("net/minecraft/")
|
||||
|| entry.getName().startsWith("com/comphenix/") || entry.getName().endsWith("Test.class")
|
||||
|| entry.getName().contains("NativeRuntime")));
|
||||
}
|
||||
}
|
||||
@@ -67,6 +81,7 @@ class PurpurDistributionTest {
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
assertTrue(build.contains("nativeInventoryTest"));
|
||||
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
|
||||
|
||||
Reference in new issue
Block a user