Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed02f46272 | ||
|
|
e4b2e004cd | ||
|
|
95a7ac41e1 | ||
|
|
8d21617553 | ||
|
|
856c707637 |
No files matched your search
@@ -1,9 +1,11 @@
|
||||
# purpur-inventory-helper agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-inventory-helper/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-inventory-helper/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-inventory-helper] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-inventory-helper/index.md`, `engineering.md` in that project section, and relevant Kaneo tickets with subject prefix `[purpur-inventory-helper]`. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Purpur Inventory Helper
|
||||
|
||||
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.
|
||||
A Purpur 26.3 plugin with Sorting Sticks, automatic broken-tool replacement, alternative Dispenser/Cobweb recipes, Powder Snow crafting, and risky over-level enchantment combining in ordinary anvils.
|
||||
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-inventory-helper/index.md).
|
||||
|
||||
@@ -8,7 +8,7 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
|
||||
|
||||
## Requirements
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Purpur 26.3 build 2651
|
||||
- Java 25 or newer
|
||||
- ProtocolLib 5.4.0 (required runtime library; missing ProtocolLib prevents plugin loading)
|
||||
|
||||
@@ -20,9 +20,9 @@ The repository, checkout and new JARs use `purpur-inventory-helper`. Runtime `Sp
|
||||
|
||||
The plugin JAR is written to `build/libs/`.
|
||||
|
||||
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.2 build 2618 launcher,
|
||||
`check` includes `nativeInventoryTest`. It downloads the exact Purpur 26.3 build 2651 launcher,
|
||||
verifies SHA-256
|
||||
`4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31`, and runs Paperclip in
|
||||
`0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and runs Paperclip in
|
||||
patch-only mode under `build/inventory-runtime/`; it does not start a server, open a port, create a
|
||||
world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `ServerRecipeBook`
|
||||
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
|
||||
@@ -81,6 +81,24 @@ Player hotbar, armor, and off-hand slots remain unchanged during player sorting.
|
||||
|
||||
When a main-hand tool breaks, an item with the exact same material is moved into the broken tool's selected hotbar slot on the next server tick. Main-storage candidates are preferred before tools in other hotbar slots. The complete replacement item is moved unchanged, preserving durability, enchantments, custom names, and other metadata. Off-hand breaks are ignored, and no unrelated item is moved when a match is unavailable.
|
||||
|
||||
## Reinforcing anvils
|
||||
|
||||
Craft one Anvil, Chipped Anvil or Damaged Anvil with **one Netherite Block**, shapeless in either crafting grid. The universally discovered `MISC` book entry is searchable as **Anvil Reinforcement**. The live preview/private messages teach both effective risk tables; book previews never guarantee success or mint usable protection.
|
||||
|
||||
| Next reinforcement level | Default failure |
|
||||
| --- | --- |
|
||||
| 1 | 20% |
|
||||
| 2 | 40% |
|
||||
| 3 | 60% |
|
||||
| 4 | 80% |
|
||||
| 5 | 99% |
|
||||
|
||||
Each accepted single attempt pays one source anvil and one block. Success returns the next level; failure destroys both without output or a crafting explosion. Damage, names/lore and unrelated metadata are preserved. Bulk/shift, unavailable destinations and sixth-level attempts are refused before payment or a roll. The five-step default survival is intentionally **0.0384%**, approximately one in2,604 chains.
|
||||
|
||||
A placed anvil's reinforcement lowers **risk excess** to `max(0, actual resulting enchantment excess − reinforcement level)` before applying the combine curve/cap below. Output eligibility/levels do not change. Actual over-limit combining remains advanced at0%: zero XP, one accepted roll/outcome, and **all reinforcement spent once**. Success resets protection without repairing the anvil; failure destroys committed items and the owned used anvil with one existing explosion, subject to final native protections. Cancellations never force block removal or refund already-committed inputs. Ordinary combining/repair/naming preserves protection and native costs/refusals/wear.
|
||||
|
||||
Native item, loaded-chunk and falling-entity PDC carry ownership; supported placement, break/drop, wear, falling, destruction and reload handling do not spawn duplicate anvils or inherit a replaced owner's charge. Unknown/refused persistence remains withheld rather than authorizing protection. No forced chunk loads or plugin tick-thread disk writes. Tests use native codecs/events/menus with explicit world/loaded-context ports, not a live disk-restart/crash-atomic transaction or arbitrary event-bypassing API-mutation guarantee.
|
||||
|
||||
## Advanced anvil combining
|
||||
|
||||
All players can combine two **same-material, non-book items** in an ordinary anvil. Enhanced combining applies **only when the combined enchantments exceed a normal maximum**: equal levels become one higher; unequal levels keep the higher. Compatible applicable enchantments carry over. Enhanced conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
|
||||
@@ -89,7 +107,7 @@ Within-limit combinations (for example Sharpness I + I → II or IV + IV → V)
|
||||
|
||||
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
|
||||
|
||||
Enhanced merges charge **no XP** and have no "Too Expensive" restriction. The private action bar displays the result and exact success/failure chances; private chat explains that taking the result commits the attempt. Each accepted attempt consumes **one item from each input**. Success delivers one result; failure destroys both consumed items and causes an **explosion** that can hurt players and damage blocks. Native protections remain authoritative, but a protected/refused explosion does not refund the inputs.
|
||||
Enhanced merges charge **no XP** and have no "Too Expensive" restriction. The private action bar displays the result and exact success/failure chances; private chat explains that taking the result commits the attempt. Each accepted attempt consumes **one item from each input**. Success delivers one result; failure destroys both consumed items and the owned used anvil, subject to native protection, and causes an **explosion** that can hurt players and damage blocks. Native protections remain authoritative, but a protected/refused explosion does not refund the inputs.
|
||||
|
||||
Default risk uses the **largest resulting enchantment excess above its normal maximum**:
|
||||
|
||||
@@ -104,7 +122,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
|
||||
| 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.
|
||||
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.3 build 2651 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
|
||||
|
||||
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
|
||||
|
||||
@@ -120,8 +138,15 @@ Administrators with `spigotinventoryhelper.admin` (default OP) and authorized co
|
||||
| `advanced-anvil-base-failure-percent` | 20 |
|
||||
| `advanced-anvil-risk-step-percent` | 5 |
|
||||
| `advanced-anvil-max-failure-percent` | 99 |
|
||||
| `advanced-anvil-reinforcement-failure-1-percent` | 20 |
|
||||
| `advanced-anvil-reinforcement-failure-2-percent` | 40 |
|
||||
| `advanced-anvil-reinforcement-failure-3-percent` | 60 |
|
||||
| `advanced-anvil-reinforcement-failure-4-percent` | 80 |
|
||||
| `advanced-anvil-reinforcement-failure-5-percent` | 99 |
|
||||
|
||||
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 stays native and never performs an enhanced roll. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
|
||||
All reinforcement percentages are integers0–100 and the complete five-value table must be nondecreasing. The max5/one-excess-tier reduction/all-level spend rules are fixed. At each durable acknowledgment, only that requested key publishes after current permission/plugin/player lifetime checks; saved neighboring keys are not publication authority. Failed saves keep prior live values; read/completion shows effective settings. A0% reinforcement attempt always succeeds,100% always fails.
|
||||
|
||||
Values are integers 0–100. For positive risk excess `X`, failure is `min(cap, base + step × X × (X − 1) / 2)`; `X = max(0, E − R)` for actual excessE and reinforcementR. Actual E0 stays native and never performs an enhanced roll; actual E>0 with X0 remains advanced, rolls at0% and spends all protection. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
|
||||
|
||||
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, real declared-library forwarding/local Netty pipeline/native queue dispatch, item serialization, result-taking, native packet limits, stale client generations and protected explosion boundaries. Pipeline installation on a live server, real-client transport/rendering and installed-stack behavior are not established by these controlled fixtures. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
|
||||
|
||||
|
||||
+7
-7
@@ -26,11 +26,11 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
@@ -49,20 +49,20 @@ tasks.test {
|
||||
// Paperclip only extracts/patches dependencies: no world, port, or EULA acceptance is involved.
|
||||
val inventoryRuntime = layout.buildDirectory.dir("inventory-runtime")
|
||||
val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
|
||||
val launcher = inventoryRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
val launcher = inventoryRuntime.map { it.file("purpur-26.3-2651.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download")
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download")
|
||||
.toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||
"Purpur 2618 checksum mismatch"
|
||||
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
|
||||
"Purpur 2651 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ val downloadInventoryRuntime = tasks.register("downloadInventoryRuntime") {
|
||||
val prepareInventoryRuntime = tasks.register<JavaExec>("prepareInventoryRuntime") {
|
||||
dependsOn(downloadInventoryRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(inventoryRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
classpath = files(inventoryRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(inventoryRuntime)
|
||||
|
||||
@@ -22,6 +22,12 @@ final class AdvancedAnvilEffects {
|
||||
}
|
||||
|
||||
static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear) {
|
||||
use(view, player, origin, wear, () -> true);
|
||||
}
|
||||
|
||||
static void use(AnvilView view, Player player, Location origin, DoubleSupplier wear,
|
||||
java.util.function.BooleanSupplier owned) {
|
||||
if (!owned.getAsBoolean()) return;
|
||||
var block = origin.getBlock();
|
||||
Material type = block.getType();
|
||||
if (player.getGameMode() != GameMode.CREATIVE && isAnvil(type) && wear.getAsDouble() < .12) {
|
||||
@@ -32,7 +38,7 @@ final class AdvancedAnvilEffects {
|
||||
};
|
||||
BlockData proposal = next == null ? null : next.createBlockData();
|
||||
var event = new AnvilDamagedEvent(view, proposal);
|
||||
if (!event.callEvent()) return; // Native cancellation also suppresses the use effect.
|
||||
if (!event.callEvent() || !owned.getAsBoolean()) return; // Final cancellation/replacement wins over old wear.
|
||||
if (event.getDamageState() == AnvilDamagedEvent.DamageState.BROKEN) {
|
||||
block.setType(Material.AIR, false);
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 0);
|
||||
@@ -47,6 +53,33 @@ final class AdvancedAnvilEffects {
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0);
|
||||
}
|
||||
|
||||
/** A failed combine loses its owned anvil even when native wear/blast resistance would spare it. */
|
||||
static void destroy(AnvilView view, Player player, Location origin,
|
||||
java.util.function.BooleanSupplier owned,
|
||||
java.util.function.Predicate<org.bukkit.event.block.BlockBreakEvent> ownedBreak) {
|
||||
var block = origin.getBlock();
|
||||
if (!isAnvil(block.getType()) || !owned.getAsBoolean()) return;
|
||||
var damage = new AnvilDamagedEvent(view, null);
|
||||
if (!damage.callEvent() || !owned.getAsBoolean()) return;
|
||||
if (damage.getDamageState() != AnvilDamagedEvent.DamageState.BROKEN) {
|
||||
// A native protection decision can select a surviving damage state instead.
|
||||
BlockData chosen = damage.getDamageState().getMaterial().createBlockData();
|
||||
if (chosen instanceof Directional facing && block.getBlockData() instanceof Directional old)
|
||||
facing.setFacing(old.getFacing());
|
||||
block.setBlockData(chosen, false);
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_USE, 0);
|
||||
return;
|
||||
}
|
||||
var breaking = new org.bukkit.event.block.BlockBreakEvent(block, player);
|
||||
breaking.setDropItems(false);
|
||||
breaking.setExpToDrop(0);
|
||||
if (!breaking.callEvent() || !ownedBreak.test(breaking)) return;
|
||||
// Do not mint native mining drops/XP for an anvil destroyed by an already-paid gamble.
|
||||
// The exact own break fence is permitted; reentrant player replacements are not.
|
||||
block.setType(Material.AIR, false);
|
||||
origin.getWorld().playEffect(origin, Effect.ANVIL_BREAK, 0);
|
||||
}
|
||||
|
||||
static boolean isAnvil(Material material) {
|
||||
return material == Material.ANVIL || material == Material.CHIPPED_ANVIL
|
||||
|| material == Material.DAMAGED_ANVIL;
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.bukkit.scheduler.BukkitTask;
|
||||
/** Owns native preview and deferred, final-cancellation-aware one-item transactions. */
|
||||
final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
private static final Component WARNING = Component.text(
|
||||
"Taking this result commits the attempt. Failure destroys both items and causes an explosion.");
|
||||
"Taking this result commits the attempt and spends all reinforcement. Failure destroys both items and causes an explosion; it also destroys the used anvil, subject to native protection.");
|
||||
private final Plugin plugin;
|
||||
private final Supplier<AdvancedAnvilPolicy> policy;
|
||||
private final java.util.function.IntSupplier risk;
|
||||
@@ -48,11 +48,20 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
private final AdvancedAnvilProtocolGuard protocol;
|
||||
private final ReinforcementState reinforcement;
|
||||
private final BukkitTask refresh;
|
||||
private boolean closed;
|
||||
private ReinforcementCrafting crafting;
|
||||
|
||||
void enableReinforcement(ReinforcementRecipeRegistrar recipes) {
|
||||
if (crafting != null || closed) throw new IllegalStateException("Reinforcement already initialized or closed");
|
||||
crafting = new ReinforcementCrafting(plugin, policy, risk, recipes, protocol);
|
||||
plugin.getServer().getPluginManager().registerEvents(crafting, plugin);
|
||||
}
|
||||
|
||||
// Main-thread-only, exact snapshots for preparation/invalidation, not transaction authority.
|
||||
private record Preview(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy,
|
||||
ReinforcementState.Snapshot reinforcement,
|
||||
AdvancedAnvilMerge.Proposal proposal, boolean originalBypass, Component message) {}
|
||||
|
||||
AdvancedAnvilListener(Plugin plugin, Supplier<AdvancedAnvilPolicy> policy,
|
||||
@@ -63,10 +72,13 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
this.risk = Objects.requireNonNull(risk);
|
||||
this.wear = Objects.requireNonNull(wear);
|
||||
protocol = new AdvancedAnvilProtocolGuard(plugin, manager, version, this);
|
||||
reinforcement = new ReinforcementState(plugin, this::policyChanged);
|
||||
try {
|
||||
plugin.getServer().getPluginManager().registerEvents(reinforcement, plugin);
|
||||
refresh = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskTimer(
|
||||
plugin, this::refreshActionBars, 40L, 40L));
|
||||
} catch (RuntimeException | LinkageError unavailable) {
|
||||
reinforcement.close();
|
||||
protocol.close();
|
||||
throw unavailable;
|
||||
}
|
||||
@@ -101,6 +113,13 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
}
|
||||
return;
|
||||
}
|
||||
var ownedAnvil = reinforcement.capture(view.getTopInventory().getLocation());
|
||||
if (ownedAnvil == null) {
|
||||
release(view);
|
||||
event.setResult(null);
|
||||
return;
|
||||
}
|
||||
proposal = AdvancedAnvilMerge.prepare(left, right, effective, ownedAnvil.level());
|
||||
if (!protocol.watch(view)) {
|
||||
release(view);
|
||||
event.setResult(null); // Withhold enhanced output BEFORE any unsafe bypass/install.
|
||||
@@ -108,12 +127,14 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
}
|
||||
boolean originalBypass = previous == null ? view.canBypassCost() : previous.originalBypass();
|
||||
Component message = message(proposal);
|
||||
if (ownedAnvil.level() > 0) message = message.append(Component.text(
|
||||
" · Reinforcement " + ownedAnvil.level() + "/5 (all spent on take)"));
|
||||
boolean generationChanged = previous == null || !matches(view, previous) || changed(previous, proposal);
|
||||
if (generationChanged) {
|
||||
NativeAnvilGeneration.menu(view).incrementStateId();
|
||||
NativeAnvilGeneration.requireResultFrame(view, proposal.output() == null);
|
||||
}
|
||||
previews.put(view, generationChanged ? new Preview(left.clone(), right.clone(), effective, proposal,
|
||||
previews.put(view, generationChanged ? new Preview(left.clone(), right.clone(), effective, ownedAnvil, proposal,
|
||||
originalBypass, message) : previous);
|
||||
if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(message);
|
||||
@@ -182,6 +203,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
var spent = reinforcement.spend(origin, attempt.preview.reinforcement());
|
||||
if (spent == null) { rerender(view); return; }
|
||||
int roll = risk.getAsInt();
|
||||
if (roll < 0 || roll >= 100) throw new IllegalStateException("Invalid anvil risk roll");
|
||||
boolean failed = roll < attempt.preview.proposal().failurePercent();
|
||||
@@ -195,11 +218,16 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
try {
|
||||
if (!failed) attempt.delivery.deliver();
|
||||
try {
|
||||
AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear);
|
||||
if (failed) AdvancedAnvilEffects.destroy(view, (Player) view.getPlayer(), origin,
|
||||
() -> reinforcement.current(origin, spent),
|
||||
event -> reinforcement.removalCurrent(origin, spent, event));
|
||||
else AdvancedAnvilEffects.use(view, (Player) view.getPlayer(), origin, wear,
|
||||
() -> reinforcement.current(origin, spent));
|
||||
} finally {
|
||||
if (failed) AdvancedAnvilEffects.explode((Player) view.getPlayer(), origin);
|
||||
}
|
||||
} finally {
|
||||
policyChanged();
|
||||
rerender(view);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +235,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
private boolean matches(AnvilView view, Preview preview) {
|
||||
return Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|
||||
&& Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|
||||
&& preview.policy().equals(policy.get());
|
||||
&& preview.policy().equals(policy.get())
|
||||
&& reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement());
|
||||
}
|
||||
|
||||
private void rerender(AnvilView view) {
|
||||
@@ -226,6 +255,7 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (crafting != null) crafting.policyChanged();
|
||||
for (AnvilView view : List.copyOf(previews.keySet())) {
|
||||
if (current(view)) {
|
||||
recompute(view);
|
||||
@@ -258,7 +288,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
// A changed input/policy cannot keep displaying a stale risk between preparations.
|
||||
if (!Objects.equals(preview.left(), view.getTopInventory().getItem(0))
|
||||
|| !Objects.equals(preview.right(), view.getTopInventory().getItem(1))
|
||||
|| !preview.policy().equals(policy.get())) {
|
||||
|| !preview.policy().equals(policy.get())
|
||||
|| !reinforcement.current(view.getTopInventory().getLocation(), preview.reinforcement())) {
|
||||
recompute(view);
|
||||
} else if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(preview.message());
|
||||
@@ -276,6 +307,27 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory();
|
||||
}
|
||||
|
||||
Object protocolPreview(org.bukkit.inventory.InventoryView view) {
|
||||
if (view instanceof AnvilView anvil) return protocolPreview(anvil);
|
||||
return crafting == null ? null : crafting.preview(view);
|
||||
}
|
||||
|
||||
boolean protocolReady(org.bukkit.inventory.InventoryView view) {
|
||||
if (view instanceof AnvilView anvil) return protocolReady(anvil);
|
||||
return crafting != null && crafting.ready(view);
|
||||
}
|
||||
|
||||
void protocolHookReady(org.bukkit.inventory.InventoryView view) {
|
||||
if (view instanceof AnvilView anvil) protocolHookReady(anvil);
|
||||
else if (crafting != null) crafting.rerender(view);
|
||||
}
|
||||
|
||||
void protocolRejected(org.bukkit.inventory.InventoryView view) {
|
||||
if (view instanceof AnvilView anvil) protocolRejected(anvil);
|
||||
else if (!closed && view.getPlayer().getOpenInventory() == view && view.getPlayer() instanceof Player player)
|
||||
player.updateInventory();
|
||||
}
|
||||
|
||||
Object protocolPreview(AnvilView view) { return previews.get(view); }
|
||||
|
||||
boolean protocolReady(AnvilView view) {
|
||||
@@ -359,6 +411,8 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (crafting != null) crafting.close();
|
||||
reinforcement.close();
|
||||
pending.clear();
|
||||
seen.clear();
|
||||
refresh.cancel();
|
||||
|
||||
@@ -17,7 +17,12 @@ final class AdvancedAnvilMerge {
|
||||
record Proposal(ItemStack output, int failurePercent, String refusal) {}
|
||||
|
||||
static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy) {
|
||||
return prepare(left, right, policy, 0);
|
||||
}
|
||||
|
||||
static Proposal prepare(ItemStack left, ItemStack right, AdvancedAnvilPolicy policy, int reinforcement) {
|
||||
Objects.requireNonNull(policy);
|
||||
if (reinforcement < 0 || reinforcement > 5) throw new IllegalArgumentException("Invalid protection level");
|
||||
if (left == null || right == null || left.isEmpty() || right.isEmpty()
|
||||
|| left.getType() != right.getType() || book(left.getType())) {
|
||||
return null;
|
||||
@@ -69,7 +74,7 @@ final class AdvancedAnvilMerge {
|
||||
} catch (RuntimeException unsupported) {
|
||||
return refused("Result cannot be represented exactly by this server.");
|
||||
}
|
||||
return new Proposal(output, policy.failurePercent(excess), null);
|
||||
return new Proposal(output, policy.failurePercent(Math.max(0, excess - reinforcement)), null);
|
||||
}
|
||||
|
||||
private static Proposal refused(String reason) {
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
/** Immutable balance captured by an attempt; excess is not an enchantment-level cap. */
|
||||
record AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent) {
|
||||
record AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent,
|
||||
java.util.List<Integer> reinforcementFailures) {
|
||||
AdvancedAnvilPolicy(int basePercent, int stepPercent, int maxFailurePercent) {
|
||||
this(basePercent, stepPercent, maxFailurePercent, java.util.List.of(20, 40, 60, 80, 99));
|
||||
}
|
||||
|
||||
AdvancedAnvilPolicy {
|
||||
validate(basePercent);
|
||||
validate(stepPercent);
|
||||
validate(maxFailurePercent);
|
||||
reinforcementFailures = java.util.List.copyOf(reinforcementFailures);
|
||||
if (reinforcementFailures.size() != 5) throw new IllegalArgumentException("Expected five reinforcement tiers");
|
||||
int previous = 0;
|
||||
for (int percent : reinforcementFailures) {
|
||||
validate(percent);
|
||||
if (percent < previous) throw new IllegalArgumentException("Reinforcement failures must be nondecreasing");
|
||||
previous = percent;
|
||||
}
|
||||
}
|
||||
|
||||
int reinforcementFailure(int currentLevel) {
|
||||
if (currentLevel < 0 || currentLevel >= 5) throw new IllegalArgumentException("No next reinforcement tier");
|
||||
return reinforcementFailures.get(currentLevel);
|
||||
}
|
||||
|
||||
static AdvancedAnvilPolicy defaults() {
|
||||
|
||||
@@ -16,7 +16,7 @@ import net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
@@ -36,12 +36,12 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
// Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement).
|
||||
boolean wrapped;
|
||||
boolean consumed;
|
||||
final AnvilView view;
|
||||
final InventoryView view;
|
||||
final net.minecraft.server.network.ServerGamePacketListenerImpl nativeListener;
|
||||
java.util.function.BooleanSupplier dispatchable;
|
||||
Runnable completed;
|
||||
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
|
||||
java.util.function.BooleanSupplier available, AnvilView view) {
|
||||
java.util.function.BooleanSupplier available, InventoryView view) {
|
||||
this.view = view;
|
||||
this.nativeListener = NativeAnvilGeneration.player(view).connection;
|
||||
this.event = event;
|
||||
@@ -58,16 +58,16 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
private final Plugin plugin;
|
||||
private final ProtocolManager manager;
|
||||
private final AdvancedAnvilListener owner;
|
||||
private final Set<AnvilView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Set<InventoryView> watched = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
// Only this immutable actor membership is read on an unexpectedly off-thread library callback.
|
||||
private final Set<Player> actors = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
private final Map<AnvilView, Admission> admissions = new IdentityHashMap<>();
|
||||
private final Map<InventoryView, Admission> admissions = new IdentityHashMap<>();
|
||||
private final Map<Player, NativeAnvilDispatch> dispatches = new IdentityHashMap<>();
|
||||
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
|
||||
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
|
||||
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
|
||||
}
|
||||
private final Map<AnvilView, Frame> frames = new IdentityHashMap<>();
|
||||
private final Map<InventoryView, Frame> frames = new IdentityHashMap<>();
|
||||
// Guard-wide, not reset on forget: even a reused window ID cannot reuse a displayed version.
|
||||
private final AnvilFrameVersions versions = new AnvilFrameVersions();
|
||||
private final PacketAdapter outbound;
|
||||
@@ -79,7 +79,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
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")
|
||||
if (version == null || !version.contains("Purpur") || !version.contains("26.3.build.2651-stable")
|
||||
|| manager.isClosed()) throw new IllegalStateException("Unsupported Advanced Anvil generation bridge");
|
||||
gate = new PacketAdapter(PacketAdapter.params(plugin, PacketType.Play.Client.WINDOW_CLICK)
|
||||
.listenerPriority(ListenerPriority.LOWEST).optionSync()) {
|
||||
@@ -106,7 +106,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
boolean watch(AnvilView view) {
|
||||
boolean watch(InventoryView view) {
|
||||
if (closed || manager.isClosed() || versions.exhausted() || !plugin.getServer().isPrimaryThread()) return false;
|
||||
try {
|
||||
NativeAnvilGeneration.menu(view);
|
||||
@@ -133,9 +133,9 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
} catch (RuntimeException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
|
||||
boolean watched(AnvilView view) { return watched.contains(view); }
|
||||
boolean watched(InventoryView view) { return watched.contains(view); }
|
||||
|
||||
void invalidate(AnvilView view) {
|
||||
void invalidate(InventoryView view) {
|
||||
Admission admission = admissions.remove(view);
|
||||
if (admission != null) {
|
||||
admission.revoked = true;
|
||||
@@ -150,7 +150,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
if (actors.contains(event.getPlayer())) event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
|
||||
InventoryView view = event.getPlayer().getOpenInventory();
|
||||
if (!watched.contains(view)) return;
|
||||
try {
|
||||
Object raw = event.getPacket().getHandle();
|
||||
int containerId;
|
||||
@@ -191,7 +192,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
if (actors.contains(event.getPlayer())) event.setCancelled(true);
|
||||
return; // Never inspect or mutate a Bukkit view off the tick thread.
|
||||
}
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
|
||||
InventoryView view = event.getPlayer().getOpenInventory();
|
||||
if (!watched.contains(view)) return;
|
||||
invalidate(view); // A new raw intent can never reuse an earlier ticket.
|
||||
NativeAnvilDispatch acquiredDispatch = null;
|
||||
Admission acquired = null;
|
||||
@@ -200,7 +202,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|
||||
|| !(raw instanceof ServerboundContainerClickPacket packet)
|
||||
|| packet.containerInput() == null || !currentFrame(view, packet)
|
||||
|| (NativeAnvilGeneration.take(packet) && !NativeAnvilGeneration.supportedTake(packet))
|
||||
|| (NativeAnvilGeneration.take(view, packet) && !NativeAnvilGeneration.supportedTake(packet))
|
||||
|| !owner.protocolReady(view)
|
||||
|| (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) {
|
||||
refuse(event, view);
|
||||
@@ -213,7 +215,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
event.setPacket(new PacketContainer(event.getPacketType(), translated));
|
||||
// Formerly enhanced windows retain only frame translation/refusal for normal
|
||||
// operations. No normal take depends on enhanced identity-budget/queue ownership.
|
||||
if (!NativeAnvilGeneration.take(translated) || owner.protocolPreview(view) == null) return;
|
||||
if (!NativeAnvilGeneration.take(view, translated) || owner.protocolPreview(view) == null) return;
|
||||
NativeAnvilDispatch dispatch = dispatches.get(event.getPlayer());
|
||||
Admission admission = new Admission(event, translated, owner.protocolPreview(view),
|
||||
() -> !closed && !manager.isClosed() && !versions.exhausted() && dispatch.ready(), view);
|
||||
@@ -252,7 +254,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) {
|
||||
private boolean currentFrame(InventoryView view, ServerboundContainerClickPacket packet) {
|
||||
Frame frame = frames.get(view);
|
||||
var menu = NativeAnvilGeneration.menu(view);
|
||||
return !manager.isClosed() && !versions.exhausted() && frame != null && frame.valid()
|
||||
@@ -262,7 +264,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
|
||||
private void seal(PacketEvent event) {
|
||||
if (closed || !plugin.getServer().isPrimaryThread()) return;
|
||||
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view)) return;
|
||||
InventoryView view = event.getPlayer().getOpenInventory();
|
||||
Admission admission = admissions.get(view);
|
||||
if (admission == null || admission.event != event) return;
|
||||
// MONITOR is observational. Retain the actual event for final cancellation checks both
|
||||
@@ -271,7 +273,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
if (!admission.sealed) invalidate(view);
|
||||
}
|
||||
|
||||
Admission consume(AnvilView view, Object preview, InventoryClickEvent event) {
|
||||
Admission consume(InventoryView view, Object preview, InventoryClickEvent event) {
|
||||
Admission admission = NativeAnvilDispatch.current();
|
||||
if (closed || admission == null || admission.view != view || admissions.get(view) != admission
|
||||
|| !admission.valid() || admission.preview != preview
|
||||
@@ -283,12 +285,12 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
return admission;
|
||||
}
|
||||
|
||||
private static void refuse(PacketEvent event, AnvilView view) {
|
||||
private static void refuse(PacketEvent event, InventoryView view) {
|
||||
event.setCancelled(true);
|
||||
if (view.getPlayer() instanceof Player player) player.updateInventory();
|
||||
}
|
||||
|
||||
void forget(AnvilView view) {
|
||||
void forget(InventoryView view) {
|
||||
invalidate(view);
|
||||
watched.remove(view);
|
||||
frames.remove(view);
|
||||
@@ -303,7 +305,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
}
|
||||
|
||||
void prune() {
|
||||
for (AnvilView view : List.copyOf(watched)) {
|
||||
for (InventoryView view : List.copyOf(watched)) {
|
||||
if (view.getPlayer().getOpenInventory() != view) forget(view);
|
||||
}
|
||||
}
|
||||
@@ -311,7 +313,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (AnvilView view : List.copyOf(watched)) {
|
||||
for (InventoryView view : List.copyOf(watched)) {
|
||||
forget(view);
|
||||
// Retiring the exact owned window prevents an old enhanced packet becoming an
|
||||
// unrelated vanilla XP take after the protocol listeners have been removed.
|
||||
|
||||
@@ -36,7 +36,12 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
private static final List<String> KEYS = List.of(
|
||||
"advanced-anvil-base-failure-percent",
|
||||
"advanced-anvil-risk-step-percent",
|
||||
"advanced-anvil-max-failure-percent");
|
||||
"advanced-anvil-max-failure-percent",
|
||||
"advanced-anvil-reinforcement-failure-1-percent",
|
||||
"advanced-anvil-reinforcement-failure-2-percent",
|
||||
"advanced-anvil-reinforcement-failure-3-percent",
|
||||
"advanced-anvil-reinforcement-failure-4-percent",
|
||||
"advanced-anvil-reinforcement-failure-5-percent");
|
||||
private final Plugin plugin;
|
||||
private final Path file;
|
||||
private final Consumer<AdvancedAnvilPolicy> onChange;
|
||||
@@ -48,7 +53,7 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
private AdvancedAnvilPolicy persisted;
|
||||
private long savedRevision;
|
||||
// Accessed only under lifecycle, including main-thread acknowledgments.
|
||||
private long publishedRevision;
|
||||
private final long[] publishedRevisions = new long[KEYS.size()];
|
||||
|
||||
static AdvancedAnvilSettings open(
|
||||
Plugin plugin, Consumer<AdvancedAnvilPolicy> onAcknowledgedChange) throws IOException {
|
||||
@@ -179,7 +184,7 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
persisted = next;
|
||||
long revision = ++savedRevision;
|
||||
schedule(() -> acknowledge(sender, key, next, revision));
|
||||
} catch (IOException | YAMLException failure) {
|
||||
} catch (IOException | YAMLException | IllegalArgumentException failure) {
|
||||
schedule(() -> {
|
||||
synchronized (lifecycle) {
|
||||
if (!closed && sender.hasPermission(PERMISSION)) {
|
||||
@@ -204,10 +209,20 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
|
||||
private void acknowledge(CommandSender sender, String key, AdvancedAnvilPolicy next, long revision) {
|
||||
synchronized (lifecycle) {
|
||||
if (closed || revision <= publishedRevision) {
|
||||
if (closed) return;
|
||||
int index = KEYS.indexOf(key);
|
||||
if (index < 0 || revision <= publishedRevisions[index]) return;
|
||||
publishedRevisions[index] = revision;
|
||||
if (!plugin.isEnabled() || !sender.hasPermission(PERMISSION)
|
||||
|| (sender instanceof org.bukkit.entity.Player player && !player.isOnline())) return;
|
||||
try {
|
||||
// Every key requires its own current authorized callback. Saved neighboring
|
||||
// values (including combine keys) are not runtime publication authority.
|
||||
next = replace(effective, key, value(next, key));
|
||||
} catch (IllegalArgumentException staleTable) {
|
||||
sender.sendMessage("Configuration acknowledgment refused; effective settings unchanged.");
|
||||
return;
|
||||
}
|
||||
publishedRevision = revision;
|
||||
effective = next;
|
||||
try {
|
||||
onChange.accept(next);
|
||||
@@ -276,7 +291,7 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
|
||||
private static AdvancedAnvilPolicy parsePolicy(Map<Object, Object> document) throws IOException {
|
||||
AdvancedAnvilPolicy defaults = AdvancedAnvilPolicy.defaults();
|
||||
int[] values = new int[3];
|
||||
int[] values = new int[KEYS.size()];
|
||||
for (int index = 0; index < KEYS.size(); index++) {
|
||||
String key = KEYS.get(index);
|
||||
Object raw = document.get(key);
|
||||
@@ -289,7 +304,12 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
throw new IOException("Invalid integer percent for " + key);
|
||||
}
|
||||
}
|
||||
return new AdvancedAnvilPolicy(values[0], values[1], values[2]);
|
||||
try {
|
||||
return new AdvancedAnvilPolicy(values[0], values[1], values[2],
|
||||
List.of(values[3], values[4], values[5], values[6], values[7]));
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IOException("Invalid reinforcement failure table", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static int value(AdvancedAnvilPolicy policy, String key) {
|
||||
@@ -297,15 +317,19 @@ final class AdvancedAnvilSettings implements AutoCloseable {
|
||||
case 0 -> policy.basePercent();
|
||||
case 1 -> policy.stepPercent();
|
||||
case 2 -> policy.maxFailurePercent();
|
||||
case 3, 4, 5, 6, 7 -> policy.reinforcementFailures().get(KEYS.indexOf(key) - 3);
|
||||
default -> throw new IllegalArgumentException("Unsupported setting");
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvancedAnvilPolicy replace(AdvancedAnvilPolicy policy, String key, int percent) {
|
||||
var failures = new java.util.ArrayList<>(policy.reinforcementFailures());
|
||||
int index = KEYS.indexOf(key);
|
||||
if (index >= 3) failures.set(index - 3, percent);
|
||||
return new AdvancedAnvilPolicy(
|
||||
key.equals(KEYS.get(0)) ? percent : policy.basePercent(),
|
||||
key.equals(KEYS.get(1)) ? percent : policy.stepPercent(),
|
||||
key.equals(KEYS.get(2)) ? percent : policy.maxFailurePercent());
|
||||
key.equals(KEYS.get(2)) ? percent : policy.maxFailurePercent(), failures);
|
||||
}
|
||||
|
||||
/** Nonblocking close: drop queued work, interrupt the daemon writer, suppress every late ack. */
|
||||
|
||||
@@ -8,7 +8,7 @@ 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. */
|
||||
/** Compile-only linkage to the checksum-pinned Purpur 26.3 build 2651 menu/packet API. */
|
||||
final class NativeAnvilGeneration {
|
||||
private NativeAnvilGeneration() {}
|
||||
|
||||
@@ -18,7 +18,17 @@ final class NativeAnvilGeneration {
|
||||
return menu;
|
||||
}
|
||||
|
||||
static net.minecraft.server.level.ServerPlayer player(AnvilView view) {
|
||||
static net.minecraft.world.inventory.AbstractContainerMenu menu(org.bukkit.inventory.InventoryView view) {
|
||||
if (!(view instanceof org.bukkit.craftbukkit.inventory.CraftInventoryView<?, ?> craft))
|
||||
throw new IllegalStateException("Unsupported native inventory view");
|
||||
var menu = craft.getHandle();
|
||||
if (!(menu instanceof AnvilMenu || menu instanceof net.minecraft.world.inventory.InventoryMenu
|
||||
|| menu instanceof net.minecraft.world.inventory.CraftingMenu))
|
||||
throw new IllegalStateException("Unsupported native transaction menu");
|
||||
return menu;
|
||||
}
|
||||
|
||||
static net.minecraft.server.level.ServerPlayer player(org.bukkit.inventory.InventoryView view) {
|
||||
if (!(view.getPlayer() instanceof org.bukkit.craftbukkit.entity.CraftPlayer player)) {
|
||||
throw new IllegalStateException("Unsupported native anvil actor");
|
||||
}
|
||||
@@ -40,13 +50,14 @@ final class NativeAnvilGeneration {
|
||||
menu(view).sendAllDataToRemote();
|
||||
}
|
||||
|
||||
static boolean current(AnvilView view, ServerboundContainerClickPacket packet) {
|
||||
static boolean current(org.bukkit.inventory.InventoryView view, ServerboundContainerClickPacket packet) {
|
||||
var menu = menu(view);
|
||||
return packet.containerId() == menu.containerId && packet.stateId() == menu.getStateId();
|
||||
}
|
||||
|
||||
static boolean take(ServerboundContainerClickPacket packet) {
|
||||
return packet.slotNum() == 2 || packet.containerInput() == ContainerInput.PICKUP_ALL;
|
||||
static boolean take(org.bukkit.inventory.InventoryView view, ServerboundContainerClickPacket packet) {
|
||||
return packet.slotNum() == (view instanceof AnvilView ? 2 : 0)
|
||||
|| packet.containerInput() == ContainerInput.PICKUP_ALL;
|
||||
}
|
||||
|
||||
static boolean supportedTake(ServerboundContainerClickPacket packet) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Accepted single gambling crafts share the exact bounded packet ownership of enhanced takes. */
|
||||
final class ReinforcementCrafting implements Listener, AutoCloseable {
|
||||
private final Plugin plugin;
|
||||
private final Supplier<AdvancedAnvilPolicy> policy;
|
||||
private final IntSupplier risk;
|
||||
private final ReinforcementRecipeRegistrar recipe;
|
||||
private final AdvancedAnvilProtocolGuard protocol;
|
||||
private final ReinforcementItems items;
|
||||
private final Map<InventoryView, Preview> previews = new IdentityHashMap<>();
|
||||
private final Map<InventoryView, Attempt> pending = new IdentityHashMap<>();
|
||||
private final Map<InventoryClickEvent, Boolean> seen = new java.util.WeakHashMap<>();
|
||||
private boolean closed;
|
||||
private record Preview(ItemStack[] matrix, int anvil, int fuel, int level,
|
||||
AdvancedAnvilPolicy policy, ItemStack output, String feedback) {}
|
||||
private static final class Attempt {
|
||||
private final Preview preview;
|
||||
private final InventoryClickEvent event;
|
||||
private final AdvancedAnvilProtocolGuard.Admission admission;
|
||||
private boolean invalid;
|
||||
Attempt(Preview preview, InventoryClickEvent event, AdvancedAnvilProtocolGuard.Admission admission) {
|
||||
this.preview = preview;
|
||||
this.event = event;
|
||||
this.admission = admission;
|
||||
}
|
||||
Preview preview() { return preview; }
|
||||
InventoryClickEvent event() { return event; }
|
||||
AdvancedAnvilProtocolGuard.Admission admission() { return admission; }
|
||||
}
|
||||
|
||||
ReinforcementCrafting(Plugin plugin, Supplier<AdvancedAnvilPolicy> policy, IntSupplier risk,
|
||||
ReinforcementRecipeRegistrar recipe, AdvancedAnvilProtocolGuard protocol) {
|
||||
this.plugin = plugin;
|
||||
this.policy = policy;
|
||||
this.risk = risk;
|
||||
this.recipe = recipe;
|
||||
this.protocol = protocol;
|
||||
items = new ReinforcementItems(plugin);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void prepare(PrepareItemCraftEvent event) {
|
||||
if (closed) return;
|
||||
InventoryView view = event.getView();
|
||||
var inventory = event.getInventory();
|
||||
Attempt attempt = pending.get(view);
|
||||
if (attempt != null) {
|
||||
attempt.invalid = true; // Consumed admissions are no longer in the guard map; invalidation must be sticky.
|
||||
protocol.invalidate(view);
|
||||
inventory.setResult(null);
|
||||
return;
|
||||
}
|
||||
ItemStack[] matrix = copies(inventory.getMatrix());
|
||||
int anvil = -1;
|
||||
int fuel = -1;
|
||||
for (int i = 0; i < matrix.length; i++) {
|
||||
ItemStack item = matrix[i];
|
||||
if (item == null) continue;
|
||||
if (AdvancedAnvilEffects.isAnvil(item.getType()) && anvil == -1) anvil = i;
|
||||
else if (item.getType() == Material.NETHERITE_BLOCK && fuel == -1) fuel = i;
|
||||
else { release(view); return; }
|
||||
}
|
||||
if (anvil < 0 || fuel < 0) { release(view); return; }
|
||||
if (!recipe.active()) { inventory.setResult(null); release(view); return; }
|
||||
if (!(inventory.getRecipe() instanceof org.bukkit.Keyed keyed)
|
||||
|| !recipe.recipeKeys().contains(keyed.getKey())) { release(view); return; }
|
||||
int level = items.level(matrix[anvil]);
|
||||
AdvancedAnvilPolicy effective = policy.get();
|
||||
String feedback = level < 0 ? "Invalid reinforcement ownership; no attempt accepted."
|
||||
: level == 5 ? "Maximum reinforcement level 5; no payment or roll."
|
||||
: "Reinforcement level " + level + "/5 → " + (level + 1) + "/5 · Failure "
|
||||
+ effective.reinforcementFailure(level) + "% · One anvil + one Netherite Block; failure loses both.";
|
||||
ItemStack output = level < 0 || level == 5 ? null
|
||||
: items.preview(matrix[anvil], level + 1, effective.reinforcementFailure(level));
|
||||
Preview previous = previews.get(view);
|
||||
if (!protocol.watch(view)) { inventory.setResult(null); release(view); return; }
|
||||
boolean changed = previous == null || !Arrays.equals(matrix, previous.matrix())
|
||||
|| !effective.equals(previous.policy());
|
||||
if (changed) {
|
||||
NativeAnvilGeneration.menu(view).incrementStateId();
|
||||
previews.put(view, new Preview(matrix, anvil, fuel, level, effective, output, feedback));
|
||||
}
|
||||
var nativeMenu = NativeAnvilGeneration.menu(view);
|
||||
nativeMenu.setRemoteSlot(0, output == null ? nativeMenu.getSlot(1).getItem().copy()
|
||||
: net.minecraft.world.item.ItemStack.EMPTY);
|
||||
inventory.setResult(output);
|
||||
if (view.getPlayer() instanceof Player player) {
|
||||
player.sendActionBar(Component.text(feedback));
|
||||
if (changed) {
|
||||
player.sendMessage(Component.text(feedback
|
||||
+ " Each reinforcement shifts advanced risk by one excess tier. An advanced attempt spends all levels; failure destroys inputs/anvil with an explosion."));
|
||||
player.sendMessage(Component.text("Reinforcement levels 1–5 failure: "
|
||||
+ effective.reinforcementFailures().stream().map(percent -> percent + "%")
|
||||
.collect(java.util.stream.Collectors.joining(", ")) + "."));
|
||||
player.sendMessage(Component.text("Combine risk excess 1–7 failure: "
|
||||
+ java.util.stream.IntStream.rangeClosed(1, 7).mapToObj(excess -> effective.failurePercent(excess) + "%")
|
||||
.collect(java.util.stream.Collectors.joining(", "))
|
||||
+ ". Risk excess = max(0, actual enchantment excess − reinforcement); zero risk excess means 0%. "
|
||||
+ "An over-limit take remains advanced and spends all reinforcement even at 0%; ordinary combining keeps it and native XP costs."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void take(InventoryClickEvent event) {
|
||||
InventoryView view = event.getView();
|
||||
Preview preview = previews.get(view);
|
||||
if (closed || preview == null || (event.getRawSlot() != 0
|
||||
&& event.getAction() != org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR)) return;
|
||||
if (pending.containsKey(view) || seen.put(event, Boolean.TRUE) != null) { event.setCancelled(true); return; }
|
||||
var admission = protocol.consume(view, preview, event);
|
||||
if (event.isCancelled() || admission == null || preview.output() == null
|
||||
|| (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT)
|
||||
|| event.getRawSlot() != 0 || !event.getWhoClicked().getItemOnCursor().isEmpty()
|
||||
|| !matches(view, preview)) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
Attempt attempt = new Attempt(preview, event, admission);
|
||||
pending.put(view, attempt);
|
||||
((CraftingInventory) view.getTopInventory()).setResult(null);
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> finish(view, attempt));
|
||||
} catch (RuntimeException unavailable) {
|
||||
pending.remove(view);
|
||||
event.setCancelled(true);
|
||||
rerender(view);
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(InventoryView view, Attempt attempt) {
|
||||
if (closed || pending.get(view) != attempt) return;
|
||||
pending.remove(view);
|
||||
Preview preview = attempt.preview();
|
||||
if (attempt.invalid || attempt.event().isCancelled() || !attempt.admission().valid() || previews.get(view) != preview
|
||||
|| !current(view) || !matches(view, preview) || !view.getPlayer().getItemOnCursor().isEmpty()) {
|
||||
rerender(view);
|
||||
return;
|
||||
}
|
||||
int roll = risk.getAsInt();
|
||||
if (roll < 0 || roll >= 100) throw new IllegalStateException("Invalid reinforcement risk roll");
|
||||
var menu = (net.minecraft.world.inventory.AbstractCraftingMenu) NativeAnvilGeneration.menu(view);
|
||||
// Live native counts: debit both before any prepare/feedback/player callback can reenter.
|
||||
menu.craftSlots.getItem(preview.anvil()).shrink(1);
|
||||
menu.craftSlots.getItem(preview.fuel()).shrink(1);
|
||||
if (roll >= preview.policy().reinforcementFailure(preview.level()))
|
||||
view.getPlayer().setItemOnCursor(items.reinforce(preview.matrix()[preview.anvil()], preview.level() + 1));
|
||||
rerender(view);
|
||||
}
|
||||
|
||||
Object preview(InventoryView view) { return previews.get(view); }
|
||||
boolean ready(InventoryView view) {
|
||||
Preview preview = previews.get(view);
|
||||
return !closed && current(view) && !pending.containsKey(view)
|
||||
&& (preview == null || (matches(view, preview) && Objects.equals(preview.output(),
|
||||
((CraftingInventory) view.getTopInventory()).getResult())));
|
||||
}
|
||||
private boolean matches(InventoryView view, Preview preview) {
|
||||
return recipe.active() && view.getTopInventory() instanceof CraftingInventory inventory
|
||||
&& Arrays.equals(preview.matrix(), copies(inventory.getMatrix())) && preview.policy().equals(policy.get());
|
||||
}
|
||||
private static boolean current(InventoryView view) {
|
||||
return view.getPlayer().getOpenInventory() == view && view.getPlayer() instanceof Player player
|
||||
&& player.isOnline() && !player.isDead();
|
||||
}
|
||||
void rerender(InventoryView view) {
|
||||
if (!closed && current(view) && view.getTopInventory() instanceof CraftingInventory inventory) {
|
||||
inventory.setMatrix(inventory.getMatrix());
|
||||
NativeAnvilGeneration.menu(view).sendAllDataToRemote();
|
||||
} else release(view);
|
||||
}
|
||||
void policyChanged() { for (InventoryView view : List.copyOf(previews.keySet())) rerender(view); }
|
||||
private void release(InventoryView view) {
|
||||
if (previews.remove(view) != null && view.getPlayer() instanceof Player player)
|
||||
player.sendActionBar(Component.empty()); // Withdraw our obsolete gambling warning, not native ordinary feedback.
|
||||
protocol.invalidate(view);
|
||||
}
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void closeView(InventoryCloseEvent event) {
|
||||
pending.remove(event.getView());
|
||||
release(event.getView());
|
||||
protocol.forget(event.getView());
|
||||
}
|
||||
private static ItemStack[] copies(ItemStack[] matrix) {
|
||||
return Arrays.stream(matrix).map(item -> item == null || item.isEmpty() ? null : item.clone()).toArray(ItemStack[]::new);
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending.clear();
|
||||
seen.clear();
|
||||
HandlerList.unregisterAll(this);
|
||||
for (InventoryView view : List.copyOf(previews.keySet())) {
|
||||
release(view);
|
||||
if (current(view)) ((CraftingInventory) view.getTopInventory()).setResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Only plugin-owned typed metadata establishes a tier; presentation never establishes authority. */
|
||||
final class ReinforcementItems {
|
||||
private final NamespacedKey tierKey;
|
||||
private final NamespacedKey identityKey;
|
||||
ReinforcementItems(Plugin plugin) {
|
||||
tierKey = new NamespacedKey(plugin, "reinforcement");
|
||||
identityKey = new NamespacedKey(plugin, "reinforcement_identity");
|
||||
}
|
||||
|
||||
/** -1 means malformed owned state, 0 ordinary, 1..5 authentic. */
|
||||
int level(ItemStack item) {
|
||||
if (item == null || item.isEmpty() || !AdvancedAnvilEffects.isAnvil(item.getType())) return -1;
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(tierKey) && !data.has(identityKey)) return 0;
|
||||
try {
|
||||
Integer tier = data.get(tierKey, PersistentDataType.INTEGER);
|
||||
String id = data.get(identityKey, PersistentDataType.STRING);
|
||||
if (tier == null || tier < 1 || tier > 5 || id == null || !UUID.fromString(id).toString().equals(id)) return -1;
|
||||
return tier;
|
||||
} catch (IllegalArgumentException malformed) { return -1; }
|
||||
}
|
||||
|
||||
ItemStack preview(ItemStack source, int next, int failure) {
|
||||
var output = ordinary(source);
|
||||
var meta = output.getItemMeta();
|
||||
var lore = meta.lore() == null ? new ArrayList<Component>() : new ArrayList<>(meta.lore());
|
||||
lore.add(Component.text("Attempt level " + next + "/5 · Failure " + failure + "% (not guaranteed)"));
|
||||
meta.lore(lore);
|
||||
output.setItemMeta(meta);
|
||||
return output;
|
||||
}
|
||||
|
||||
ItemStack reinforce(ItemStack source, int level) {
|
||||
if (level < 1 || level > 5) throw new IllegalArgumentException("Invalid reinforcement level");
|
||||
var output = ordinary(source);
|
||||
var meta = output.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(tierKey, PersistentDataType.INTEGER, level);
|
||||
meta.getPersistentDataContainer().set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString());
|
||||
var lore = meta.lore() == null ? new ArrayList<Component>() : new ArrayList<>(meta.lore());
|
||||
lore.add(label(level));
|
||||
meta.lore(lore);
|
||||
output.setItemMeta(meta);
|
||||
return output;
|
||||
}
|
||||
|
||||
ItemStack ordinary(ItemStack source) {
|
||||
var output = source.asQuantity(1);
|
||||
int previous = level(source);
|
||||
var meta = output.getItemMeta();
|
||||
List<Component> lore = meta.lore();
|
||||
if (previous > 0 && lore != null && !lore.isEmpty() && lore.getLast().equals(label(previous))) {
|
||||
var retained = new ArrayList<>(lore);
|
||||
retained.removeLast();
|
||||
meta.lore(retained.isEmpty() ? null : retained);
|
||||
}
|
||||
meta.getPersistentDataContainer().remove(tierKey);
|
||||
meta.getPersistentDataContainer().remove(identityKey);
|
||||
output.setItemMeta(meta);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static Component label(int level) { return Component.text("Reinforcement level " + level + "/5"); }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.inventory.recipe.CraftingBookCategory;
|
||||
|
||||
/** A discoverable native recipe; its static result is never reinforcement authority. */
|
||||
final class ReinforcementRecipeRegistrar implements RecipeRegistrar {
|
||||
private final NamespacedKey key;
|
||||
private final String group = "reinforcement_" + UUID.randomUUID();
|
||||
private Server server;
|
||||
private boolean owned;
|
||||
|
||||
ReinforcementRecipeRegistrar(NamespacedKey key) { this.key = Objects.requireNonNull(key); }
|
||||
|
||||
@Override public boolean register(Server target) {
|
||||
if (owned) return true;
|
||||
server = Objects.requireNonNull(target);
|
||||
if (server.getRecipe(key) != null) return false;
|
||||
var result = new ItemStack(Material.ANVIL);
|
||||
var meta = result.getItemMeta();
|
||||
meta.displayName(Component.text("Anvil Reinforcement")); // Native book hover-name/search identity; no usable tier authority.
|
||||
meta.lore(List.of(Component.text("Gamble one Netherite Block: up to five reinforcement levels."),
|
||||
Component.text("See the current tier and failure chance in crafting."),
|
||||
Component.text("Each level shifts advanced risk by one excess tier."),
|
||||
Component.text("One advanced attempt spends all protection; failure causes an explosion.")));
|
||||
result.setItemMeta(meta);
|
||||
var recipe = new ShapelessRecipe(key, result);
|
||||
recipe.addIngredient(new RecipeChoice.MaterialChoice(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL));
|
||||
recipe.addIngredient(Material.NETHERITE_BLOCK);
|
||||
recipe.setCategory(CraftingBookCategory.MISC);
|
||||
recipe.setGroup(group);
|
||||
try {
|
||||
owned = server.addRecipe(recipe);
|
||||
return owned;
|
||||
} catch (RuntimeException failure) {
|
||||
owned = matches(server.getRecipe(key));
|
||||
try { unregister(); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
boolean active() { return owned && server != null && matches(server.getRecipe(key)); }
|
||||
|
||||
private boolean matches(Recipe recipe) {
|
||||
return recipe instanceof ShapelessRecipe shapeless && shapeless.getKey().equals(key)
|
||||
&& shapeless.getGroup().equals(group);
|
||||
}
|
||||
|
||||
@Override public List<NamespacedKey> recipeKeys() { return List.of(key); }
|
||||
@Override public String description() { return "Anvil reinforcement recipe"; }
|
||||
@Override public void unregister() {
|
||||
if (!owned || server == null) return;
|
||||
Recipe current = server.getRecipe(key);
|
||||
if (current != null && !matches(current)) { owned = false; return; }
|
||||
if (current != null && !server.removeRecipe(key) && server.getRecipe(key) != null)
|
||||
throw new IllegalStateException("Could not remove owned reinforcement recipe");
|
||||
owned = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package games.dmg.spigotinventoryhelper;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Native loaded-chunk PDC owns a physical anvil. No plugin disk store, chunk load or replay. */
|
||||
final class ReinforcementState implements Listener, AutoCloseable {
|
||||
record Snapshot(String identity, ItemStack item, int level) {}
|
||||
private record Position(UUID world, int x, int y, int z) {}
|
||||
private final Plugin plugin;
|
||||
private final ReinforcementItems items;
|
||||
private final Runnable changed;
|
||||
private final NamespacedKey identity;
|
||||
private final NamespacedKey payload;
|
||||
private final NamespacedKey withheld;
|
||||
private final NamespacedKey carrierPayload;
|
||||
private final NamespacedKey carrierPhase;
|
||||
private final Map<UUID, Object> landings = new HashMap<>();
|
||||
private final Map<UUID, org.bukkit.event.entity.EntityDropItemEvent> dropping = new HashMap<>();
|
||||
private final Map<Position, Object> transitions = new HashMap<>();
|
||||
private final Map<Position, Breakage> breaks = new HashMap<>();
|
||||
private record Drop(org.bukkit.entity.Item entity, ItemStack before) {}
|
||||
private static final class Breakage {
|
||||
final Block block;
|
||||
final Snapshot snapshot;
|
||||
final org.bukkit.event.block.BlockBreakEvent event;
|
||||
org.bukkit.event.block.BlockDropItemEvent dropEvent;
|
||||
List<Drop> drops = List.of();
|
||||
Breakage(Block block, Snapshot snapshot, org.bukkit.event.block.BlockBreakEvent event) {
|
||||
this.block = block;
|
||||
this.snapshot = snapshot;
|
||||
this.event = event;
|
||||
}
|
||||
}
|
||||
private boolean closed;
|
||||
|
||||
ReinforcementState(Plugin plugin, Runnable changed) {
|
||||
this.plugin = plugin;
|
||||
this.changed = changed;
|
||||
items = new ReinforcementItems(plugin);
|
||||
identity = new NamespacedKey(plugin, "identity");
|
||||
payload = new NamespacedKey(plugin, "item");
|
||||
withheld = new NamespacedKey(plugin, "withheld");
|
||||
carrierPayload = new NamespacedKey(plugin, "falling_reinforcement");
|
||||
carrierPhase = new NamespacedKey(plugin, "transport_phase");
|
||||
}
|
||||
|
||||
Snapshot capture(Location origin) {
|
||||
if (closed || origin == null || !loaded(origin) || transitions.containsKey(position(origin))) return null;
|
||||
Block block = origin.getBlock();
|
||||
if (!AdvancedAnvilEffects.isAnvil(block.getType())) return null;
|
||||
try {
|
||||
var data = data(block);
|
||||
if (data == null) return null;
|
||||
Snapshot snapshot = read(block);
|
||||
if (snapshot == null && !data.has(key(block))) {
|
||||
snapshot = new Snapshot(UUID.randomUUID().toString(), new ItemStack(block.getType()), 0);
|
||||
write(block, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
} catch (RuntimeException malformed) { return null; }
|
||||
}
|
||||
|
||||
boolean current(Location origin, Snapshot snapshot) {
|
||||
return snapshot != null && snapshot.equals(capture(origin));
|
||||
}
|
||||
|
||||
/** A destruction decision may cross ONLY its own registered break fence, never a replacement. */
|
||||
boolean removalCurrent(Location origin, Snapshot snapshot, org.bukkit.event.block.BlockBreakEvent event) {
|
||||
if (closed || origin == null || snapshot == null || !loaded(origin)) return false;
|
||||
Object transition = transitions.get(position(origin));
|
||||
if (transition != null && (!(transition instanceof Breakage breakage) || breakage.event != event)) return false;
|
||||
try { return snapshot.equals(read(origin.getBlock(), transition instanceof Breakage)); }
|
||||
catch (RuntimeException malformed) { return false; }
|
||||
}
|
||||
|
||||
Snapshot spend(Location origin, Snapshot snapshot) {
|
||||
if (!current(origin, snapshot)) return null;
|
||||
Snapshot ordinary = new Snapshot(UUID.randomUUID().toString(), items.ordinary(snapshot.item()), 0);
|
||||
try {
|
||||
write(origin.getBlock(), ordinary);
|
||||
return ordinary.equals(read(origin.getBlock())) ? ordinary : null;
|
||||
} catch (RuntimeException uncertain) { return null; }
|
||||
}
|
||||
|
||||
private boolean loaded(Location location) {
|
||||
return location.getWorld() != null && location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4);
|
||||
}
|
||||
private PersistentDataContainer data(Block block) {
|
||||
if (!block.getWorld().isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) return null;
|
||||
var chunk = block.getChunk();
|
||||
return chunk != null && chunk.isLoaded() ? chunk.getPersistentDataContainer() : null;
|
||||
}
|
||||
private NamespacedKey key(Block block) {
|
||||
return new NamespacedKey(plugin, "anvil_" + (block.getX() & 15) + "_" + block.getY() + "_" + (block.getZ() & 15));
|
||||
}
|
||||
private static Position position(Location location) {
|
||||
return new Position(location.getWorld().getUID(), location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
}
|
||||
private Snapshot read(Block block) { return read(block, false); }
|
||||
private Snapshot read(Block block, boolean allowWithheld) {
|
||||
var chunk = data(block);
|
||||
if (chunk == null || !chunk.has(key(block))) return null;
|
||||
var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
if (record == null) throw new IllegalArgumentException("Malformed anvil record");
|
||||
if (!allowWithheld && record.has(withheld)) return null; // Durable unknown custody is not a fresh ordinary owner.
|
||||
String id = record.get(identity, PersistentDataType.STRING);
|
||||
byte[] bytes = record.get(payload, PersistentDataType.BYTE_ARRAY);
|
||||
if (id == null || !UUID.fromString(id).toString().equals(id) || bytes == null)
|
||||
throw new IllegalArgumentException("Malformed anvil identity");
|
||||
ItemStack item = ItemStack.deserializeBytes(bytes);
|
||||
int level = items.level(item);
|
||||
if (level < 0 || item.getAmount() != 1) throw new IllegalArgumentException("Malformed anvil item");
|
||||
// Native wear/falling can change damage. Never restore the serialized old variant.
|
||||
item.setType(block.getType());
|
||||
return new Snapshot(id, item, level);
|
||||
}
|
||||
private void write(Block block, Snapshot snapshot) {
|
||||
var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable");
|
||||
var record = chunk.getAdapterContext().newPersistentDataContainer();
|
||||
record.set(identity, PersistentDataType.STRING, snapshot.identity());
|
||||
record.set(payload, PersistentDataType.BYTE_ARRAY, snapshot.item().serializeAsBytes());
|
||||
chunk.set(key(block), PersistentDataType.TAG_CONTAINER, record);
|
||||
}
|
||||
private void quarantine(Block block) {
|
||||
var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable");
|
||||
var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
if (record == null) return;
|
||||
var reserved = chunk.getAdapterContext().newPersistentDataContainer();
|
||||
record.copyTo(reserved, true);
|
||||
reserved.set(withheld, PersistentDataType.BYTE, (byte) 1);
|
||||
chunk.set(key(block), PersistentDataType.TAG_CONTAINER, reserved);
|
||||
var installed = chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
if (installed == null || !Objects.equals(installed.get(withheld, PersistentDataType.BYTE), (byte) 1))
|
||||
throw new IllegalStateException("Anvil ownership quarantine refused");
|
||||
}
|
||||
|
||||
private void remove(Block block) {
|
||||
var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable");
|
||||
quarantine(block); // A failed/ignored removal cannot reactivate the previous charge on reload.
|
||||
chunk.remove(key(block));
|
||||
if (chunk.has(key(block))) throw new IllegalStateException("Anvil ownership removal refused");
|
||||
}
|
||||
|
||||
/** Native falling has distinct source-removal and destination-placement outcomes. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void falling(org.bukkit.event.entity.EntityChangeBlockEvent event) {
|
||||
if (closed || event.isCancelled() || !(event.getEntity() instanceof org.bukkit.entity.FallingBlock carrier)) return;
|
||||
if (event.getTo().isAir() && AdvancedAnvilEffects.isAnvil(event.getBlock().getType())) depart(event, carrier);
|
||||
else if (AdvancedAnvilEffects.isAnvil(event.getTo())) land(event, carrier);
|
||||
}
|
||||
|
||||
private void depart(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) {
|
||||
Block block = event.getBlock();
|
||||
Snapshot snapshot = capture(block.getLocation());
|
||||
if (snapshot == null || carrier.getPersistentDataContainer().has(carrierPayload)) return;
|
||||
Position position = position(block.getLocation());
|
||||
Object transition = new Object();
|
||||
transitions.put(position, transition);
|
||||
changed.run();
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return;
|
||||
if (event.isCancelled()) {
|
||||
transitions.remove(position, transition);
|
||||
changed.run();
|
||||
return; // No persistent authority was written to the unspawned candidate.
|
||||
}
|
||||
if (AdvancedAnvilEffects.isAnvil(block.getType())) return; // Source removal unobserved.
|
||||
try {
|
||||
if (carrier.isValid() && !carrier.isDead()) {
|
||||
writeCarrier(carrier, snapshot, (byte) 0); // Provisional, not usable before source retirement.
|
||||
retire(block, snapshot);
|
||||
writeCarrier(carrier, snapshot, (byte) 1);
|
||||
} else {
|
||||
retire(block, snapshot); // Native spawn refusal/destruction cannot mint a live charge.
|
||||
}
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
transitions.remove(position, transition);
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
plugin.getLogger().warning("Anvil departure ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
private Snapshot carrierSnapshot(org.bukkit.entity.FallingBlock carrier, byte phase) {
|
||||
var record = carrier.getPersistentDataContainer().get(carrierPayload, PersistentDataType.TAG_CONTAINER);
|
||||
if (record == null || !Objects.equals(record.get(carrierPhase, PersistentDataType.BYTE), phase)) return null;
|
||||
String id = record.get(identity, PersistentDataType.STRING);
|
||||
byte[] bytes = record.get(payload, PersistentDataType.BYTE_ARRAY);
|
||||
if (id == null || !UUID.fromString(id).toString().equals(id) || bytes == null) return null;
|
||||
ItemStack item = ItemStack.deserializeBytes(bytes);
|
||||
int level = items.level(item);
|
||||
if (level < 0 || item.getAmount() != 1 || !AdvancedAnvilEffects.isAnvil(carrier.getBlockData().getMaterial())) return null;
|
||||
item.setType(carrier.getBlockData().getMaterial());
|
||||
return new Snapshot(id, item, level);
|
||||
}
|
||||
|
||||
private void writeCarrier(org.bukkit.entity.FallingBlock carrier, Snapshot snapshot, byte phase) {
|
||||
var data = carrier.getPersistentDataContainer();
|
||||
var record = data.getAdapterContext().newPersistentDataContainer();
|
||||
record.set(identity, PersistentDataType.STRING, snapshot.identity());
|
||||
record.set(payload, PersistentDataType.BYTE_ARRAY, snapshot.item().serializeAsBytes());
|
||||
record.set(carrierPhase, PersistentDataType.BYTE, phase);
|
||||
data.set(carrierPayload, PersistentDataType.TAG_CONTAINER, record);
|
||||
if (!snapshot.equals(carrierSnapshot(carrier, phase))) throw new IllegalStateException("Anvil transport write refused");
|
||||
}
|
||||
|
||||
private void land(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) {
|
||||
Snapshot snapshot;
|
||||
try { snapshot = carrierSnapshot(carrier, (byte) 1); }
|
||||
catch (RuntimeException malformed) { event.setCancelled(true); return; }
|
||||
if (snapshot == null) {
|
||||
if (carrier.getPersistentDataContainer().has(carrierPayload)) event.setCancelled(true);
|
||||
else ordinaryLanding(event, carrier);
|
||||
return;
|
||||
}
|
||||
Block block = event.getBlock();
|
||||
if (!loaded(block.getLocation()) || landings.containsKey(carrier.getUniqueId())) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
Object transition = new Object();
|
||||
Position position = position(block.getLocation());
|
||||
landings.put(carrier.getUniqueId(), transition);
|
||||
transitions.put(position, transition);
|
||||
try {
|
||||
writeCarrier(carrier, snapshot, (byte) 2); // Persisted one-use reservation survives reload conservatively.
|
||||
changed.run();
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || landings.get(carrier.getUniqueId()) != transition) return;
|
||||
if (!loaded(block.getLocation())) return; // Unknown outcome stays reserved, never reusable.
|
||||
try {
|
||||
if (transitions.get(position) == transition && !event.isCancelled() && carrier.isDead()
|
||||
&& block.getType() == event.getTo() && snapshot.equals(carrierSnapshot(carrier, (byte) 2))) {
|
||||
// Native final cancellation + removal + unchanged destination reservation
|
||||
// distinguish accepted landing from a refused write/player replacement.
|
||||
Snapshot landed = new Snapshot(UUID.randomUUID().toString(), snapshot.item(), snapshot.level());
|
||||
write(block, landed);
|
||||
if (!landed.equals(read(block))) throw new IllegalStateException("Anvil landing write refused");
|
||||
carrier.getPersistentDataContainer().remove(carrierPayload);
|
||||
} else if (carrier.isDead() && !dropping.containsKey(carrier.getUniqueId())) {
|
||||
carrier.getPersistentDataContainer().remove(carrierPayload); // Refused landing with no native drop claimant.
|
||||
} else if (snapshot.equals(carrierSnapshot(carrier, (byte) 2))) {
|
||||
writeCarrier(carrier, snapshot, (byte) 1); // Observed failed-write/live carrier may retry, not the block.
|
||||
}
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
landings.remove(carrier.getUniqueId(), transition);
|
||||
transitions.remove(position, transition);
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
event.setCancelled(true);
|
||||
plugin.getLogger().warning("Anvil landing ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Untagged native reload/ordinary carriers must not inherit a prior position's durable charge. */
|
||||
private void ordinaryLanding(org.bukkit.event.entity.EntityChangeBlockEvent event, org.bukkit.entity.FallingBlock carrier) {
|
||||
Block block = event.getBlock();
|
||||
if (!loaded(block.getLocation())) { event.setCancelled(true); return; }
|
||||
Position position = position(block.getLocation());
|
||||
Object transition = new Object();
|
||||
var before = block.getBlockData().clone();
|
||||
var chunk = data(block);
|
||||
var previous = chunk == null ? null : chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
transitions.put(position, transition);
|
||||
try {
|
||||
remove(block);
|
||||
changed.run();
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return;
|
||||
try {
|
||||
if (event.isCancelled() && previous != null && AdvancedAnvilEffects.isAnvil(before.getMaterial())
|
||||
&& before.equals(block.getBlockData()))
|
||||
Objects.requireNonNull(data(block)).set(key(block), PersistentDataType.TAG_CONTAINER, previous);
|
||||
else if (!event.isCancelled() && carrier.isDead() && block.getType() == event.getTo()) {
|
||||
var ordinary = new Snapshot(UUID.randomUUID().toString(), new ItemStack(event.getTo()), 0);
|
||||
write(block, ordinary);
|
||||
if (!ordinary.equals(read(block))) return;
|
||||
}
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
transitions.remove(position, transition);
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
event.setCancelled(true);
|
||||
changed.run();
|
||||
plugin.getLogger().warning("Ordinary anvil landing ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Native ordinary onTake wear does not necessarily emit a mining/explosion event. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void wear(com.destroystokyo.paper.event.block.AnvilDamagedEvent event) {
|
||||
if (closed || event.isCancelled()) return;
|
||||
Location origin = event.getView().getTopInventory().getLocation();
|
||||
Snapshot snapshot = capture(origin);
|
||||
if (snapshot == null) return;
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || !loaded(origin) || event.isCancelled()
|
||||
|| event.getDamageState() != com.destroystokyo.paper.event.block.AnvilDamagedEvent.DamageState.BROKEN
|
||||
|| AdvancedAnvilEffects.isAnvil(origin.getBlock().getType())) return;
|
||||
try { retire(origin.getBlock(), snapshot); } catch (RuntimeException uncertain) { return; }
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
plugin.getLogger().warning("Anvil wear ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewrite one accepted vanilla supplied item only AFTER final drop cancellation and registration. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void fallingDrop(org.bukkit.event.entity.EntityDropItemEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof org.bukkit.entity.FallingBlock carrier)
|
||||
|| !carrier.getPersistentDataContainer().has(carrierPayload)) return;
|
||||
if (dropping.containsKey(carrier.getUniqueId())) {
|
||||
if (dropping.get(carrier.getUniqueId()) != event) event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
Snapshot snapshot;
|
||||
try {
|
||||
Snapshot active = carrierSnapshot(carrier, (byte) 1);
|
||||
snapshot = active == null ? carrierSnapshot(carrier, (byte) 2) : active;
|
||||
} catch (RuntimeException malformed) { event.setCancelled(true); return; }
|
||||
if (snapshot == null) { event.setCancelled(true); return; }
|
||||
var supplied = new Drop(event.getItemDrop(), event.getItemDrop().getItemStack().clone());
|
||||
try {
|
||||
writeCarrier(carrier, snapshot, (byte) 3); // One terminal claimant; no retry from a second callback.
|
||||
dropping.put(carrier.getUniqueId(), event);
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || dropping.get(carrier.getUniqueId()) != event) return;
|
||||
try {
|
||||
if (!snapshot.equals(carrierSnapshot(carrier, (byte) 3))) return;
|
||||
var data = carrier.getPersistentDataContainer();
|
||||
data.remove(carrierPayload);
|
||||
if (data.has(carrierPayload)) return; // Failed retirement may not publish a second charge.
|
||||
if (!event.isCancelled() && supplied.entity().isValid() && !supplied.entity().isDead()
|
||||
&& supplied.entity().getWorld().equals(carrier.getWorld())
|
||||
&& supplied.before().getAmount() == 1
|
||||
&& supplied.before().getType() == snapshot.item().getType()
|
||||
&& supplied.entity().getItemStack().equals(supplied.before()))
|
||||
supplied.entity().setItemStack(snapshot.item().clone());
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
dropping.remove(carrier.getUniqueId(), event);
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
event.setCancelled(true);
|
||||
plugin.getLogger().warning("Anvil drop ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Fence the old owner during native execution; final cancellation is read after return. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void breaking(org.bukkit.event.block.BlockBreakEvent event) {
|
||||
if (closed || event.isCancelled()) return;
|
||||
Block block = event.getBlock();
|
||||
Snapshot snapshot = capture(block.getLocation());
|
||||
if (snapshot == null) return;
|
||||
Position position = position(block.getLocation());
|
||||
Breakage breakage = new Breakage(block, snapshot, event);
|
||||
breaks.put(position, breakage);
|
||||
transitions.put(position, breakage);
|
||||
changed.run();
|
||||
try {
|
||||
quarantine(block); // Persist the fence BEFORE native removal; unload/close may cancel reconciliation.
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> finishBreak(position, breakage));
|
||||
} catch (RuntimeException unavailable) {
|
||||
// Rejected durable withdrawal/native outcome cannot authorize destructive gameplay.
|
||||
event.setCancelled(true);
|
||||
plugin.getLogger().warning("Anvil break ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void drops(org.bukkit.event.block.BlockDropItemEvent event) {
|
||||
if (closed) return;
|
||||
Breakage breakage = breaks.get(position(event.getBlock().getLocation()));
|
||||
if (breakage == null || breakage.dropEvent != null || event.getPlayer() != breakage.event.getPlayer()
|
||||
|| event.getBlockState().getType() != breakage.snapshot.item().getType()) return;
|
||||
breakage.dropEvent = event;
|
||||
breakage.drops = event.getItems().stream().map(item -> new Drop(item, item.getItemStack().clone())).toList();
|
||||
}
|
||||
|
||||
private void finishBreak(Position position, Breakage breakage) {
|
||||
if (closed || breaks.get(position) != breakage) return;
|
||||
Block block = breakage.block;
|
||||
if (!loaded(block.getLocation())) return; // Unobserved native outcome stays fenced.
|
||||
if (breakage.event.isCancelled()) {
|
||||
if (transitions.get(position) != breakage) return;
|
||||
try {
|
||||
var record = Objects.requireNonNull(data(block)).get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
if (record != null && Objects.equals(record.get(identity, PersistentDataType.STRING), breakage.snapshot.identity())
|
||||
&& block.getType() == breakage.snapshot.item().getType()) write(block, breakage.snapshot);
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
breaks.remove(position, breakage);
|
||||
transitions.remove(position, breakage);
|
||||
changed.run();
|
||||
return; // Native refusal leaves the original persistent record untouched.
|
||||
}
|
||||
if (AdvancedAnvilEffects.isAnvil(block.getType()) && transitions.get(position) == breakage)
|
||||
return; // No observed terminal removal: do not copy an unresolved charge into a drop.
|
||||
try {
|
||||
retire(block, breakage.snapshot);
|
||||
} catch (RuntimeException uncertain) { return; }
|
||||
breaks.remove(position, breakage);
|
||||
transitions.remove(position, breakage);
|
||||
if (breakage.event.isDropItems() && breakage.dropEvent != null && !breakage.dropEvent.isCancelled()) {
|
||||
for (Drop drop : breakage.drops) {
|
||||
if (breakage.dropEvent.getItems().contains(drop.entity()) && !drop.entity().isDead()
|
||||
&& drop.before().getType() == breakage.snapshot.item().getType()
|
||||
&& drop.before().getAmount() == 1 && drop.entity().getItemStack().equals(drop.before())) {
|
||||
drop.entity().setItemStack(breakage.snapshot.item().clone());
|
||||
break; // Rewrite one native supplied drop; never spawn/duplicate a second one.
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.run();
|
||||
}
|
||||
|
||||
/** Never retire a subsequently placed owner at the same coordinates. */
|
||||
private void retire(Block block, Snapshot snapshot) {
|
||||
var chunk = Objects.requireNonNull(data(block), "Loaded persistent chunk unavailable");
|
||||
var record = chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
if (record != null && Objects.equals(record.get(identity, PersistentDataType.STRING), snapshot.identity())) {
|
||||
chunk.remove(key(block));
|
||||
if (chunk.has(key(block))) throw new IllegalStateException("Anvil ownership retirement refused");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void explode(org.bukkit.event.entity.EntityExplodeEvent event) {
|
||||
if (!closed && !event.isCancelled()) destruction(List.copyOf(event.blockList()),
|
||||
() -> !event.isCancelled(), event.blockList());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void explode(org.bukkit.event.block.BlockExplodeEvent event) {
|
||||
if (!closed && !event.isCancelled()) destruction(List.copyOf(event.blockList()),
|
||||
() -> !event.isCancelled(), event.blockList());
|
||||
}
|
||||
|
||||
private void destruction(List<Block> candidates, java.util.function.BooleanSupplier accepted, List<Block> finalBlocks) {
|
||||
for (Block block : candidates) {
|
||||
Snapshot snapshot = capture(block.getLocation());
|
||||
if (snapshot == null) continue;
|
||||
Position position = position(block.getLocation());
|
||||
Object transition = new Object();
|
||||
transitions.put(position, transition);
|
||||
changed.run();
|
||||
try {
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || transitions.get(position) != transition || !loaded(block.getLocation())) return;
|
||||
if (accepted.getAsBoolean() && finalBlocks.contains(block)
|
||||
&& !AdvancedAnvilEffects.isAnvil(block.getType())) {
|
||||
try { retire(block, snapshot); } catch (RuntimeException uncertain) { return; }
|
||||
}
|
||||
transitions.remove(position, transition);
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
plugin.getLogger().warning("Anvil destruction ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void place(BlockPlaceEvent event) {
|
||||
if (closed) return;
|
||||
Block block = event.getBlockPlaced();
|
||||
Location location = block.getLocation();
|
||||
if (location == null || !loaded(location)) return;
|
||||
Position position = position(location);
|
||||
Object transition = new Object();
|
||||
var chunk = data(block);
|
||||
PersistentDataContainer previous = chunk == null || !chunk.has(key(block), PersistentDataType.TAG_CONTAINER)
|
||||
? null : chunk.get(key(block), PersistentDataType.TAG_CONTAINER);
|
||||
transitions.put(position, transition);
|
||||
// Withdraw old authority immediately; uncertain/cancelled task delivery cannot leave
|
||||
// an old physical identity attached to a newly placed same-material block on restart.
|
||||
changed.run();
|
||||
ItemStack placed = event.getItemInHand().asQuantity(1);
|
||||
try {
|
||||
remove(block);
|
||||
plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
if (closed || transitions.get(position) != transition) return;
|
||||
transitions.remove(position);
|
||||
if ((event.isCancelled() || !event.canBuild()) && previous != null && loaded(location)
|
||||
&& Objects.equals(block.getBlockData(), event.getBlockReplacedState().getBlockData())) {
|
||||
Objects.requireNonNull(data(block)).set(key(block), PersistentDataType.TAG_CONTAINER, previous);
|
||||
} else if (!event.isCancelled() && event.canBuild() && loaded(location)
|
||||
&& block.getType() == placed.getType() && items.level(placed) >= 0) {
|
||||
try { write(block, new Snapshot(UUID.randomUUID().toString(), placed, items.level(placed))); }
|
||||
catch (RuntimeException uncertain) { remove(block); }
|
||||
}
|
||||
changed.run();
|
||||
});
|
||||
} catch (RuntimeException unavailable) {
|
||||
// No speculative acknowledgment/restore after a rejected scheduler.
|
||||
plugin.getLogger().warning("Anvil placement ownership unavailable; reinforcement withheld.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
transitions.clear();
|
||||
breaks.clear();
|
||||
landings.clear();
|
||||
dropping.clear();
|
||||
HandlerList.unregisterAll(this);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
private RecipeLifecycleGroup recipeLifecycles;
|
||||
private AdvancedAnvilSettings advancedAnvilSettings;
|
||||
private AdvancedAnvilListener advancedAnvil;
|
||||
private RecipeLifecycle reinforcementRecipe;
|
||||
private java.util.function.IntSupplier anvilRisk = () -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100);
|
||||
private java.util.function.DoubleSupplier anvilWear = () -> java.util.concurrent.ThreadLocalRandom.current().nextDouble();
|
||||
|
||||
@@ -98,6 +99,10 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
advancedAnvil = new AdvancedAnvilListener(this, advancedAnvilSettings::policy, anvilRisk, anvilWear,
|
||||
anvilProtocol.get(), anvilVersion.get());
|
||||
getServer().getPluginManager().registerEvents(advancedAnvil, this);
|
||||
var reinforcementRecipes = new ReinforcementRecipeRegistrar(new NamespacedKey(this, "reinforce_anvil"));
|
||||
advancedAnvil.enableReinforcement(reinforcementRecipes);
|
||||
reinforcementRecipe = new RecipeLifecycle(getServer(), this, reinforcementRecipes, getLogger());
|
||||
reinforcementRecipe.enable();
|
||||
} catch (IOException | RuntimeException | LinkageError failure) {
|
||||
closeAdvancedAnvil();
|
||||
getLogger().log(java.util.logging.Level.SEVERE,
|
||||
@@ -108,22 +113,47 @@ public final class SpigotInventoryHelperPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
closeAdvancedAnvil();
|
||||
RuntimeException failure = null;
|
||||
try { closeAdvancedAnvil(); }
|
||||
catch (RuntimeException unavailable) { failure = unavailable; }
|
||||
if (recipeLifecycles != null) {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
try {
|
||||
recipeLifecycles.disableAll();
|
||||
recipeLifecycles = null;
|
||||
} catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); }
|
||||
}
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
|
||||
private void closeAdvancedAnvil() {
|
||||
RuntimeException failure = null;
|
||||
// A failed owner remains reachable for explicit cleanup retry, but must not
|
||||
// prevent independent listener/protocol/settings owners from closing now.
|
||||
if (reinforcementRecipe != null) {
|
||||
try {
|
||||
reinforcementRecipe.disable();
|
||||
reinforcementRecipe = null;
|
||||
} catch (RuntimeException unavailable) { failure = unavailable; }
|
||||
}
|
||||
if (advancedAnvil != null) {
|
||||
advancedAnvil.close();
|
||||
advancedAnvil = null;
|
||||
try {
|
||||
advancedAnvil.close();
|
||||
advancedAnvil = null;
|
||||
} catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); }
|
||||
}
|
||||
if (advancedAnvilSettings != null) {
|
||||
advancedAnvilSettings.close();
|
||||
advancedAnvilSettings = null;
|
||||
try {
|
||||
advancedAnvilSettings.close();
|
||||
advancedAnvilSettings = null;
|
||||
} catch (RuntimeException unavailable) { failure = cleanupFailure(failure, unavailable); }
|
||||
}
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
|
||||
private static RuntimeException cleanupFailure(RuntimeException prior, RuntimeException next) {
|
||||
if (prior == null) return next;
|
||||
if (prior != next) prior.addSuppressed(next);
|
||||
return prior;
|
||||
}
|
||||
|
||||
SortingStickItemFactory sortingSticks() {
|
||||
|
||||
@@ -4,3 +4,9 @@
|
||||
advanced-anvil-base-failure-percent: 20
|
||||
advanced-anvil-risk-step-percent: 5
|
||||
advanced-anvil-max-failure-percent: 99
|
||||
# Complete reinforcement table must be nondecreasing. Five successful levels maximum.
|
||||
advanced-anvil-reinforcement-failure-1-percent: 20
|
||||
advanced-anvil-reinforcement-failure-2-percent: 40
|
||||
advanced-anvil-reinforcement-failure-3-percent: 60
|
||||
advanced-anvil-reinforcement-failure-4-percent: 80
|
||||
advanced-anvil-reinforcement-failure-5-percent: 99
|
||||
+4
-1
@@ -193,7 +193,10 @@ class NativeAdvancedAnvilPacketTest {
|
||||
assertEquals(0, countOutput(f, expected));
|
||||
org.mockito.Mockito.verify(f.nativePlayer, org.mockito.Mockito.never())
|
||||
.drop(org.mockito.ArgumentMatchers.any(net.minecraft.world.item.ItemStack.class),
|
||||
org.mockito.ArgumentMatchers.anyBoolean());
|
||||
org.mockito.ArgumentMatchers.anyBoolean(),
|
||||
org.mockito.ArgumentMatchers.any(net.minecraft.util.Prediction.class),
|
||||
org.mockito.ArgumentMatchers.anyBoolean(),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -249,7 +249,7 @@ class NativeAdvancedAnvilProtocolTest {
|
||||
@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")) {
|
||||
"Purpur 26.3.build.2652-stable")) {
|
||||
assertEquals(1, f.eventErrors.size(), "Only the explicitly unsupported bridge should report failure");
|
||||
assertInstanceOf(IllegalStateException.class, f.eventErrors.getFirst());
|
||||
assertEquals("Unsupported Advanced Anvil generation bridge", f.eventErrors.getFirst().getMessage());
|
||||
|
||||
@@ -51,6 +51,24 @@ class NativeAdvancedAnvilRiskTest {
|
||||
return;
|
||||
}
|
||||
for (int roll = 0; roll < 100; roll++) {
|
||||
// US-011 explicitly destroys the used anvil on failure. Each independent
|
||||
// trial needs a new physical anvil through registered placement, not an
|
||||
// assertion that later trials can reuse AIR or inherit its old ownership.
|
||||
if (effects.block.getType() == org.bukkit.Material.AIR) {
|
||||
var replaced = org.bukkit.craftbukkit.block.CraftBlockStates.getBlockState(
|
||||
f.world, f.position, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(), null);
|
||||
var anvil = new ItemStack(org.bukkit.Material.ANVIL);
|
||||
f.realInventory.setItem(0, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(anvil));
|
||||
effects.data.set(org.bukkit.craftbukkit.block.data.CraftBlockData.createData(
|
||||
net.minecraft.world.level.block.Blocks.ANVIL.defaultBlockState()));
|
||||
var placement = new org.bukkit.event.block.BlockPlaceEvent(effects.block, replaced,
|
||||
effects.block, anvil, f.player, true, org.bukkit.inventory.EquipmentSlot.HAND);
|
||||
f.events.callEvent(placement);
|
||||
assertFalse(placement.isCancelled());
|
||||
assertTrue(placement.canBuild());
|
||||
f.realInventory.getItem(0).shrink(1); // Qualified world/hand execution port.
|
||||
f.tick();
|
||||
}
|
||||
selectedRoll.set(roll);
|
||||
f.player.setItemOnCursor(null);
|
||||
ItemStack left = NativeAdvancedAnvilTransactionTest.stackedSword(4 + excess, 3);
|
||||
@@ -72,10 +90,12 @@ class NativeAdvancedAnvilRiskTest {
|
||||
assertEquals(2, f.menu.getSlot(1).getItem().getCount(), context);
|
||||
assertEquals(0, f.nativePlayer.experienceLevel, context);
|
||||
if (roll < failure) {
|
||||
assertEquals(org.bukkit.Material.AIR, effects.block.getType(), context);
|
||||
assertTrue(f.player.getItemOnCursor().isEmpty(), context);
|
||||
assertEquals(0, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
|
||||
assertEquals(explosionsBefore + 1, effects.explosions.size(), context);
|
||||
} else {
|
||||
assertEquals(org.bukkit.Material.ANVIL, effects.block.getType(), context);
|
||||
assertEquals(expected, f.player.getItemOnCursor(), context);
|
||||
assertEquals(1, NativeAdvancedAnvilBoundaryTest.count(f, expected), context);
|
||||
assertEquals(explosionsBefore, effects.explosions.size(), context);
|
||||
|
||||
@@ -93,6 +93,7 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
final com.comphenix.protocol.ProtocolManager protocol = mock(com.comphenix.protocol.ProtocolManager.class);
|
||||
final List<com.comphenix.protocol.events.PacketListener> protocolListeners = new ArrayList<>();
|
||||
private final Map<Integer, Integer> displayedStates = new HashMap<>();
|
||||
private final List<SharedAnvilActor> additionalActors = new ArrayList<>();
|
||||
final SpigotInventoryHelperPlugin plugin;
|
||||
final AnvilMenu menu;
|
||||
private final Server previous;
|
||||
@@ -103,16 +104,16 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
|
||||
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");
|
||||
this(directory, risk, wear, "Purpur 26.3.build.2651-stable");
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception {
|
||||
this(directory, () -> 99, () -> 1.0, "Purpur 26.2.build.2618-stable", queuedTransport);
|
||||
this(directory, () -> 99, () -> 1.0, "Purpur 26.3.build.2651-stable", queuedTransport);
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
|
||||
this(directory, risk, wear, "Purpur 26.2.build.2618-stable", queuedTransport);
|
||||
this(directory, risk, wear, "Purpur 26.3.build.2651-stable", queuedTransport);
|
||||
}
|
||||
|
||||
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
|
||||
@@ -168,9 +169,9 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
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.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
|
||||
when(server.getMinecraftVersion()).thenReturn("26.3");
|
||||
when(server.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
|
||||
when(server.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
|
||||
when(server.getLogger()).thenReturn(Logger.getAnonymousLogger());
|
||||
when(server.getPluginManager()).thenReturn(events);
|
||||
@@ -244,6 +245,22 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
when(level.getBlockState(position)).thenReturn(Blocks.ANVIL.defaultBlockState());
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
when(block.getType()).thenReturn(org.bukkit.Material.ANVIL);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getX()).thenReturn(position.getX());
|
||||
when(block.getY()).thenReturn(position.getY());
|
||||
when(block.getZ()).thenReturn(position.getZ());
|
||||
when(block.getLocation()).thenAnswer(ignored -> new org.bukkit.Location(world,
|
||||
position.getX(), position.getY(), position.getZ()));
|
||||
when(block.getBlockData()).thenAnswer(ignored -> org.bukkit.craftbukkit.block.data.CraftBlockData.createData(
|
||||
Blocks.ANVIL.defaultBlockState()));
|
||||
// A typed loaded-chunk/native-PDC port. No permissive global mock or forced loading.
|
||||
var chunk = mock(org.bukkit.Chunk.class);
|
||||
var chunkData = new org.bukkit.craftbukkit.persistence.DirtyCraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
|
||||
when(chunk.isLoaded()).thenReturn(true);
|
||||
when(chunk.getPersistentDataContainer()).thenReturn(chunkData);
|
||||
when(block.getChunk()).thenReturn(chunk);
|
||||
when(world.isChunkLoaded(position.getX() >> 4, position.getZ() >> 4)).thenReturn(true);
|
||||
when(world.getBlockAt(any(org.bukkit.Location.class))).thenReturn(block);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
@@ -296,6 +313,141 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
menu.setSynchronizer(synchronizer);
|
||||
}
|
||||
|
||||
/** A second native user, not a second fixture/plugin or a swapped main-player pointer.
|
||||
* Reuses the existing exact world/chunk/PDC, scheduler and registered protocol listeners.
|
||||
* Only per-user server-player/network boundaries are doubled, as for the primary actor.
|
||||
*/
|
||||
SharedAnvilActor secondActor(NativeAnvilPacketFixture primary) throws Exception {
|
||||
if (primary.anvil != this) throw new IllegalArgumentException("Different owning fixture");
|
||||
var actor = new SharedAnvilActor(primary);
|
||||
additionalActors.add(actor);
|
||||
return actor;
|
||||
}
|
||||
|
||||
final class SharedAnvilActor {
|
||||
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
|
||||
final CraftPlayer player = mock(CraftPlayer.class);
|
||||
final net.minecraft.world.entity.player.Inventory inventory =
|
||||
new net.minecraft.world.entity.player.Inventory(nativePlayer, new EntityEquipment());
|
||||
final net.minecraft.network.Connection connection = mock(net.minecraft.network.Connection.class);
|
||||
final net.minecraft.server.network.ServerGamePacketListenerImpl handler =
|
||||
mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
|
||||
final io.netty.channel.embedded.EmbeddedChannel channel = new io.netty.channel.embedded.EmbeddedChannel();
|
||||
final AnvilMenu menu;
|
||||
final List<Component> actionBars = new ArrayList<>();
|
||||
final List<org.bukkit.event.inventory.InventoryClickEvent> clicks = new ArrayList<>();
|
||||
final List<com.comphenix.protocol.events.PacketEvent> received = new ArrayList<>();
|
||||
private final Map<Integer, Integer> states = new HashMap<>();
|
||||
|
||||
private SharedAnvilActor(NativeAnvilPacketFixture primary) throws Exception {
|
||||
when(player.getHandle()).thenReturn(nativePlayer);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.getName()).thenReturn("SecondAnvilPlayer");
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(player.getInventory()).thenReturn(new CraftInventoryPlayer(inventory));
|
||||
doCallRealMethod().when(player).getItemOnCursor();
|
||||
doCallRealMethod().when(player).setItemOnCursor(any());
|
||||
doCallRealMethod().when(player).updateInventory();
|
||||
doCallRealMethod().when(player).closeInventory();
|
||||
doCallRealMethod().when(player).closeInventory(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
doAnswer(inv -> { actionBars.add(inv.getArgument(0)); return null; })
|
||||
.when(player).sendActionBar(any(Component.class));
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.getInventory()).thenReturn(inventory);
|
||||
when(nativePlayer.registryAccess()).thenReturn(CraftRegistry.getMinecraftRegistry());
|
||||
when(nativePlayer.level()).thenReturn(level);
|
||||
when(nativePlayer.isAlive()).thenReturn(true);
|
||||
when(nativePlayer.isWithinBlockInteractionRange(position, 4.0)).thenReturn(true);
|
||||
when(nativePlayer.getAbilities()).thenReturn(new net.minecraft.world.entity.player.Abilities());
|
||||
when(nativePlayer.hasInfiniteMaterials()).thenReturn(false);
|
||||
when(nativePlayer.gameMode()).thenReturn(net.minecraft.world.level.GameType.SURVIVAL);
|
||||
doCallRealMethod().when(nativePlayer).doCloseContainer();
|
||||
doCallRealMethod().when(nativePlayer).closeContainer(any(org.bukkit.event.inventory.InventoryCloseEvent.Reason.class));
|
||||
doAnswer(inv -> { throw new AssertionError("Unexpected second-user drop"); })
|
||||
.when(nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean(),
|
||||
any(net.minecraft.util.Prediction.class), anyBoolean(),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
|
||||
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "server", nativeServer);
|
||||
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "cserver", primary.craftServer);
|
||||
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, handler, "connection", connection);
|
||||
field(net.minecraft.server.network.ServerGamePacketListenerImpl.class, handler, "player", nativePlayer);
|
||||
nativePlayer.connection = handler;
|
||||
connection.channel = channel;
|
||||
channel.pipeline().addLast("packet_handler", connection);
|
||||
when(handler.shouldHandleMessage(any())).thenReturn(true);
|
||||
doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; })
|
||||
.when(handler).onPacketError(any(), any());
|
||||
doCallRealMethod().when(handler).handleContainerClick(any());
|
||||
doCallRealMethod().when(handler).send(any(net.minecraft.network.protocol.Packet.class));
|
||||
doCallRealMethod().when(handler).send(any(net.minecraft.network.protocol.Packet.class), any());
|
||||
doAnswer(inv -> {
|
||||
send(inv.getArgument(0), player, states);
|
||||
return null;
|
||||
}).when(connection).send(any(net.minecraft.network.protocol.Packet.class), any(), anyBoolean());
|
||||
doAnswer(inv -> {
|
||||
Object message = inv.getArgument(1);
|
||||
try {
|
||||
if (message instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(handler);
|
||||
else if (message instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(handler);
|
||||
else throw new AssertionError("Unsupported second-user native packet: " + message);
|
||||
} catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
|
||||
// The existing shared PacketProcessor owns native thread dispatch.
|
||||
}
|
||||
return null;
|
||||
}).when(connection).channelRead(any(), any());
|
||||
var inventoryMenu = new net.minecraft.world.inventory.InventoryMenu(inventory, true, nativePlayer);
|
||||
field(net.minecraft.world.entity.player.Player.class, nativePlayer, "inventoryMenu", inventoryMenu);
|
||||
menu = new AnvilMenu(9, inventory, ContainerLevelAccess.create(level, position));
|
||||
menu.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
|
||||
nativePlayer.containerMenu = menu;
|
||||
when(player.getOpenInventory()).thenAnswer(ignored -> nativePlayer.containerMenu.getBukkitView());
|
||||
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
|
||||
.getDeclaredConstructor(ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
var synchronizer = (net.minecraft.world.inventory.ContainerSynchronizer) constructor.newInstance(nativePlayer);
|
||||
field(ServerPlayer.class, nativePlayer, "containerSynchronizer", synchronizer);
|
||||
inventoryMenu.setSynchronizer(synchronizer);
|
||||
menu.setSynchronizer(synchronizer);
|
||||
events.registerEvent(org.bukkit.event.inventory.InventoryClickEvent.class, new org.bukkit.event.Listener() {},
|
||||
org.bukkit.event.EventPriority.MONITOR, (ignored, raw) -> {
|
||||
var click = (org.bukkit.event.inventory.InventoryClickEvent) raw;
|
||||
if (click.getWhoClicked() == player) clicks.add(click);
|
||||
}, plugin, false);
|
||||
}
|
||||
|
||||
void inputs(ItemStack left, ItemStack right) {
|
||||
menu.getSlot(0).set(CraftItemStack.asNMSCopy(left));
|
||||
menu.getSlot(1).set(CraftItemStack.asNMSCopy(right));
|
||||
assertNoEventErrors();
|
||||
}
|
||||
|
||||
int clientState() {
|
||||
Integer state = states.get(menu.containerId);
|
||||
if (state == null) throw new AssertionError("Second user has no published frame");
|
||||
return state;
|
||||
}
|
||||
|
||||
void click() {
|
||||
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
|
||||
menu.containerId, clientState(), (short) 2, (byte) 0,
|
||||
net.minecraft.world.inventory.ContainerInput.PICKUP,
|
||||
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
|
||||
var event = com.comphenix.protocol.events.PacketEvent.fromClient(protocol,
|
||||
new com.comphenix.protocol.events.PacketContainer(com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, raw), player);
|
||||
dispatchProtocol(event);
|
||||
if (!event.isCancelled()) {
|
||||
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
|
||||
if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event));
|
||||
channel.pipeline().fireChannelRead(event.getPacket().getHandle());
|
||||
channel.checkException();
|
||||
}
|
||||
received.add(event);
|
||||
assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
void inputs(ItemStack left, ItemStack right) {
|
||||
// setItem triggers SimpleContainer.setChanged -> slotsChanged -> actual result event dispatch.
|
||||
menu.getSlot(0).set(CraftItemStack.asNMSCopy(left));
|
||||
@@ -400,6 +552,11 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
}
|
||||
|
||||
com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw) {
|
||||
return send(raw, player, displayedStates);
|
||||
}
|
||||
|
||||
private com.comphenix.protocol.events.PacketEvent send(net.minecraft.network.protocol.Packet<?> raw,
|
||||
CraftPlayer recipient, Map<Integer, Integer> states) {
|
||||
com.comphenix.protocol.PacketType type;
|
||||
if (raw instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket) {
|
||||
type = com.comphenix.protocol.PacketType.Play.Server.WINDOW_ITEMS;
|
||||
@@ -407,14 +564,14 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
type = com.comphenix.protocol.PacketType.Play.Server.SET_SLOT;
|
||||
} else return null; // Non-state packets are outside this fixture's client header port.
|
||||
var event = com.comphenix.protocol.events.PacketEvent.fromServer(protocol,
|
||||
new com.comphenix.protocol.events.PacketContainer(type, raw), player);
|
||||
new com.comphenix.protocol.events.PacketContainer(type, raw), recipient);
|
||||
dispatchSending(event);
|
||||
if (!event.isCancelled()) {
|
||||
Object wire = event.getPacket().getHandle();
|
||||
if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket content) {
|
||||
displayedStates.put(content.containerId(), content.stateId());
|
||||
states.put(content.containerId(), content.stateId());
|
||||
} else if (wire instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket slot) {
|
||||
displayedStates.put(slot.getContainerId(), slot.getStateId());
|
||||
states.put(slot.getContainerId(), slot.getStateId());
|
||||
} else throw new AssertionError("Unsupported wire frame");
|
||||
}
|
||||
return event;
|
||||
@@ -436,6 +593,7 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
Runnable task;
|
||||
while ((task = libraryTasks.poll()) != null) task.run();
|
||||
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks();
|
||||
additionalActors.forEach(actor -> actor.channel.runPendingTasks());
|
||||
}
|
||||
|
||||
void tick() {
|
||||
@@ -474,6 +632,7 @@ final class NativeAnvilFixture implements AutoCloseable {
|
||||
try {
|
||||
plugin.setEnabled(false);
|
||||
HandlerList.unregisterAll(plugin);
|
||||
additionalActors.forEach(actor -> actor.channel.finishAndReleaseAll());
|
||||
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.finishAndReleaseAll();
|
||||
else ((NativeAnvilTestChannel) channel).finish();
|
||||
field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", previousLibraryScheduler);
|
||||
|
||||
+1
-1
@@ -287,7 +287,7 @@ class NativeAnvilFrameGenerationTest {
|
||||
&& slot.getContainerId() == packet.anvil.menu.containerId).count();
|
||||
}
|
||||
|
||||
private static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
|
||||
static <T> T roundTrip(net.minecraft.network.codec.StreamCodec<net.minecraft.network.RegistryFriendlyByteBuf, T> codec,
|
||||
T packet) throws ReflectiveOperationException {
|
||||
// Paper's real item sanitizer consults the server registry during class initialization.
|
||||
// Supply only that external registry port, and restore the singleton even on failure.
|
||||
|
||||
@@ -68,7 +68,9 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
|
||||
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(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean(),
|
||||
any(net.minecraft.util.Prediction.class), anyBoolean(),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Consumer<org.bukkit.entity.Item>>isNull());
|
||||
when(nativeServer.packetProcessor()).thenReturn(processor);
|
||||
when(craftServer.getPluginManager()).thenReturn(anvil.events);
|
||||
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
|
||||
|
||||
+5
-4
@@ -88,9 +88,9 @@ class NativeCobwebPluginWiringTest {
|
||||
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.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
|
||||
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
|
||||
when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
|
||||
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
@@ -278,7 +278,7 @@ class NativeCobwebPluginWiringTest {
|
||||
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");
|
||||
() -> "Purpur 26.3.build.2651-stable");
|
||||
commands.registerAll(createdPlugin.getName(),
|
||||
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
@@ -316,6 +316,7 @@ class NativeCobwebPluginWiringTest {
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(COBWEB);
|
||||
expected.add(NativeReinforcementCraftTest.KEY);
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
+221
-26
@@ -38,6 +38,188 @@ class NativePowderSnowBookTest {
|
||||
&& result.stack().create().is(net.minecraft.world.item.Items.POWDER_SNOW_BUCKET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reinforcementBookDisplayHasExactSearchIdentityNativeIngredientsAndOneWireClonedMiscEntry() throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 2)) {
|
||||
var key = NativeReinforcementCraftTest.KEY;
|
||||
var holder = f.holders.get(key);
|
||||
assertEquals(RecipeBookCategories.CRAFTING_MISC, holder.value().recipeBookCategory());
|
||||
var display = assertInstanceOf(ShapelessCraftingRecipeDisplay.class, holder.value().display().getFirst());
|
||||
var context = net.minecraft.util.context.ContextMap.builder()
|
||||
.set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
|
||||
.buildAndValidate(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
|
||||
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
|
||||
assertEquals(List.of(Material.ANVIL, Material.CHIPPED_ANVIL, Material.DAMAGED_ANVIL), ingredients.getFirst()
|
||||
.stream().map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList());
|
||||
assertEquals(List.of(Material.NETHERITE_BLOCK), ingredients.getLast().stream()
|
||||
.map(stack -> CraftItemStack.asBukkitCopy(stack).getType()).toList());
|
||||
var result = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class, display.result()).stack().create();
|
||||
var shown = CraftItemStack.asBukkitCopy(result);
|
||||
assertEquals(f.registered.get(key).getResult(), shown, "Display is the exact owning registration, not any anvil count");
|
||||
assertEquals(1, shown.getAmount());
|
||||
assertFalse(shown.getItemMeta().getPersistentDataContainer().has(
|
||||
new NamespacedKey("spigotinventoryhelper", "reinforcement")));
|
||||
// These native hover-name components are the actual client search/display identity.
|
||||
// A plain 'Anvil' plus tooltip-only lore cannot identify the mechanic in book search.
|
||||
String searchName = result.getHoverName().getString().toLowerCase(java.util.Locale.ROOT);
|
||||
assertTrue(searchName.contains("reinforc"), "Reinforcement-specific searchable output name: " + searchName);
|
||||
var displays = new java.util.ArrayList<net.minecraft.world.item.crafting.display.RecipeDisplayEntry>();
|
||||
f.manager.listDisplaysForRecipe(holder.id(), displays::add);
|
||||
assertEquals(1, displays.size());
|
||||
var target = displays.getFirst();
|
||||
var entries = new java.util.ArrayList<ClientboundRecipeBookAddPacket.Entry>();
|
||||
for (var packet : f.packets.packets) {
|
||||
if (!(packet instanceof ClientboundRecipeBookAddPacket book)) continue;
|
||||
var wire = NativeAnvilFrameGenerationTest.roundTrip(ClientboundRecipeBookAddPacket.STREAM_CODEC, book);
|
||||
for (var entry : wire.entries()) {
|
||||
if (entry.contents().display().result() instanceof SlotDisplay.ItemStackSlotDisplay item
|
||||
&& shown.equals(CraftItemStack.asBukkitCopy(item.stack().create()))) entries.add(entry);
|
||||
}
|
||||
}
|
||||
assertFalse(entries.isEmpty());
|
||||
assertEquals(1, entries.stream().filter(ClientboundRecipeBookAddPacket.Entry::notification).count());
|
||||
assertEquals(target.id(), entries.getLast().contents().id(), "Current display ID identifies this exact recipe");
|
||||
assertTrue(entries.getLast().highlight());
|
||||
for (var entry : entries) {
|
||||
assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category());
|
||||
assertEquals(target.display(), entry.contents().display());
|
||||
var wireResult = assertInstanceOf(SlotDisplay.ItemStackSlotDisplay.class,
|
||||
entry.contents().display().result()).stack().create();
|
||||
assertEquals(shown, CraftItemStack.asBukkitCopy(wireResult));
|
||||
assertEquals(searchName, wireResult.getHoverName().getString().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
assertEquals(1, f.discoveries.stream().filter(event -> event.getRecipe().equals(key)).count());
|
||||
assertTrue(f.menu.craftSlots.isEmpty());
|
||||
assertTrue(f.menu.getCarried().isEmpty());
|
||||
f.actor.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void actualInGameTeachingExplainsBothEffectiveRiskTablesAndAllSpentDestructiveExplosion(boolean configured)
|
||||
throws Exception {
|
||||
var rolls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) {
|
||||
if (configured) {
|
||||
when(f.actor.player.hasPermission("spigotinventoryhelper.admin")).thenReturn(true);
|
||||
for (var setting : List.of("advanced-anvil-base-failure-percent", "advanced-anvil-risk-step-percent",
|
||||
"advanced-anvil-max-failure-percent")) {
|
||||
int value = setting.contains("base") ? 30 : setting.contains("step") ? 10 : 90;
|
||||
f.actor.adminMessages.clear();
|
||||
f.actor.admin(f.actor.player, setting, value);
|
||||
f.actor.acknowledgeSave();
|
||||
}
|
||||
for (int tier = 1; tier <= 5; tier++) {
|
||||
f.actor.adminMessages.clear();
|
||||
f.actor.admin(f.actor.player, "advanced-anvil-reinforcement-failure-" + tier + "-percent", tier * 20 - 10);
|
||||
f.actor.acknowledgeSave();
|
||||
}
|
||||
}
|
||||
f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.ANVIL)));
|
||||
f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.NETHERITE_BLOCK)));
|
||||
var lessons = new java.util.ArrayList<String>();
|
||||
var bookLore = f.registered.get(NativeReinforcementCraftTest.KEY).getResult().getItemMeta().lore();
|
||||
if (bookLore != null) bookLore.forEach(line -> lessons.add(plain(line)));
|
||||
var previewLore = CraftItemStack.asBukkitCopy(f.menu.getResultSlot().getItem()).getItemMeta().lore();
|
||||
if (previewLore != null) previewLore.forEach(line -> lessons.add(plain(line)));
|
||||
// Exercise actual private preparation too: accessible in-game teaching may live in
|
||||
// the tooltip, crafting feedback or private anvil feedback, not just one static format.
|
||||
f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView()));
|
||||
f.actor.nativePlayer.containerMenu = f.actor.menu;
|
||||
f.actor.inputs(NativeAnvilCombiningRegressionTest.sword(7), NativeAnvilCombiningRegressionTest.sword(7));
|
||||
f.actor.actionBars.forEach(line -> lessons.add(plain(line)));
|
||||
f.actor.chats.forEach(line -> lessons.add(plain(line)));
|
||||
// Do NOT include admin output or README: percentages must be explained to a player.
|
||||
String teaching = String.join("\n", lessons).toLowerCase(java.util.Locale.ROOT);
|
||||
String reinforcement = configured ? "10%, 30%, 50%, 70%, 90%" : "20%, 40%, 60%, 80%, 99%";
|
||||
String combine = configured ? "30%, 40%, 60%, 90%, 90%, 90%, 90%" : "20%, 25%, 35%, 50%, 70%, 95%, 99%";
|
||||
assertTrue(hasOrderedTable(lessons, "reinforc", reinforcement), "Missing explained level 1–5 effective failure table:\n" + teaching);
|
||||
assertTrue(hasOrderedTable(lessons, "excess", combine), "Missing explained excess 1–7 effective combine table:\n" + teaching);
|
||||
assertTrue(teaching.contains("one excess tier"), teaching);
|
||||
assertTrue(teaching.contains("0%"), "Explain the zero-excess/zero-risk floor: " + teaching);
|
||||
assertTrue(teaching.contains("all") && (teaching.contains("spent") || teaching.contains("spends")), teaching);
|
||||
assertTrue(teaching.contains("explosion"), teaching);
|
||||
assertTrue(teaching.contains("destroy") && teaching.contains("input") && teaching.contains("anvil"), teaching);
|
||||
assertTrue(f.actor.menu.getCarried().isEmpty());
|
||||
assertEquals(0, rolls.get(), "Teaching/registration/discovery/preview is never an attempt");
|
||||
f.actor.assertNoEventErrors();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasOrderedTable(List<String> lessons, String subject, String values) {
|
||||
// Semantic subject plus ordered percentages; tolerate punctuation/whitespace and
|
||||
// either one compact line or adjacent multiline rows, but not unrelated aggregate counts.
|
||||
for (int start = 0; start < lessons.size(); start++) {
|
||||
String text = String.join(" ", lessons.subList(start, Math.min(start + 8, lessons.size())))
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
if (!lessons.get(start).toLowerCase(java.util.Locale.ROOT).contains(subject)) continue;
|
||||
int position = 0;
|
||||
boolean found = true;
|
||||
for (String value : values.split(", ")) {
|
||||
int next = text.indexOf(value, position);
|
||||
if (next < 0) { found = false; break; }
|
||||
position = next + value.length();
|
||||
}
|
||||
if (found) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String plain(net.kyori.adventure.text.Component component) {
|
||||
return net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(component);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reinforcementSpecificPerPlayerDiscoveryFailureAndCancellationRetryWithoutLosingOtherNativeRecords()
|
||||
throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 2)) {
|
||||
var key = NativeReinforcementCraftTest.KEY;
|
||||
var nativeKey = NativePowderSnowFixture.nativeKey(key);
|
||||
var cancelled = f.joiningPlayer(null);
|
||||
var failed = f.joiningPlayer(null);
|
||||
var healthy = f.joiningPlayer(null);
|
||||
var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA);
|
||||
for (var actor : List.of(cancelled, failed, healthy)) actor.book().add(vanillaKey);
|
||||
var cancel = new AtomicBoolean(true);
|
||||
f.actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.HIGHEST,
|
||||
(ignored, raw) -> {
|
||||
var event = (PlayerRecipeDiscoverEvent) raw;
|
||||
if (event.getPlayer() == cancelled.player() && event.getRecipe().equals(key)) event.setCancelled(cancel.get());
|
||||
}, f.actor.plugin, false);
|
||||
var injected = new IllegalStateException("injected reinforcement-only discovery failure");
|
||||
doThrow(injected).when(failed.player()).discoverRecipes(List.of(key));
|
||||
f.actor.plugin.setEnabled(false);
|
||||
doReturn(List.of(cancelled.player(), failed.player(), healthy.player())).when(f.actor.server).getOnlinePlayers();
|
||||
f.actor.plugin.setEnabled(true); // Actual ONLINE registration failure must not block healthy players.
|
||||
assertFalse(cancelled.book().contains(nativeKey));
|
||||
assertFalse(failed.book().contains(nativeKey));
|
||||
assertTrue(healthy.book().contains(nativeKey));
|
||||
for (var actor : List.of(cancelled, failed, healthy)) {
|
||||
assertTrue(actor.book().contains(vanillaKey));
|
||||
assertTrue(actor.book().contains(NativePowderSnowFixture.nativeKey(KEY)), "Unrelated utility discovery stays independent");
|
||||
}
|
||||
var failedKnown = failed.book().pack().known();
|
||||
var cancelledKnown = cancelled.book().pack().known();
|
||||
cancel.set(false);
|
||||
doCallRealMethod().when(failed.player()).discoverRecipes(any());
|
||||
// Healthy existing native book already knows all registrations; retry only missing reinforcement.
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry"));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry"));
|
||||
assertTrue(failed.book().pack().known().containsAll(failedKnown));
|
||||
assertTrue(cancelled.book().pack().known().containsAll(cancelledKnown));
|
||||
assertTrue(failed.book().contains(nativeKey));
|
||||
assertTrue(cancelled.book().contains(nativeKey));
|
||||
int sent = failed.sent().size();
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "known"));
|
||||
assertEquals(sent, failed.sent().size());
|
||||
assertEquals(1, f.discoveries.stream().filter(event -> event.getRecipe().equals(key)
|
||||
&& event.getPlayer() == failed.player()).count());
|
||||
assertEquals(List.of(injected), f.actor.eventErrors, "Qualify only the exact intentionally injected per-player fault");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertPackedEquals(ServerRecipeBook.Packed expected, ServerRecipeBook.Packed actual) {
|
||||
assertEquals(ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, expected).getOrThrow(),
|
||||
ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, actual).getOrThrow());
|
||||
@@ -49,10 +231,10 @@ class NativePowderSnowBookTest {
|
||||
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,
|
||||
var context = net.minecraft.util.context.ContextMap.builder()
|
||||
.set(net.minecraft.world.item.crafting.display.SlotDisplayContext.REGISTRIES,
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry())
|
||||
.create(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
|
||||
.buildAndValidate(net.minecraft.world.item.crafting.display.SlotDisplayContext.CONTEXT);
|
||||
var ingredients = display.ingredients().stream().map(slot -> slot.resolveForStacks(context)).toList();
|
||||
assertEquals(List.of(1, 1), ingredients.stream().map(List::size).toList());
|
||||
assertEquals(List.of(Material.SNOW_BLOCK, Material.BUCKET), ingredients.stream()
|
||||
@@ -70,25 +252,20 @@ class NativePowderSnowBookTest {
|
||||
var powderPackets = f.packets.packets.stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
|
||||
.map(ClientboundRecipeBookAddPacket.class::cast)
|
||||
.filter(packet -> packet.entries().stream().anyMatch(NativePowderSnowBookTest::powderEntry)).toList();
|
||||
assertEquals(1, powderPackets.size());
|
||||
var packet = powderPackets.getFirst();
|
||||
assertEquals(1, powderPackets.stream().flatMap(p -> p.entries().stream())
|
||||
.filter(NativePowderSnowBookTest::powderEntry).filter(ClientboundRecipeBookAddPacket.Entry::notification).count(),
|
||||
"Exactly one new-discovery notification; recipe reloads also send native resets");
|
||||
var packet = powderPackets.getLast();
|
||||
var entry = packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).findFirst().orElseThrow();
|
||||
assertEquals(id, entry.contents().id());
|
||||
assertEquals(1, packet.entries().stream().filter(NativePowderSnowBookTest::powderEntry).count());
|
||||
assertEquals(RecipeBookCategories.CRAFTING_MISC, entry.contents().category());
|
||||
assertTrue(entry.notification());
|
||||
assertTrue(entry.highlight());
|
||||
assertTrue(powderPackets.getFirst().entries().stream().filter(NativePowderSnowBookTest::powderEntry)
|
||||
.findFirst().orElseThrow().notification());
|
||||
assertEquals(2, entry.contents().craftingRequirements().orElseThrow().size());
|
||||
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
|
||||
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
|
||||
try {
|
||||
ClientboundRecipeBookAddPacket.STREAM_CODEC.encode(buffer, packet);
|
||||
var decoded = ClientboundRecipeBookAddPacket.STREAM_CODEC.decode(buffer);
|
||||
assertEquals(packet, decoded);
|
||||
assertFalse(buffer.isReadable());
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
assertEquals(packet, NativeAnvilFrameGenerationTest.roundTrip(
|
||||
ClientboundRecipeBookAddPacket.STREAM_CODEC, packet));
|
||||
var powderEvents = f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY)).toList();
|
||||
assertEquals(1, powderEvents.size());
|
||||
assertTrue(powderEvents.getFirst().shouldShowNotification());
|
||||
@@ -103,6 +280,11 @@ class NativePowderSnowBookTest {
|
||||
void nativeKnownHighlightCodecJoinReconnectDisableAndActualReenableRetainUnrelatedState() throws Exception {
|
||||
try (var f = new NativePowderSnowFixture(directory, 3)) {
|
||||
var vanillaKey = NativePowderSnowFixture.nativeKey(VANILLA);
|
||||
var reinforcement = NativeReinforcementCraftTest.KEY;
|
||||
var reinforcedKey = NativePowderSnowFixture.nativeKey(reinforcement);
|
||||
assertTrue(f.book.contains(reinforcedKey));
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement)
|
||||
&& e.getPlayer() == f.actor.player).count());
|
||||
f.book.add(vanillaKey);
|
||||
f.book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
var joined = f.joiningPlayer(null);
|
||||
@@ -110,11 +292,15 @@ class NativePowderSnowBookTest {
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "join"));
|
||||
assertTrue(joined.book().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
assertTrue(joined.book().contains(vanillaKey));
|
||||
assertEquals(5, addedEntries(joined.sent()));
|
||||
assertTrue(joined.book().contains(reinforcedKey));
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement)
|
||||
&& e.getPlayer() == joined.player()).count());
|
||||
assertEquals(6, addedEntries(joined.sent()));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(joined.player(), "idempotent"));
|
||||
assertEquals(5, addedEntries(joined.sent()));
|
||||
assertEquals(6, addedEntries(joined.sent()));
|
||||
var packed = f.book.pack();
|
||||
assertTrue(packed.highlight().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
assertTrue(packed.highlight().contains(reinforcedKey));
|
||||
var nbt = ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, packed).getOrThrow();
|
||||
var decoded = ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, nbt).getOrThrow();
|
||||
var reconnected = f.joiningPlayer(decoded);
|
||||
@@ -123,6 +309,8 @@ class NativePowderSnowBookTest {
|
||||
assertTrue(reconnected.book().isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(reconnected.player(), "reconnected"));
|
||||
assertTrue(reconnected.sent().isEmpty(), "No second discovery/notification for known recipe");
|
||||
assertTrue(reconnected.book().contains(reinforcedKey));
|
||||
assertTrue(reconnected.book().contains(vanillaKey));
|
||||
f.actor.plugin.setEnabled(false);
|
||||
assertTrue(f.registered.isEmpty());
|
||||
assertPackedEquals(packed, f.book.pack());
|
||||
@@ -132,12 +320,19 @@ class NativePowderSnowBookTest {
|
||||
assertTrue(disabledJoiner.book().pack().known().isEmpty());
|
||||
doReturn(List.of(f.actor.player, reconnected.player())).when(f.actor.server).getOnlinePlayers();
|
||||
f.actor.plugin.setEnabled(true);
|
||||
assertEquals(5, f.registered.size());
|
||||
assertTrue(reconnected.sent().isEmpty());
|
||||
assertEquals(6, f.registered.size());
|
||||
assertTrue(reconnected.sent().stream().filter(ClientboundRecipeBookAddPacket.class::isInstance)
|
||||
.map(ClientboundRecipeBookAddPacket.class::cast).allMatch(ClientboundRecipeBookAddPacket::replace),
|
||||
"Known recipes are resynchronized, not rediscovered, during native recipe reload");
|
||||
assertPackedEquals(packed, f.book.pack());
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(KEY) && e.getPlayer() == f.actor.player).count());
|
||||
assertTrue(f.registered.containsKey(reinforcement));
|
||||
assertTrue(reconnected.book().contains(reinforcedKey));
|
||||
assertEquals(1, f.discoveries.stream().filter(e -> e.getRecipe().equals(reinforcement)
|
||||
&& e.getPlayer() == f.actor.player).count());
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(disabledJoiner.player(), "enabled"));
|
||||
assertTrue(disabledJoiner.book().contains(NativePowderSnowFixture.nativeKey(KEY)));
|
||||
assertTrue(disabledJoiner.book().contains(reinforcedKey));
|
||||
f.actor.realInventory.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.SNOW_BLOCK)));
|
||||
f.actor.realInventory.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(Material.BUCKET)));
|
||||
f.autofill(false);
|
||||
@@ -167,19 +362,19 @@ class NativePowderSnowBookTest {
|
||||
assertFalse(cancelled.book().contains(key));
|
||||
assertFalse(failed.book().contains(key));
|
||||
assertTrue(healthy.book().contains(key));
|
||||
assertEquals(4, cancelled.book().pack().known().size());
|
||||
assertEquals(4, failed.book().pack().known().size());
|
||||
assertEquals(5, healthy.book().pack().known().size());
|
||||
assertEquals(4, addedEntries(cancelled.sent()));
|
||||
assertEquals(5, cancelled.book().pack().known().size());
|
||||
assertEquals(5, failed.book().pack().known().size());
|
||||
assertEquals(6, healthy.book().pack().known().size());
|
||||
assertEquals(5, addedEntries(cancelled.sent()));
|
||||
cancel.set(false);
|
||||
doCallRealMethod().when(failed.player()).discoverRecipes(any());
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "retry"));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(failed.player(), "retry"));
|
||||
assertTrue(cancelled.book().contains(key));
|
||||
assertTrue(failed.book().contains(key));
|
||||
assertEquals(5, addedEntries(cancelled.sent()));
|
||||
assertEquals(6, addedEntries(cancelled.sent()));
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(cancelled.player(), "known"));
|
||||
assertEquals(5, addedEntries(cancelled.sent()));
|
||||
assertEquals(6, addedEntries(cancelled.sent()));
|
||||
assertEquals(0, NativePowderSnowFixture.count(f.actor.realInventory, Material.POWDER_SNOW_BUCKET));
|
||||
assertEquals(List.of(injected), f.actor.eventErrors, "Only the deliberately injected discovery failure is logged");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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;
|
||||
@@ -47,14 +46,25 @@ final class NativePowderSnowFixture implements AutoCloseable {
|
||||
final Map<NamespacedKey, RecipeHolder<CraftingRecipe>> holders = new HashMap<>();
|
||||
final List<PlayerRecipeDiscoverEvent> discoveries = new ArrayList<>();
|
||||
final AbstractCraftingMenu menu;
|
||||
NamespacedKey removalFailureKey;
|
||||
RuntimeException removalFailure;
|
||||
|
||||
NativePowderSnowFixture(Path directory, int width) throws Exception {
|
||||
actor = new NativeAnvilFixture(directory);
|
||||
this(directory, width, () -> 99);
|
||||
}
|
||||
|
||||
NativePowderSnowFixture(Path directory, int width, java.util.function.IntSupplier risk) throws Exception {
|
||||
this(directory, width, risk, () -> 1.0);
|
||||
}
|
||||
|
||||
NativePowderSnowFixture(Path directory, int width, java.util.function.IntSupplier risk,
|
||||
java.util.function.DoubleSupplier wear) throws Exception {
|
||||
actor = new NativeAnvilFixture(directory, risk, wear);
|
||||
packets = new NativeAnvilPacketFixture(actor);
|
||||
actor.events.registerEvent(PlayerRecipeDiscoverEvent.class, new Listener() {}, EventPriority.MONITOR,
|
||||
(ignored, event) -> discoveries.add((PlayerRecipeDiscoverEvent) event), actor.plugin, false);
|
||||
actor.plugin.setEnabled(false);
|
||||
manager = new RecipeManager(CraftRegistry.getMinecraftRegistry());
|
||||
manager = NativeRuntime.recipeManager();
|
||||
when(packets.nativeServer.getRecipeManager()).thenReturn(manager);
|
||||
when(actor.level.recipeAccess()).thenReturn(manager);
|
||||
when(actor.level.getGameRules()).thenReturn(
|
||||
@@ -80,6 +90,7 @@ final class NativePowderSnowFixture implements AutoCloseable {
|
||||
});
|
||||
when(actor.server.removeRecipe(any(NamespacedKey.class))).thenAnswer(inv -> {
|
||||
NamespacedKey key = inv.getArgument(0);
|
||||
if (key.equals(removalFailureKey) && removalFailure != null) throw removalFailure;
|
||||
holders.remove(key);
|
||||
refreshManager();
|
||||
return registered.remove(key) != null;
|
||||
@@ -87,8 +98,8 @@ final class NativePowderSnowFixture implements AutoCloseable {
|
||||
doReturn(List.of(actor.player)).when(actor.server).getOnlinePlayers();
|
||||
actor.plugin.setEnabled(true);
|
||||
assertTrue(book.contains(nativeKey(NativePowderSnowRecipeTest.KEY)));
|
||||
assertEquals(5, registered.size());
|
||||
assertEquals(5, discoveries.size());
|
||||
assertEquals(6, registered.size());
|
||||
assertEquals(6, discoveries.size());
|
||||
assertEquals(0, count(actor.realInventory, org.bukkit.Material.POWDER_SNOW_BUCKET));
|
||||
if (width == 2) {
|
||||
menu = packets.inventoryMenu;
|
||||
@@ -158,8 +169,20 @@ final class NativePowderSnowFixture implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void refreshManager() {
|
||||
manager.recipes = RecipeMap.create(new ArrayList<RecipeHolder<?>>(holders.values()));
|
||||
manager.recipes = RecipeMap.createEmpty();
|
||||
holders.values().forEach(manager.recipes::addRecipe);
|
||||
manager.finalizeRecipeLoading(FeatureFlags.VANILLA_SET);
|
||||
// Pinned CraftServer.add/removeRecipe calls PlayerList.reloadRecipes after finalizing.
|
||||
// Run that real native book synchronization at the actor/network boundary too: later
|
||||
// registrations can renumber displays without changing discovered resource keys.
|
||||
for (var online : actor.server.getOnlinePlayers()) {
|
||||
if (!(online instanceof org.bukkit.craftbukkit.entity.CraftPlayer craft))
|
||||
throw new AssertionError("Expected typed native recipe-book player port");
|
||||
var nativePlayer = craft.getHandle();
|
||||
nativePlayer.connection.send(new net.minecraft.network.protocol.game.ClientboundUpdateRecipesPacket(
|
||||
manager.getSynchronizedItemProperties(), manager.getSynchronizedStonecutterRecipes()));
|
||||
nativePlayer.getRecipeBook().sendInitialRecipeBook(nativePlayer);
|
||||
}
|
||||
}
|
||||
|
||||
private static CraftingRecipe convert(Recipe recipe) {
|
||||
|
||||
+189
-4
@@ -3,6 +3,7 @@ package games.dmg.spigotinventoryhelper;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -12,6 +13,8 @@ import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
|
||||
@@ -35,6 +38,7 @@ import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.HandlerList;
|
||||
@@ -91,9 +95,9 @@ class NativePowderSnowPluginWiringTest {
|
||||
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.getVersion()).thenReturn("Purpur 2651 (MC: 26.3)");
|
||||
when(serverUnderTest.getMinecraftVersion()).thenReturn("26.3");
|
||||
when(serverUnderTest.getBukkitVersion()).thenReturn("26.3.build.2651-stable");
|
||||
when(serverUnderTest.getWarningState()).thenReturn(org.bukkit.Warning.WarningState.DEFAULT);
|
||||
logger = Logger.getAnonymousLogger();
|
||||
logs = new ArrayList<>();
|
||||
@@ -156,12 +160,17 @@ class NativePowderSnowPluginWiringTest {
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(POWDER));
|
||||
assertEquals(expectedKnown(), onlineKnown);
|
||||
assertInstanceOf(org.bukkit.inventory.ShapelessRecipe.class, registry.get(NativeReinforcementCraftTest.KEY));
|
||||
assertTrue(onlineKnown.contains(NativeReinforcementCraftTest.KEY));
|
||||
verify(online, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY));
|
||||
plugin.setEnabled(true);
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined"));
|
||||
events.callEvent(new PlayerJoinEvent(joining, "joined again"));
|
||||
assertEquals(expectedKnown(), joiningKnown);
|
||||
assertEquals(1, onlineGrants.get());
|
||||
assertEquals(1, joiningGrants.get());
|
||||
assertTrue(joiningKnown.contains(NativeReinforcementCraftTest.KEY));
|
||||
verify(joining, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY));
|
||||
|
||||
plugin.setEnabled(false);
|
||||
assertEquals(Set.of(VANILLA), registry.keySet());
|
||||
@@ -177,6 +186,10 @@ class NativePowderSnowPluginWiringTest {
|
||||
assertTrue(registry.keySet().containsAll(EXISTING));
|
||||
assertTrue(registry.containsKey(POWDER));
|
||||
assertEquals(1, onlineGrants.get(), "Re-enable must not rediscover a known recipe");
|
||||
assertTrue(registry.containsKey(NativeReinforcementCraftTest.KEY));
|
||||
assertTrue(onlineKnown.contains(NativeReinforcementCraftTest.KEY));
|
||||
assertTrue(onlineKnown.contains(VANILLA));
|
||||
verify(online, times(1)).discoverRecipes(List.of(NativeReinforcementCraftTest.KEY));
|
||||
assertNoSevereLogs();
|
||||
} finally {
|
||||
plugin.setEnabled(false);
|
||||
@@ -454,12 +467,183 @@ class NativePowderSnowPluginWiringTest {
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"craft", "combine"})
|
||||
void actualDisableClosesAdvancedOwnersDespiteThrowingOwnedRecipeRemovalAndCanExplicitlyRetry(String pendingKind)
|
||||
throws Exception {
|
||||
var rolls = new AtomicInteger();
|
||||
try (var f = new NativePowderSnowFixture(directory, 2, () -> { rolls.incrementAndGet(); return 99; })) {
|
||||
var effects = new NativeAnvilEffectsFixture(f.actor);
|
||||
var foreignKey = new NamespacedKey("acceptance", "foreign_recipe");
|
||||
var foreignRecipe = new org.bukkit.inventory.ShapelessRecipe(foreignKey,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.EMERALD));
|
||||
foreignRecipe.addIngredient(org.bukkit.Material.DIRT);
|
||||
assertTrue(f.actor.server.addRecipe(foreignRecipe));
|
||||
Listener foreignListener = new Listener() {};
|
||||
var foreignJoins = new AtomicInteger();
|
||||
f.actor.events.registerEvent(PlayerJoinEvent.class, foreignListener, org.bukkit.event.EventPriority.MONITOR,
|
||||
(ignored, event) -> foreignJoins.incrementAndGet(), f.actor.plugin, false);
|
||||
var foreignProtocol = new com.comphenix.protocol.events.PacketAdapter(f.actor.plugin,
|
||||
com.comphenix.protocol.events.ListenerPriority.NORMAL,
|
||||
com.comphenix.protocol.PacketType.Play.Client.CHAT) {};
|
||||
f.actor.protocol.addPacketListener(foreignProtocol);
|
||||
f.actor.tick();
|
||||
var settingsField = SpigotInventoryHelperPlugin.class.getDeclaredField("advancedAnvilSettings");
|
||||
assertEquals(AdvancedAnvilSettings.class, settingsField.getType(), "Inspected typed settings owner");
|
||||
settingsField.setAccessible(true);
|
||||
var settings = org.junit.jupiter.api.Assertions.assertInstanceOf(AdvancedAnvilSettings.class,
|
||||
settingsField.get(f.actor.plugin));
|
||||
var initialPolicy = settings.policy();
|
||||
String base = "advanced-anvil-base-failure-percent";
|
||||
f.actor.admin(f.actor.console, base, 37);
|
||||
Runnable acknowledgment = f.actor.nextTick.poll(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(acknowledgment, "Real save must enqueue its main-thread acknowledgment");
|
||||
assertTrue(java.nio.file.Files.readString(f.actor.plugin.getDataFolder().toPath().resolve("config.yml"))
|
||||
.contains(base + ": 37"), "Retain real durable-save proof before disable");
|
||||
assertEquals(initialPolicy, settings.policy());
|
||||
assertTrue(f.actor.adminMessages.isEmpty());
|
||||
if (pendingKind.equals("craft")) {
|
||||
f.menu.craftSlots.setItem(0, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(org.bukkit.Material.CHIPPED_ANVIL, 3)));
|
||||
f.menu.craftSlots.setItem(1, CraftItemStack.asNMSCopy(new org.bukkit.inventory.ItemStack(org.bukkit.Material.NETHERITE_BLOCK, 3)));
|
||||
} else {
|
||||
f.actor.events.callEvent(new org.bukkit.event.inventory.InventoryCloseEvent(f.menu.getBukkitView()));
|
||||
f.actor.nativePlayer.containerMenu = f.actor.menu;
|
||||
f.actor.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(5, 3),
|
||||
NativeAdvancedAnvilTransactionTest.stackedSword(5, 3));
|
||||
}
|
||||
var unrelated = new org.bukkit.inventory.ItemStack(org.bukkit.Material.EMERALD, 7);
|
||||
unrelated.editMeta(meta -> meta.displayName(net.kyori.adventure.text.Component.text("Unrelated inventory")));
|
||||
f.actor.realInventory.setItem(9, CraftItemStack.asNMSCopy(unrelated));
|
||||
var possessionsBeforeClose = shutdownPossessions(f);
|
||||
f.packets.click(pendingKind.equals("craft") ? 0 : 2, 0, net.minecraft.world.inventory.ContainerInput.PICKUP);
|
||||
assertFalse(f.packets.received.getLast().isCancelled());
|
||||
assertFalse(f.packets.clicks.getLast().isCancelled(), "Actual native intent admitted before disable");
|
||||
if (pendingKind.equals("craft"))
|
||||
org.junit.jupiter.api.Assertions.assertInstanceOf(org.bukkit.event.inventory.CraftItemEvent.class, f.packets.clicks.getLast());
|
||||
assertFalse(f.actor.nextTick.isEmpty(), "Native admitted intent has queued payment");
|
||||
assertEquals(0, rolls.get());
|
||||
var ownedRecipe = f.registered.get(NativeReinforcementCraftTest.KEY);
|
||||
assertNotNull(ownedRecipe);
|
||||
var injected = new IllegalStateException("injected owned reinforcement removal failure");
|
||||
f.removalFailureKey = NativeReinforcementCraftTest.KEY;
|
||||
f.removalFailure = injected;
|
||||
long removalsBefore = org.mockito.Mockito.mockingDetails(f.actor.server).getInvocations().stream()
|
||||
.filter(inv -> inv.getMethod().getName().equals("removeRecipe")
|
||||
&& NativeReinforcementCraftTest.KEY.equals(inv.getArgument(0))).count();
|
||||
try {
|
||||
// Actual production callback, not individual owners' close methods. Calling the
|
||||
// callback directly also prevents a false GREEN from isEnabled=false alone;
|
||||
// listener/protocol/settings closure must suppress late work in its own right.
|
||||
try {
|
||||
f.actor.plugin.onDisable();
|
||||
} catch (IllegalStateException failure) {
|
||||
assertSame(injected, failure); // Propagating or reporting this port error is not the tested contract.
|
||||
}
|
||||
assertEquals(removalsBefore + 1, org.mockito.Mockito.mockingDetails(f.actor.server).getInvocations().stream()
|
||||
.filter(inv -> inv.getMethod().getName().equals("removeRecipe")
|
||||
&& NativeReinforcementCraftTest.KEY.equals(inv.getArgument(0))).count(),
|
||||
"Actual callback reaches the throwing owned-removal port");
|
||||
// US-010 closes the exact owned native window: removed() returns unconsumed
|
||||
// inputs to real inventory. Unchanged source-slot positions are not a no-debit oracle.
|
||||
assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu);
|
||||
assertTrue(f.menu.craftSlots.getItems().stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty),
|
||||
"Actual native close clears the old crafting matrix");
|
||||
assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty());
|
||||
assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty());
|
||||
assertEquals(possessionsBeforeClose, shutdownPossessions(f),
|
||||
"Native close conserves every input quantity and its metadata");
|
||||
// A production callback may log the deliberate fault instead of propagating it.
|
||||
// Qualify ONLY that exact injected object; all other listener/setup errors still fail.
|
||||
f.actor.eventErrors.removeIf(failure -> failure == injected);
|
||||
var remaining = HandlerList.getRegisteredListeners(f.actor.plugin).stream()
|
||||
.map(org.bukkit.plugin.RegisteredListener::getListener).toList();
|
||||
f.actor.tick(); // Run actual queued payment only via the existing scheduler port.
|
||||
f.actor.tick();
|
||||
acknowledgment.run();
|
||||
org.junit.jupiter.api.Assertions.assertAll("Independent shutdown remains fail-closed after recipe removal failure",
|
||||
() -> assertFalse(remaining.stream().anyMatch(listener -> listener instanceof AdvancedAnvilListener
|
||||
|| listener instanceof ReinforcementCrafting || listener instanceof ReinforcementState),
|
||||
"RED: throwing recipe cleanup must not strand advanced native listeners"),
|
||||
() -> assertEquals(List.of(foreignProtocol), f.actor.protocolListeners,
|
||||
"Owned protocol listener is closed; unrelated registration survives"),
|
||||
() -> assertTrue(remaining.contains(foreignListener)),
|
||||
() -> assertSame(foreignRecipe, f.registered.get(foreignKey)),
|
||||
() -> assertSame(ownedRecipe, f.registered.get(NativeReinforcementCraftTest.KEY),
|
||||
"Unremoved recipe ownership is retained honestly for explicit retry"),
|
||||
() -> assertEquals(initialPolicy, settings.policy(), "No late saved-policy publication"),
|
||||
() -> assertTrue(f.actor.adminMessages.isEmpty(), "No late saved-success feedback"),
|
||||
() -> assertEquals(0, rolls.get(), "No late admitted payment/roll after actual onDisable"),
|
||||
() -> assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu),
|
||||
() -> assertTrue(f.menu.craftSlots.getItems().stream().allMatch(net.minecraft.world.item.ItemStack::isEmpty)),
|
||||
() -> assertTrue(f.actor.menu.getSlot(0).getItem().isEmpty()),
|
||||
() -> assertTrue(f.actor.menu.getSlot(1).getItem().isEmpty()),
|
||||
() -> assertEquals(possessionsBeforeClose, shutdownPossessions(f),
|
||||
"Late work cannot debit, duplicate or alter returned input metadata"),
|
||||
() -> assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9))),
|
||||
() -> assertTrue(f.menu.getCarried().isEmpty()),
|
||||
() -> assertTrue(f.actor.menu.getCarried().isEmpty()),
|
||||
() -> assertTrue(f.packets.drops.isEmpty()),
|
||||
() -> assertTrue(effects.explosions.isEmpty()));
|
||||
var command = f.actor.plugin.getCommand("inventoryhelperadmin");
|
||||
assertNotNull(command);
|
||||
assertEquals(List.of(), command.tabComplete(f.actor.console, "inventoryhelperadmin", new String[] {"config", ""}),
|
||||
"Closed settings cannot offer live commands");
|
||||
assertTrue(command.execute(f.actor.console, "inventoryhelperadmin", new String[] {"config", base}));
|
||||
assertEquals(List.of("Configuration unavailable."), f.actor.adminMessages);
|
||||
f.actor.events.callEvent(new PlayerJoinEvent(f.actor.player, "unrelated listener probe"));
|
||||
assertEquals(1, foreignJoins.get());
|
||||
|
||||
// RecipeLifecycle has disable(), not close(), and retains owned registrar state
|
||||
// on a throwing removal. Require only an EXPLICIT same-callback cleanup retry,
|
||||
// never an automatic setEnabled(false) retry or an invented retry scheduler.
|
||||
f.removalFailure = null;
|
||||
assertDoesNotThrow(f.actor.plugin::onDisable);
|
||||
assertEquals(Set.of(foreignKey), f.registered.keySet());
|
||||
assertSame(foreignRecipe, f.registered.get(foreignKey));
|
||||
assertEquals(List.of(foreignProtocol), f.actor.protocolListeners);
|
||||
assertDoesNotThrow(f.actor.plugin::onDisable, "Successful cleanup is idempotent");
|
||||
assertSame(f.actor.nativePlayer.inventoryMenu, f.actor.nativePlayer.containerMenu);
|
||||
assertEquals(possessionsBeforeClose, shutdownPossessions(f), "Retry cannot return inputs twice");
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(f.actor.realInventory.getItem(9)));
|
||||
assertEquals(0, rolls.get());
|
||||
assertEquals(initialPolicy, settings.policy());
|
||||
f.actor.assertNoEventErrors();
|
||||
} finally {
|
||||
f.removalFailure = null;
|
||||
f.actor.plugin.onDisable(); // Recover fixture ownership without discarding the behavioral RED.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<org.bukkit.inventory.ItemStack, Integer> shutdownPossessions(NativePowderSnowFixture f) {
|
||||
var stacks = new ArrayList<net.minecraft.world.item.ItemStack>();
|
||||
stacks.addAll(f.actor.realInventory.getContents());
|
||||
stacks.addAll(f.menu.craftSlots.getItems());
|
||||
// Only authoritative inputs: crafting/anvil preview result slots are not possessions.
|
||||
stacks.add(f.actor.menu.getSlot(0).getItem());
|
||||
stacks.add(f.actor.menu.getSlot(1).getItem());
|
||||
stacks.add(f.menu.getCarried());
|
||||
stacks.add(f.actor.menu.getCarried());
|
||||
var active = f.actor.nativePlayer.containerMenu;
|
||||
if (active != f.menu && active != f.actor.menu) stacks.add(active.getCarried());
|
||||
stacks.addAll(f.packets.drops); // The fixture's actual native drop port, not Bukkit previews.
|
||||
Map<org.bukkit.inventory.ItemStack, Integer> counts = new HashMap<>();
|
||||
for (var stack : stacks) {
|
||||
if (stack.isEmpty()) continue;
|
||||
var normalized = CraftItemStack.asBukkitCopy(stack);
|
||||
int quantity = normalized.getAmount();
|
||||
normalized.setAmount(1); // Detached keys retain all metadata; amounts accumulate once per source.
|
||||
counts.merge(normalized, quantity, Integer::sum);
|
||||
}
|
||||
return Map.copyOf(counts);
|
||||
}
|
||||
|
||||
private SpigotInventoryHelperPlugin plugin() {
|
||||
try {
|
||||
createdPlugin = new SpigotInventoryHelperPlugin(new JavaPluginLoader(serverUnderTest),
|
||||
NativeAnvilFixture.description(), directory.resolve("data").toFile(),
|
||||
directory.resolve("plugin.jar").toFile(), () -> 99, () -> 1, protocol,
|
||||
() -> "Purpur 26.2.build.2618-stable");
|
||||
() -> "Purpur 26.3.build.2651-stable");
|
||||
commands.registerAll(createdPlugin.getName(),
|
||||
org.bukkit.command.PluginCommandYamlParser.parse(createdPlugin));
|
||||
createdPlugin.getLogger().addHandler(logHandler);
|
||||
@@ -497,6 +681,7 @@ class NativePowderSnowPluginWiringTest {
|
||||
private static Set<NamespacedKey> expectedKnown() {
|
||||
Set<NamespacedKey> expected = new LinkedHashSet<>(EXISTING);
|
||||
expected.add(POWDER);
|
||||
expected.add(NativeReinforcementCraftTest.KEY); // Approved additional independently discoverable recipe.
|
||||
expected.add(VANILLA);
|
||||
return expected;
|
||||
}
|
||||
|
||||
+2636
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,7 @@ import org.bukkit.craftbukkit.CraftRegistry;
|
||||
/** Loads vanilla registries, tags, and components using the server test bootstrap path. */
|
||||
final class NativeRuntime {
|
||||
private static boolean bootstrapped;
|
||||
private static HolderLookup.Provider reloadableRegistries;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
@@ -47,12 +48,12 @@ final class NativeRuntime {
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(
|
||||
resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(
|
||||
layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
layers.getAccessForLoading(RegistryLayer.WORLD), tags);
|
||||
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
@@ -61,7 +62,15 @@ final class NativeRuntime {
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
reloadableRegistries = datapack.fullRegistries().lookup();
|
||||
bootstrapped = true;
|
||||
}
|
||||
}
|
||||
|
||||
static net.minecraft.world.item.crafting.RecipeManager recipeManager() {
|
||||
if (!bootstrapped) {
|
||||
throw new IllegalStateException("Native runtime has not been bootstrapped");
|
||||
}
|
||||
return new net.minecraft.world.item.crafting.RecipeManager(reloadableRegistries);
|
||||
}
|
||||
}
|
||||
@@ -116,13 +116,28 @@ final class AdvancedAnvilSettingsTest {
|
||||
assertEquals(List.of(BASE + " = 20"), fixture.messages);
|
||||
fixture.messages.clear();
|
||||
callbacks.getLast().run();
|
||||
// A saved bundle is not publication authority for its neighboring keys.
|
||||
AdvancedAnvilPolicy baseOnly = new AdvancedAnvilPolicy(42, 5, 99);
|
||||
AdvancedAnvilPolicy withStep = new AdvancedAnvilPolicy(42, 7, 99);
|
||||
AdvancedAnvilPolicy expected = new AdvancedAnvilPolicy(42, 7, 100);
|
||||
assertEquals(expected, settings.policy());
|
||||
for (Runnable callback : callbacks) {
|
||||
callback.run(); // Older and repeated acknowledgments cannot regress or duplicate publication.
|
||||
}
|
||||
assertEquals(List.of(expected), changes);
|
||||
assertEquals(baseOnly, settings.policy());
|
||||
assertEquals(List.of(baseOnly), changes);
|
||||
assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages);
|
||||
callbacks.getFirst().run(); // Superseded BASE=31 cannot regress BASE=42.
|
||||
assertEquals(baseOnly, settings.policy());
|
||||
assertEquals(List.of(baseOnly), changes);
|
||||
assertEquals(List.of(BASE + " = 42 (saved)"), fixture.messages);
|
||||
callbacks.get(1).run(); // STEP needs its own current acknowledgment.
|
||||
assertEquals(withStep, settings.policy());
|
||||
assertEquals(List.of(baseOnly, withStep), changes);
|
||||
assertEquals(List.of(BASE + " = 42 (saved)", STEP + " = 7 (saved)"), fixture.messages);
|
||||
callbacks.get(2).run(); // CAP likewise publishes only its own key.
|
||||
assertEquals(expected, settings.policy());
|
||||
callbacks.getLast().run(); // Repeated BASE=42 cannot duplicate publication.
|
||||
assertEquals(expected, settings.policy());
|
||||
assertEquals(List.of(baseOnly, withStep, expected), changes);
|
||||
assertEquals(List.of(BASE + " = 42 (saved)", STEP + " = 7 (saved)",
|
||||
CAP + " = 100 (saved)"), fixture.messages);
|
||||
try (AdvancedAnvilSettings restarted = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(expected, restarted.policy());
|
||||
}
|
||||
@@ -267,7 +282,12 @@ final class AdvancedAnvilSettingsTest {
|
||||
assertEquals(List.of("config"), fixture.complete(""));
|
||||
assertEquals(List.of("config"), fixture.complete("con"));
|
||||
assertEquals(List.of(), fixture.complete("bad"));
|
||||
assertEquals(List.of(BASE, STEP, CAP), fixture.complete("config", ""));
|
||||
assertEquals(List.of(BASE, STEP, CAP,
|
||||
"advanced-anvil-reinforcement-failure-1-percent",
|
||||
"advanced-anvil-reinforcement-failure-2-percent",
|
||||
"advanced-anvil-reinforcement-failure-3-percent",
|
||||
"advanced-anvil-reinforcement-failure-4-percent",
|
||||
"advanced-anvil-reinforcement-failure-5-percent"), fixture.complete("config", ""));
|
||||
assertEquals(List.of(STEP), fixture.complete("config", "advanced-anvil-risk"));
|
||||
for (String key : List.of(BASE, STEP, CAP)) {
|
||||
String defaultValue = key.equals(BASE) ? "20" : key.equals(STEP) ? "5" : "99";
|
||||
@@ -294,18 +314,92 @@ final class AdvancedAnvilSettingsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionRevokedBeforeAcknowledgmentDoesNotLeakSavedValue() throws Exception {
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {BASE, STEP, CAP})
|
||||
void permissionRevokedBeforeExistingKeyAcknowledgmentRetainsPriorLivePolicy(String key) throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", BASE, "37");
|
||||
fixture.execute("config", key, "37");
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
// Retain the real save proof: denial invalidates runtime authority, not disk history.
|
||||
try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(key.equals(BASE) ? new AdvancedAnvilPolicy(37, 5, 99)
|
||||
: key.equals(STEP) ? new AdvancedAnvilPolicy(20, 37, 99)
|
||||
: new AdvancedAnvilPolicy(20, 5, 37), loaded.policy());
|
||||
}
|
||||
when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false);
|
||||
acknowledgment.run();
|
||||
assertEquals(37, settings.policy().basePercent());
|
||||
assertEquals(1, changes.size());
|
||||
acknowledgment.run();
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy(),
|
||||
"RED: unauthorized acknowledgment must not publish even an existing combine key");
|
||||
assertTrue(changes.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty(), "No success feedback to revoked sender");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
BASE + ",denied", BASE + ",disconnected", BASE + ",disabled",
|
||||
STEP + ",denied", STEP + ",disconnected", STEP + ",disabled",
|
||||
CAP + ",denied", CAP + ",disconnected", CAP + ",disabled"})
|
||||
void existingCombineKeyPlayerCallbacksRequireCurrentPermissionConnectionAndPluginLifetime(
|
||||
String key, String lostAuthority) throws Exception {
|
||||
Player player = mock(Player.class);
|
||||
Fixture fixture = new Fixture(directory, player);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
assertTrue(player.isOnline());
|
||||
fixture.execute("config", key, "37"); // Actual bound PluginCommand, not executor/policy injection.
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
AdvancedAnvilPolicy saved = key.equals(BASE) ? new AdvancedAnvilPolicy(37, 5, 99)
|
||||
: key.equals(STEP) ? new AdvancedAnvilPolicy(20, 37, 99)
|
||||
: new AdvancedAnvilPolicy(20, 5, 37);
|
||||
try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(saved, loaded.policy(), "Queued callback follows the real asynchronous durable save");
|
||||
}
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
assertTrue(changes.isEmpty());
|
||||
assertTrue(fixture.messages.isEmpty());
|
||||
switch (lostAuthority) {
|
||||
case "denied" -> when(player.hasPermission(PERMISSION)).thenReturn(false);
|
||||
case "disconnected" -> when(player.isOnline()).thenReturn(false);
|
||||
case "disabled" -> when(fixture.plugin.isEnabled()).thenReturn(false);
|
||||
default -> fail("Unknown lifetime boundary " + lostAuthority);
|
||||
}
|
||||
acknowledgment.run();
|
||||
acknowledgment.run();
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy(),
|
||||
"RED: stale existing-key callback retains prior live policy after " + lostAuthority);
|
||||
assertTrue(changes.isEmpty(), "No gameplay publication after authority/lifetime loss");
|
||||
assertTrue(fixture.messages.isEmpty(), "No success feedback after authority/lifetime loss");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void registeredReinforcementPolicyAbaPublishesBothDurablyAcknowledgedChanges() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
String key = "advanced-anvil-reinforcement-failure-1-percent";
|
||||
AdvancedAnvilPolicy initial = AdvancedAnvilPolicy.defaults();
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
for (int percent : new int[] {21, 20}) {
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", key, Integer.toString(percent));
|
||||
Runnable acknowledgment = fixture.nextAcknowledgment();
|
||||
assertEquals(changes.isEmpty() ? initial : changes.getLast(), settings.policy());
|
||||
assertTrue(fixture.messages.isEmpty(), "No publication feedback before durable acknowledgment");
|
||||
try (AdvancedAnvilSettings loaded = new Fixture(directory).open(ignored -> {})) {
|
||||
assertEquals(percent, loaded.policy().reinforcementFailure(0));
|
||||
}
|
||||
acknowledgment.run();
|
||||
assertEquals(percent, settings.policy().reinforcementFailure(0));
|
||||
assertEquals(List.of(key + " = " + percent + " (saved)"), fixture.messages);
|
||||
}
|
||||
assertEquals(List.of(new AdvancedAnvilPolicy(20, 5, 99, List.of(21, 40, 60, 80, 99)), initial), changes,
|
||||
"A→B→A is two real authorized policy publications, not an equality-only no-op");
|
||||
assertEquals(initial, settings.policy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +429,80 @@ final class AdvancedAnvilSettingsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reinforcementKeysReadDefaultTableAndPersistValidatedIndividualUpdates() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings settings = fixture.open(ignored -> {})) {
|
||||
assertEquals(AdvancedAnvilPolicy.defaults(), settings.policy());
|
||||
int[] defaults = {20, 40, 60, 80, 99};
|
||||
for (int tier = 1; tier <= 5; tier++) {
|
||||
String key = "advanced-anvil-reinforcement-failure-" + tier + "-percent";
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", key);
|
||||
assertEquals(List.of(key + " = " + defaults[tier - 1]), fixture.messages);
|
||||
assertTrue(fixture.complete("config", "").contains(key));
|
||||
assertEquals(List.of(Integer.toString(defaults[tier - 1]), "0", "100"),
|
||||
fixture.complete("config", key, ""));
|
||||
}
|
||||
String key = "advanced-anvil-reinforcement-failure-3-percent";
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", key, "65");
|
||||
Runnable ack = fixture.nextAcknowledgment();
|
||||
fixture.execute("config", key);
|
||||
assertEquals(List.of(key + " = 60"), fixture.messages);
|
||||
fixture.messages.clear();
|
||||
ack.run();
|
||||
assertEquals(List.of(key + " = 65 (saved)"), fixture.messages);
|
||||
fixture.messages.clear();
|
||||
fixture.execute("config", key, "81");
|
||||
fixture.nextAcknowledgment().run();
|
||||
fixture.execute("config", key);
|
||||
assertTrue(fixture.messages.contains(key + " = 65"));
|
||||
assertFalse(fixture.messages.stream().anyMatch(m -> m.contains("81 (saved)")));
|
||||
assertTrue(Files.readString(directory.resolve("config.yml")).contains(key + ": 65"));
|
||||
}
|
||||
Fixture restart = new Fixture(directory);
|
||||
try (AdvancedAnvilSettings loaded = restart.open(policy -> {})) {
|
||||
assertNotNull(loaded.policy());
|
||||
restart.execute("config", "advanced-anvil-reinforcement-failure-3-percent");
|
||||
assertEquals(List.of("advanced-anvil-reinforcement-failure-3-percent = 65"), restart.messages);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reinforcementAcknowledgmentsCannotPublishDeniedNeighborOrChangeWithoutCurrentPermission() throws Exception {
|
||||
Fixture fixture = new Fixture(directory);
|
||||
List<AdvancedAnvilPolicy> changes = new ArrayList<>();
|
||||
String first = "advanced-anvil-reinforcement-failure-1-percent";
|
||||
String second = "advanced-anvil-reinforcement-failure-2-percent";
|
||||
try (AdvancedAnvilSettings settings = fixture.open(changes::add)) {
|
||||
fixture.execute("config", first, "30");
|
||||
Runnable denied = fixture.nextAcknowledgment();
|
||||
when(fixture.sender.hasPermission(PERMISSION)).thenReturn(false);
|
||||
denied.run();
|
||||
assertEquals(20, settings.policy().reinforcementFailure(0));
|
||||
assertTrue(changes.isEmpty());
|
||||
when(fixture.sender.hasPermission(PERMISSION)).thenReturn(true);
|
||||
fixture.execute("config", second, "50");
|
||||
fixture.nextAcknowledgment().run();
|
||||
assertEquals(20, settings.policy().reinforcementFailure(0), "Only the acknowledged key may publish");
|
||||
assertEquals(50, settings.policy().reinforcementFailure(1));
|
||||
assertEquals(1, changes.size());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"-1", "101", "20.0", "'20'", "true", "null"})
|
||||
void malformedReinforcementConfigurationFailsClosedForEveryTier(String raw) throws Exception {
|
||||
for (int tier = 1; tier <= 5; tier++) {
|
||||
String key = "advanced-anvil-reinforcement-failure-" + tier + "-percent";
|
||||
Files.writeString(directory.resolve("config.yml"), key + ": " + raw + "\n");
|
||||
assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> fail("Partial installation")));
|
||||
}
|
||||
Files.writeString(directory.resolve("config.yml"), "advanced-anvil-reinforcement-failure-3-percent: 81\n");
|
||||
assertThrows(IOException.class, () -> new Fixture(directory).open(ignored -> {}));
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final Plugin plugin = mock(Plugin.class);
|
||||
private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
@@ -357,6 +525,7 @@ final class AdvancedAnvilSettingsTest {
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(sender.hasPermission(PERMISSION)).thenReturn(true);
|
||||
if (sender instanceof Player player) when(player.isOnline()).thenReturn(true);
|
||||
doAnswer(call -> {
|
||||
assertSame(mainThread, Thread.currentThread(), "Feedback must stay on main thread");
|
||||
messages.add(call.getArgument(0, String.class));
|
||||
|
||||
@@ -20,6 +20,7 @@ class PurpurDistributionTest {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotInventoryHelper", metadata.get("name"), "Retain the data directory and persistent namespace");
|
||||
assertEquals("1.20", metadata.get("api-version"));
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||
assertEquals("games.dmg.spigotinventoryhelper.SpigotInventoryHelperPlugin", metadata.get("main"));
|
||||
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
|
||||
@@ -30,7 +31,12 @@ class PurpurDistributionTest {
|
||||
assertEquals("spigotinventoryhelper.admin", ((Map<?, ?>) commands.get("inventoryhelperadmin")).get("permission"));
|
||||
Map<?, ?> config = new Yaml().load(jar.getInputStream(jar.getJarEntry("config.yml")));
|
||||
assertEquals(Map.of("advanced-anvil-base-failure-percent", 20,
|
||||
"advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99), config);
|
||||
"advanced-anvil-risk-step-percent", 5, "advanced-anvil-max-failure-percent", 99,
|
||||
"advanced-anvil-reinforcement-failure-1-percent", 20,
|
||||
"advanced-anvil-reinforcement-failure-2-percent", 40,
|
||||
"advanced-anvil-reinforcement-failure-3-percent", 60,
|
||||
"advanced-anvil-reinforcement-failure-4-percent", 80,
|
||||
"advanced-anvil-reinforcement-failure-5-percent", 99), config);
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/PowderSnowRecipeRegistrar.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilListener.class"));
|
||||
assertNotNull(jar.getJarEntry("games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.class"));
|
||||
@@ -63,7 +69,9 @@ class PurpurDistributionTest {
|
||||
assertTrue(readme.contains("Dropper + Tripwire Hook"));
|
||||
assertTrue(readme.contains("Redstone category"));
|
||||
assertTrue(readme.contains("CraftServer, CraftPlayer, and client boundaries are doubled"));
|
||||
assertTrue(readme.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
assertTrue(readme.contains("Purpur 26.3 build 2651"));
|
||||
assertTrue(readme.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
|
||||
assertFalse(readme.contains("26.2") || readme.contains("2618"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,14 +86,20 @@ class PurpurDistributionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
void releaseAndCiAgreeWithThePurpur26_3DistributionAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
|
||||
assertTrue(build.contains("purpur-26.3-2651.jar"));
|
||||
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
|
||||
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
|
||||
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertFalse(build.contains("26.2") || build.contains("2618"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
assertTrue(build.contains("nativeInventoryTest"));
|
||||
assertTrue(build.contains("tasks.check { dependsOn(nativeInventoryTest) }"));
|
||||
assertTrue(build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
|
||||
String guard = Files.readString(Path.of("src/main/java/games/dmg/spigotinventoryhelper/AdvancedAnvilProtocolGuard.java"));
|
||||
assertTrue(guard.contains("26.3.build.2651-stable"));
|
||||
assertFalse(guard.contains("26.2") || guard.contains("2618"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
|
||||
Reference in new issue
Block a user