Compare commits

...
1 Commits
Author SHA1 Message Date
dmg fcf93fa966 feat(crafting): support cancellable Q and Ctrl-Q delivery
Release / release (push) Successful in 5m26s
CI / build (push) Successful in 2m56s
2026-09-18 07:37:14 -04:00
8 changed files with 625 additions and 3 deletions

No files matched your search

+8
View File
@@ -63,6 +63,14 @@ Nine native delivery tests cover normal/right-click, bulk and exact-slot deliver
The caller still must contain vanilla consumption, validate a one-use lifecycle/attempt token, schedule on the server thread, pass the player's actual current view and synchronize the appropriate client menu afterward. World-drop plans return `UNSUPPORTED_ROUTE` unchanged in this component; that is not a registered gameplay policy. No completed-craft listener or recipe is exposed yet.
## Q/Ctrl-Q craft delivery (not registered)
Q/Ctrl-Q remain supported scope; no shortcut restriction was accepted or enabled. `ManualDropDelivery` executes saved drop plans one unit at a time, checking original contents minus completed debits before every next unit. `PlayerSheetDrops` creates an unspawned item, dispatches `PlayerDropItemEvent`, revalidates, and uses native `Item.spawnAt` for the cancellable `ItemSpawnEvent` path. It never uses inventory-source dropping or cancellation's return-to-inventory fallback. A successful unit consumes one ingredient set, then recomputes; cancellation stops the suffix without charging it.
Thirteen additional tests cover sequential/bulk/cancelled drops, exact remaining-input validation, stale context, changed/removed entities, same-batch reentry and exceptions. Tests combine real native matrices, serialized sheets, YAML and Paper's event dispatcher; world insertion itself remains an explicit API boundary double, supplemented by pinned-runtime bytecode inspection. No live world or packet dispatch is claimed.
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. That controller and recipe registration remain pending.
## Native equipment adapter
`EquipmentStorage` preflights and moves nine hotbar/four armor slots into an actual named shulker in one free main-inventory slot. Item counts, metadata/damage/enchantments and legal nested contents remain native item data, accessible without this plugin. Eligibility is tested against the real vanilla shulker slot for every nonlegacy native item material; a main-inventory shulker does not prevent entry. Oversized or ineligible equipment and missing space produce unchanged refusals.
@@ -103,25 +103,39 @@ public final class ManualCraftPlan {
* Never call this from a worker or use an old view to write a new menu's cursor.
*/
public boolean matches(InventoryView current) {
return matches(current, matrix);
}
private boolean matches(InventoryView current, ItemStack[] expected) {
if (current != view || current.getTopInventory() != grid || current.getBottomInventory() != inventory) return false;
var recipe = grid.getRecipe();
return isSheetRecipe(recipe) && Objects.equals(result, recipe.getResult())
&& Arrays.equals(matrix, copy(grid.getMatrix())) && Objects.equals(result, copy(grid.getResult()))
&& Arrays.equals(expected, copy(grid.getMatrix())) && Objects.equals(result, copy(grid.getResult()))
&& Objects.equals(cursor, copy(current.getCursor()))
&& inventorySlots.stream().allMatch(slot -> empty(inventory.getItem(slot)));
}
/** Revalidate the original drop intent after exactly this many completed units, not a new snapshot. */
public boolean matchesAfterDrops(InventoryView current, int completed) {
return route == Route.DROP && completed >= 0 && completed < quantity
&& matches(current, remainingMatrix(completed));
}
public int quantity() { return quantity; }
public Route route() { return route; }
public List<Integer> inventorySlots() { return inventorySlots; }
public ItemStack[] remainingMatrix() {
return remainingMatrix(quantity);
}
private ItemStack[] remainingMatrix(int consumed) {
var remaining = copy(matrix);
for (int slot = 0; slot < remaining.length; slot++) {
var item = remaining[slot];
if (item != null) {
if (item.getAmount() == quantity()) remaining[slot] = null;
else item.setAmount(item.getAmount() - quantity());
if (item.getAmount() == consumed) remaining[slot] = null;
else item.setAmount(item.getAmount() - consumed);
}
}
return remaining;
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import org.bukkit.inventory.CraftingInventory;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
/**
* Server-thread drop executor. The caller owns vanilla containment and a one-use lifecycle/attempt guard,
* including refusing NEW attempts during callbacks against these same still-undebited inputs.
* UNCONFIRMED means an output may have escaped without a safely authorized debit; stop/report, never replay.
* Public callbacks consume the batch's cleanup proof, including any unattempted suffix. This is not atomic
* with arbitrary plugin mutations, native insertion failures or Minecraft/file saves.
*/
public final class ManualDropDelivery {
public enum Status { COMPLETE, CANCELLED, STALE, UNCONFIRMED, UNAVAILABLE, UNSUPPORTED_ROUTE }
/** completed counts units with both confirmed insertion and ingredient debit, not every possibly issued sheet. */
public record Result(Status status, int completed) { }
@FunctionalInterface
public interface DropTarget {
/** COMPLETE means one matching sheet was inserted; callbacks must recheck context before insertion. */
Status drop(ItemStack sheet, BooleanSupplier current);
}
private ManualDropDelivery() { }
public static Result commit(ManualCraftPlan plan, CharacterStorage.CraftBatch batch,
Supplier<InventoryView> currentView, BooleanSupplier authorized, DropTarget target) {
if (plan.route() != ManualCraftPlan.Route.DROP) return new Result(Status.UNSUPPORTED_ROUTE, 0);
BooleanSupplier current = () -> authorized.getAsBoolean() && plan.matchesAfterDrops(currentView.get(), 0);
if (!current.getAsBoolean()) return new Result(Status.STALE, 0);
var records = batch.claimForDelivery(plan.quantity());
if (records.isEmpty()) return new Result(Status.UNAVAILABLE, 0);
var sheets = new CharacterSheets();
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
for (int completed = 0; completed < outputs.size(); completed++) {
int expectedCompleted = completed;
BooleanSupplier next = () -> authorized.getAsBoolean()
&& plan.matchesAfterDrops(currentView.get(), expectedCompleted);
if (!next.getAsBoolean()) return new Result(Status.STALE, completed);
var status = java.util.Objects.requireNonNull(target.drop(outputs.get(completed), next));
if (status != Status.COMPLETE) return new Result(status, completed);
// A spawn callback may close/replace the menu after an item has escaped. Never charge a stale snapshot.
if (!next.getAsBoolean()) return new Result(Status.UNCONFIRMED, completed);
var grid = (CraftingInventory) currentView.get().getTopInventory();
var inputs = grid.getMatrix();
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - 1);
grid.setItem(1, inputs[0]);
// Revalidate against the original plan's expected remaining matrix after this callback boundary.
}
return new Result(Status.COMPLETE, outputs.size());
}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.items.CharacterSheets;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.inventory.ItemStack;
/**
* World boundary for saved sheets; no inventory-source drop or cancellation return-to-inventory fallback.
* Invoke on the server thread only after the batch claim, since listeners receive a playable identity.
* Native spawnAt supplies ItemSpawnEvent; do not dispatch that event separately or infer success from a wrapper.
*/
public final class PlayerSheetDrops implements ManualDropDelivery.DropTarget {
private final Player player;
private final Consumer<PlayerDropItemEvent> events;
/** events must dispatch synchronously through the server's PluginManager.callEvent in the registered path. */
public PlayerSheetDrops(Player player, Consumer<PlayerDropItemEvent> events) {
this.player = java.util.Objects.requireNonNull(player);
this.events = java.util.Objects.requireNonNull(events);
}
@Override
public ManualDropDelivery.Status drop(ItemStack sheet, BooleanSupplier current) {
if (sheet == null || sheet.getAmount() != 1 || new CharacterSheets().identify(sheet).isEmpty()) {
throw new IllegalArgumentException("Exactly one identified character sheet is required");
}
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
var location = player.getEyeLocation().subtract(0, .3, 0);
var item = location.getWorld().createEntity(location, Item.class);
item.setItemStack(sheet.clone());
item.setPickupDelay(40);
item.setThrower(player.getUniqueId());
var random = ThreadLocalRandom.current();
double angle = random.nextDouble() * Math.PI * 2;
double spread = random.nextDouble() * .02;
var velocity = location.getDirection().multiply(.3);
velocity.add(new org.bukkit.util.Vector(Math.cos(angle) * spread,
.1 + (random.nextDouble() - random.nextDouble()) * .1, Math.sin(angle) * spread));
item.setVelocity(velocity);
var event = new PlayerDropItemEvent(player, item);
events.accept(event);
if (item.isInWorld() || item.isValid() || item.isDead() || !sheet.equals(item.getItemStack())) {
return ManualDropDelivery.Status.UNCONFIRMED;
}
if (event.isCancelled()) return ManualDropDelivery.Status.CANCELLED;
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
boolean inserted = item.spawnAt(item.getLocation(), SpawnReason.CUSTOM);
return inserted && item.isValid() && item.isInWorld() && sheet.equals(item.getItemStack())
? ManualDropDelivery.Status.COMPLETE : ManualDropDelivery.Status.UNCONFIRMED;
}
}
@@ -261,6 +261,28 @@ class ManualCraftPlanNativeTest {
}
}
@Test
void bulkDropRevalidationAcceptsOnlyTheExpectedRemainingInputsBeforeEachNextUnit() {
for (int width : new int[] {2, 3}) {
var fixture = new Fixture(width, 3);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
assertTrue(plan.matchesAfterDrops(fixture.view, 0));
for (int completed = 1; completed < 3; completed++) {
for (var item : fixture.grid.getMatrix()) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - 1);
assertTrue(plan.matchesAfterDrops(fixture.view, completed));
assertFalse(plan.matchesAfterDrops(fixture.view, completed - 1));
assertFalse(plan.matches(fixture.view));
}
fixture.grid.getMatrix()[0].setAmount(2);
assertFalse(plan.matchesAfterDrops(fixture.view, 2), "Do not recapture unrelated callback changes as new authorized input");
assertFalse(plan.matchesAfterDrops(fixture.view, -1));
assertFalse(plan.matchesAfterDrops(fixture.view, 3));
var cursor = new Fixture(width, 3);
var cursorPlan = ManualCraftPlan.capture(cursor.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
assertFalse(cursorPlan.matchesAfterDrops(cursor.view, 0));
}
}
static final class Fixture {
final Menu menu = new Menu();
Menu activeMenu = menu;
@@ -0,0 +1,188 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.crafting.ManualCraftPlan;
import games.dmg.fantasystick.crafting.ManualDropDelivery;
import games.dmg.fantasystick.crafting.ManualDropDelivery.Status;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Real matrix/item/persistence boundaries; DropTarget is the external world-insertion seam, not a live world. */
class ManualDropDeliveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void ctrlQCompletesDistinctUnitsAndStopsAtCancellationWithoutChargingTheCancelledUnit() throws Exception {
for (int cancelAfter : new int[] {0, 2, 4}) {
var fixture = new ManualCraftPlanNativeTest.Fixture(3, 4);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
var factory = new CharacterFactory();
var records = java.util.stream.IntStream.range(0, 4).mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var attempt = storage.prepareCraft(records);
var batch = attempt.ready().join();
var outputs = new ArrayList<ItemStack>();
var changes = new AtomicInteger();
var calls = new AtomicInteger();
fixture.menu.onChange = () -> changes.incrementAndGet();
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, (sheet, current) -> {
calls.incrementAndGet();
assertTrue(current.getAsBoolean());
assertEquals(4 - outputs.size(), fixture.grid.getMatrix()[0].getAmount());
if (outputs.size() == cancelAfter) return Status.CANCELLED;
outputs.add(ItemStack.deserializeBytes(sheet.serializeAsBytes()));
return Status.COMPLETE;
});
assertEquals(new ManualDropDelivery.Result(cancelAfter == 4 ? Status.COMPLETE : Status.CANCELLED, cancelAfter), result);
assertEquals(Math.min(4, cancelAfter + 1), calls.get());
assertEquals(cancelAfter, changes.get());
var first = fixture.grid.getMatrix()[0];
assertEquals(4 - cancelAfter, first == null ? 0 : first.getAmount());
assertEquals(records.subList(0, cancelAfter).stream().map(record -> record.id()).toList(),
outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList());
assertEquals(cancelAfter, outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).distinct().count());
assertEquals(4, attempt.abandon().join().retained().size(), "Never delete records after public drop callbacks may have exposed sheets");
}
}
}
@Test
void refusedAttemptsNeverReachTheWorldOrConsumeCleanupAuthority() throws Exception {
for (int scenario = 0; scenario < 5; scenario++) {
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
var plan = ManualCraftPlan.capture(fixture.event(scenario == 4 ? ClickType.LEFT : ClickType.CONTROL_DROP,
scenario == 4 ? InventoryAction.PICKUP_ALL : InventoryAction.DROP_ALL_SLOT)).orElseThrow();
var factory = new CharacterFactory();
var records = java.util.stream.IntStream.range(0, scenario == 2 ? 1 : 2)
.mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var attempt = storage.prepareCraft(records);
var batch = attempt.ready().join();
if (scenario == 0) fixture.grid.getMatrix()[0].setAmount(7);
if (scenario == 3) attempt.abandon();
boolean allowed = scenario != 1;
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> allowed,
(sheet, current) -> { fail("A refused attempt must not reach the world"); return Status.COMPLETE; });
assertEquals(scenario < 2 ? Status.STALE : scenario == 4 ? Status.UNSUPPORTED_ROUTE : Status.UNAVAILABLE, result.status());
assertEquals(0, result.completed());
assertEquals(scenario == 0 ? 7 : 2, fixture.grid.getMatrix()[0].getAmount());
assertEquals(records.size(), attempt.abandon().join().removed().size());
}
}
}
@Test
void callbackChangesStopBulkWithoutChargingNewContentsOrRewritingTheNewCursor() throws Exception {
for (boolean duringSpawn : new boolean[] {true, false}) {
for (int mutation = 0; mutation < 4; mutation++) {
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 3);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
var factory = new CharacterFactory();
var records = java.util.stream.IntStream.range(0, 3).mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
var view = new java.util.concurrent.atomic.AtomicReference<org.bukkit.inventory.InventoryView>(fixture.view);
var authorized = new java.util.concurrent.atomic.AtomicBoolean(true);
int change = mutation;
Runnable mutate = () -> {
if (change == 0) fixture.grid.getMatrix()[0].setAmount(7);
if (change == 1) fixture.view.setCursor(new ItemStack(org.bukkit.Material.DIAMOND));
if (change == 2) view.set(new ManualCraftPlanNativeTest.Fixture(2, 3).view);
if (change == 3) authorized.set(false); // Same-menu close/reopen is not detected by contents alone.
};
var calls = new AtomicInteger();
if (!duringSpawn) fixture.menu.onChange = mutate;
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var attempt = storage.prepareCraft(records);
var result = ManualDropDelivery.commit(plan, attempt.ready().join(), view::get, authorized::get, (sheet, current) -> {
assertEquals(1, calls.incrementAndGet());
assertTrue(current.getAsBoolean());
if (duringSpawn) mutate.run();
return Status.COMPLETE;
});
assertEquals(new ManualDropDelivery.Result(duringSpawn ? Status.UNCONFIRMED : Status.STALE, duringSpawn ? 0 : 1), result);
assertEquals(change == 0 ? 7 : duringSpawn ? 3 : 2, fixture.grid.getMatrix()[0].getAmount());
if (change == 1) assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
assertEquals(3, attempt.abandon().join().retained().size());
}
}
}
}
@Test
void repeatedReentrantAndExceptionalCompletionsNeverReuseABatchOrDeletePossiblyIssuedRecords() throws Exception {
for (int failure : new int[] {0, 1, 2}) {
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var attempt = storage.prepareCraft(List.of(record));
var batch = attempt.ready().join();
var calls = new AtomicInteger();
int mode = failure;
if (mode == 2) fixture.menu.onChange = () -> { throw new IllegalStateException("prepare failure"); };
ManualDropDelivery.DropTarget target = (sheet, current) -> {
assertEquals(1, calls.incrementAndGet());
var reentrant = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true,
(other, valid) -> { fail("Batch replay reached world insertion"); return Status.COMPLETE; });
assertEquals(Status.UNAVAILABLE, reentrant.status());
if (mode == 1) throw new IllegalStateException("drop failure");
return Status.COMPLETE;
};
if (mode == 0) assertEquals(Status.COMPLETE,
ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target).status());
else assertThrows(IllegalStateException.class,
() -> ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target));
assertEquals(mode == 1 ? 2 : 1, fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, attempt.abandon().join().retained().size());
assertNotEquals(Status.COMPLETE, ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target).status());
assertEquals(1, calls.get());
}
}
}
@Test
void qCraftDropsOneSavedSheetAndDebitsExactlyOneSetBeforeRecomputation() throws Exception {
for (int width : new int[] {2, 3}) {
var fixture = new ManualCraftPlanNativeTest.Fixture(width, 3);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
var batch = storage.prepareCraft(List.of(record)).ready().join();
var outputs = new ArrayList<ItemStack>();
var changes = new AtomicInteger();
fixture.menu.onChange = () -> {
changes.incrementAndGet();
assertEquals(1, outputs.size());
assertEquals(2, fixture.grid.getMatrix()[0].getAmount());
};
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, (sheet, current) -> {
assertTrue(current.getAsBoolean());
assertEquals(3, fixture.grid.getMatrix()[0].getAmount(), "Do not debit before cancellable drop callbacks");
outputs.add(ItemStack.deserializeBytes(sheet.serializeAsBytes()));
return Status.COMPLETE;
});
assertEquals(new ManualDropDelivery.Result(Status.COMPLETE, 1), result);
assertEquals(record.id(), new CharacterSheets().identify(outputs.getFirst()).orElseThrow());
assertEquals(record, storage.find(record.id()).join().orElseThrow());
assertEquals(1, changes.get());
assertTrue(fixture.view.getCursor().isEmpty());
assertNull(fixture.inventory.getItem(8));
}
}
}
}
@@ -0,0 +1,262 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.crafting.ManualDropDelivery.Status;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.lang.reflect.Proxy;
import java.util.UUID;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.ItemSpawnEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
/** Real native sheet data and API events; world insertion/player are explicit boundary doubles, not a live server. */
class PlayerSheetDropsNativeTest {
@org.junit.jupiter.api.io.TempDir java.nio.file.Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void publishesOnePlayerDropEventBeforeNativeSpawningWithARealSavedSheetAndPlayerDropDefaults() {
var fixture = new WorldFixture();
var sheet = sheet();
var events = new AtomicInteger();
var drops = new PlayerSheetDrops(fixture.player, event -> {
events.incrementAndGet();
assertSame(fixture.player, event.getPlayer());
assertEquals(sheet, event.getItemDrop().getItemStack());
assertFalse(event.getItemDrop().isInWorld());
assertEquals(0, fixture.spawnCalls);
});
assertEquals(Status.COMPLETE, drops.drop(sheet, () -> true));
assertEquals(1, events.get());
assertEquals(1, fixture.spawnCalls);
assertEquals(1, fixture.spawnEvents);
assertEquals(sheet, fixture.stack);
assertEquals(fixture.playerId, fixture.thrower);
assertEquals(40, fixture.pickupDelay);
assertEquals(65.32, fixture.location.getY(), 0.00001);
assertTrue(Math.abs(fixture.velocity.getX()) <= .02);
assertTrue(fixture.velocity.getY() >= 0 && fixture.velocity.getY() <= .2);
assertTrue(fixture.velocity.getZ() >= .28 && fixture.velocity.getZ() <= .32);
}
@Test
void aCancelledPlayerDropNeverSpawnsOrUsesTheNativeInventoryReturnFallback() {
var fixture = new WorldFixture();
var drops = new PlayerSheetDrops(fixture.player, event -> event.setCancelled(true));
assertEquals(Status.CANCELLED, drops.drop(sheet(), () -> true));
assertEquals(1, fixture.creates);
assertEquals(0, fixture.spawnCalls);
assertEquals(0, fixture.spawnEvents);
assertFalse(fixture.inWorld);
// The player boundary rejects any getInventory/dropItem call: no cancelled sheet is inserted there.
}
@Test
void staleContextBeforeOrDuringThePlayerDropEventPreventsWorldInsertion() {
for (boolean staleInitially : new boolean[] {false, true}) {
var fixture = new WorldFixture();
var current = new java.util.concurrent.atomic.AtomicBoolean(!staleInitially);
var events = new AtomicInteger();
var drops = new PlayerSheetDrops(fixture.player, event -> { events.incrementAndGet(); current.set(false); });
assertEquals(Status.STALE, drops.drop(sheet(), current::get));
assertEquals(staleInitially ? 0 : 1, fixture.creates);
assertEquals(staleInitially ? 0 : 1, events.get());
assertEquals(0, fixture.spawnCalls);
}
}
@Test
void changedRemovedOrAlreadyPublishedEntitiesAreNotSpawnedAgainOrOverwritten() {
for (int scenario = 0; scenario < 4; scenario++) {
var fixture = new WorldFixture();
int mutation = scenario;
var drops = new PlayerSheetDrops(fixture.player, event -> {
if (mutation == 0) fixture.stack.setAmount(2);
if (mutation == 1) fixture.removed = true;
if (mutation == 2) { fixture.inWorld = true; event.setCancelled(true); }
if (mutation == 3) fixture.stack = sheet();
});
assertEquals(Status.UNCONFIRMED, drops.drop(sheet(), () -> true));
assertEquals(0, fixture.spawnCalls);
if (mutation == 0) assertEquals(2, fixture.stack.getAmount());
if (mutation == 2) assertTrue(fixture.inWorld);
}
}
@Test
void cancelledNativeSpawnsAndPostInsertionChangesAreUnconfirmedRatherThanSuccessfulDrops() {
for (int scenario = 0; scenario < 3; scenario++) {
var fixture = new WorldFixture();
if (scenario == 0) fixture.onSpawn = event -> event.setCancelled(true);
if (scenario == 1) fixture.onAdded = () -> fixture.removed = true;
if (scenario == 2) fixture.onAdded = () -> fixture.stack = new ItemStack(org.bukkit.Material.DIAMOND);
var drops = new PlayerSheetDrops(fixture.player, event -> { });
assertEquals(Status.UNCONFIRMED, drops.drop(sheet(), () -> true));
assertEquals(1, fixture.spawnCalls);
assertEquals(1, fixture.spawnEvents);
if (scenario == 0) assertFalse(fixture.inWorld);
if (scenario == 2) assertEquals(org.bukkit.Material.DIAMOND, fixture.stack.getType());
}
}
@Test
void rejectsNonSheetsAndWrongStackCountsBeforeAnyPublicCallback() {
var multiple = sheet();
multiple.setAmount(2);
for (var invalid : List.of(new ItemStack(org.bukkit.Material.PAPER), multiple)) {
var fixture = new WorldFixture();
var drops = new PlayerSheetDrops(fixture.player, event -> fail("Invalid sheet reached an event"));
assertThrows(IllegalArgumentException.class, () -> drops.drop(invalid, () -> true));
assertEquals(0, fixture.creates);
}
}
@Test
void realDispatcherCancellationFlowsThroughSavedDropExecutionWithoutChargingCancelledUnits() throws Exception {
var server = proxy(org.bukkit.Server.class, (name, args) -> switch (name) {
case "isPrimaryThread" -> true;
default -> throw new AssertionError("Unexpected server call " + name);
});
var dispatcher = new io.papermc.paper.plugin.manager.NativeEventDispatcher(server);
var plugin = proxy(org.bukkit.plugin.Plugin.class, (name, args) -> switch (name) {
case "isEnabled" -> true;
default -> throw new AssertionError("Unexpected plugin call " + name);
});
for (int mode = 0; mode < 3; mode++) {
var listener = new org.bukkit.event.Listener() { };
var trace = new java.util.ArrayList<String>();
int cancellation = mode;
PlayerDropItemEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener, (ignored, raw) -> {
trace.add("player-veto");
if (cancellation == 1) ((PlayerDropItemEvent) raw).setCancelled(true);
}, org.bukkit.event.EventPriority.HIGH, plugin, false));
PlayerDropItemEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener,
(ignored, raw) -> trace.add("player-allowed"), org.bukkit.event.EventPriority.HIGHEST, plugin, true));
ItemSpawnEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener, (ignored, raw) -> {
trace.add("spawn-veto");
if (cancellation == 2) ((ItemSpawnEvent) raw).setCancelled(true);
}, org.bukkit.event.EventPriority.HIGH, plugin, false));
ItemSpawnEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener,
(ignored, raw) -> trace.add("spawn-allowed"), org.bukkit.event.EventPriority.HIGHEST, plugin, true));
try {
var fixture = new WorldFixture();
fixture.onSpawn = dispatcher::accept;
var grid = new ManualCraftPlanNativeTest.Fixture(2, 2);
var plan = games.dmg.fantasystick.crafting.ManualCraftPlan.capture(grid.event(
org.bukkit.event.inventory.ClickType.DROP, org.bukkit.event.inventory.InventoryAction.DROP_ONE_SLOT)).orElseThrow();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data), java.time.Clock.systemUTC())) {
var attempt = storage.prepareCraft(List.of(record));
var result = games.dmg.fantasystick.crafting.ManualDropDelivery.commit(plan, attempt.ready().join(),
() -> grid.view, () -> true, new PlayerSheetDrops(fixture.player, dispatcher::accept));
assertEquals(mode == 0 ? Status.COMPLETE : mode == 1 ? Status.CANCELLED : Status.UNCONFIRMED, result.status());
assertEquals(mode == 0 ? 1 : 0, result.completed());
assertEquals(mode == 0 ? 1 : 2, grid.grid.getMatrix()[0].getAmount());
assertTrue(grid.view.getCursor().isEmpty());
assertNull(grid.inventory.getItem(8));
assertEquals(mode == 1 ? List.of("player-veto") : mode == 2 ? List.of("player-veto", "player-allowed", "spawn-veto")
: List.of("player-veto", "player-allowed", "spawn-veto", "spawn-allowed"), trace);
assertEquals(1, attempt.abandon().join().retained().size());
}
} finally {
org.bukkit.event.HandlerList.unregisterAll(listener);
}
}
}
private static ItemStack sheet() {
return new CharacterSheets().create(new CharacterFactory().create(ProgressionRules.defaults()));
}
static final class WorldFixture {
final UUID playerId = UUID.randomUUID();
final World world;
final Player player;
final Item item;
ItemStack stack;
Location location;
Vector velocity;
UUID thrower;
int pickupDelay;
int creates;
int spawnCalls;
int spawnEvents;
boolean inWorld;
boolean removed;
Consumer<ItemSpawnEvent> onSpawn = event -> { };
Runnable onAdded = () -> { };
WorldFixture() {
item = proxy(Item.class, (name, args) -> switch (name) {
case "setItemStack" -> { stack = ((ItemStack) args[0]).clone(); yield null; }
case "getItemStack" -> stack.clone();
case "setPickupDelay" -> { pickupDelay = (int) args[0]; yield null; }
case "setThrower" -> { thrower = (UUID) args[0]; yield null; }
case "setVelocity" -> { velocity = ((Vector) args[0]).clone(); yield null; }
case "getLocation" -> location.clone();
case "isInWorld", "isValid" -> inWorld && !removed;
case "isDead" -> removed;
case "spawnAt" -> {
spawnCalls++;
assertEquals(org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM, args[1]);
var event = new ItemSpawnEvent(argsItem());
spawnEvents++;
onSpawn.accept(event);
if (event.isCancelled() || removed) { removed = true; yield false; }
inWorld = true;
onAdded.run();
yield true;
}
default -> throw new AssertionError("Unexpected item call " + name);
});
world = proxy(World.class, (name, args) -> switch (name) {
case "createEntity" -> {
creates++;
assertEquals(Item.class, args[1]);
location = ((Location) args[0]).clone();
returnItemUnspawned();
yield item;
}
default -> throw new AssertionError("Unexpected world call " + name);
});
player = proxy(Player.class, (name, args) -> switch (name) {
case "getUniqueId" -> playerId;
case "getEyeLocation" -> new Location(world, 10, 65.62, 10, 0, 0);
default -> throw new AssertionError("Unexpected player call " + name);
});
}
private Item argsItem() { return item; }
private void returnItemUnspawned() { inWorld = false; removed = false; }
}
@FunctionalInterface
private interface Call { Object invoke(String name, Object[] args); }
private static <T> T proxy(Class<T> type, Call call) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (object, method, args) -> {
if (method.getDeclaringClass() == Object.class) return switch (method.getName()) {
case "equals" -> object == args[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Boundary fixture " + type.getSimpleName();
default -> throw new AssertionError(method.getName());
};
return call.invoke(method.getName(), args);
}));
}
}
@@ -0,0 +1,14 @@
package io.papermc.paper.plugin.manager;
import java.util.function.Consumer;
import org.bukkit.Server;
import org.bukkit.event.Event;
/** Test-only access to the pinned package-private dispatcher; never included in the plugin artifact. */
public final class NativeEventDispatcher implements Consumer<Event> {
private final PaperEventManager manager;
public NativeEventDispatcher(Server server) { manager = new PaperEventManager(server); }
@Override public void accept(Event event) { manager.callEvent(event); }
}