Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7912d03ce | ||
|
|
fcf93fa966 |
No files matched your search
@@ -63,6 +63,22 @@ 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. The controller now has native lifecycle coverage as described below, but it and the recipe remain unregistered.
|
||||
|
||||
## Manual craft controller (not registered)
|
||||
|
||||
`ManualCraftingController` contains vanilla handling before deferred work, allocates/saves accepted plans, returns delivery to an always-deferred server-thread callback and synchronizes the player's actual current inventory afterward. It uses attempt-object identity plus a delivery reservation that survives close/reopen and remains held through client-sync callbacks. It invalidates on close, accepted open, quit and disable; stale callbacks cannot clear a newer attempt, and rejected scheduling abandons owned unissued records without worker-side player access.
|
||||
|
||||
Twelve native controller tests cover saved deferred delivery, Q/Ctrl-Q routing with full storage, replay/reentrancy, menu reuse, client-sync reentry, pre-existing cancellation/unrelated recipes, rejected intents, allocation/save/scheduler failures, postclaim exceptions and retained-file reporting. Review reproduced a planning exception escaping before vanilla cancellation; capture now always cancels in `finally`. Async diagnostics are collected and asserted on the test thread rather than lost inside discarded futures. These tests invoke controller callbacks with real native grids/items/YAML and explicit player/scheduler/world boundaries, not a listening server. Clean and incremental packaging pass all 153 tests, audit and static analysis. Registration/lifecycle assembly is still pending.
|
||||
|
||||
Scheduler injection must always enqueue (never run inline) or throw on rejection. Diagnostic callbacks must be nonthrowing, thread-safe and avoid native player access. The existing cancellation boolean cannot distinguish this handler's containment from a later plugin's independent veto; prior cancellations are preserved. No new Q/Ctrl-Q restriction, global inventory lock or automatic uncertain-delivery replay is introduced.
|
||||
|
||||
## 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,175 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import org.bukkit.Keyed;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
|
||||
/**
|
||||
* Owns accepted manual craft lifetimes. Native entrypoints/close run on the server thread.
|
||||
* nextTick must always enqueue onto that thread (never run inline), or throw on rejection.
|
||||
* Diagnostics must be thread-safe, nonthrowing and independent of native player access.
|
||||
* A cancellation observed before this HIGHEST handler is preserved; the event's single boolean cannot
|
||||
* distinguish our containment cancellation from an independent later plugin veto.
|
||||
*/
|
||||
public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
public record Failure(UUID player, List<UUID> characters, String operation, Throwable cause) {
|
||||
public Failure { characters = List.copyOf(characters); }
|
||||
}
|
||||
|
||||
private final CharacterStorage storage;
|
||||
private final Supplier<CharacterRecord> allocate;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<Player, ManualDropDelivery.DropTarget> drops;
|
||||
private final Consumer<Failure> failures;
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
|
||||
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
|
||||
|
||||
public ManualCraftingController(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Function<Player, ManualDropDelivery.DropTarget> drops, Consumer<Failure> failures) {
|
||||
this.storage = java.util.Objects.requireNonNull(storage);
|
||||
this.allocate = java.util.Objects.requireNonNull(allocate);
|
||||
this.nextTick = java.util.Objects.requireNonNull(nextTick);
|
||||
this.drops = java.util.Objects.requireNonNull(drops);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|
||||
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
java.util.Optional<ManualCraftPlan> plan;
|
||||
try {
|
||||
plan = ManualCraftPlan.capture(event);
|
||||
} catch (RuntimeException failure) {
|
||||
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
|
||||
return;
|
||||
} finally {
|
||||
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
|
||||
event.setCancelled(true);
|
||||
}
|
||||
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|
||||
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
|
||||
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
|
||||
try {
|
||||
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
|
||||
attempt.characters = records.stream().map(CharacterRecord::id).toList();
|
||||
attempt.preparation = storage.prepareCraft(records);
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nextTick.accept(() -> complete(attempt, batch, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
// This branch may run on the persistence worker: diagnostics use captured IDs, never native player access.
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "schedule", rejected));
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
return;
|
||||
}
|
||||
if (!delivering.add(attempt.id)) return;
|
||||
try {
|
||||
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
else if (attempt.player.isOnline()) {
|
||||
attempt.handoffStarted = true;
|
||||
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
|
||||
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
|
||||
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
|
||||
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
|
||||
}
|
||||
} else {
|
||||
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
|
||||
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
|
||||
} finally {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
try {
|
||||
if (attempt.player.isOnline()) attempt.player.updateInventory();
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "sync", unexpected));
|
||||
} finally {
|
||||
delivering.remove(attempt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final UUID id;
|
||||
final Player player;
|
||||
final ManualCraftPlan plan;
|
||||
List<UUID> characters = List.of();
|
||||
CharacterStorage.CraftPreparation preparation;
|
||||
volatile boolean handoffStarted;
|
||||
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
Attempt(UUID id, Player player, ManualCraftPlan plan) {
|
||||
this.id = id;
|
||||
this.player = player;
|
||||
this.plan = plan;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onClose(org.bukkit.event.inventory.InventoryCloseEvent event) { invalidate(event.getPlayer().getUniqueId()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onOpen(org.bukkit.event.inventory.InventoryOpenEvent event) {
|
||||
if (!event.isCancelled()) invalidate(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { invalidate(event.getPlayer().getUniqueId()); }
|
||||
|
||||
private void invalidate(UUID player) {
|
||||
var attempt = pending.remove(player);
|
||||
if (attempt != null) abandon(attempt);
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.preparation == null || !attempt.cleanupObserved.compareAndSet(false, true)) return;
|
||||
attempt.preparation.abandon().thenAccept(cleanup -> {
|
||||
if (!attempt.handoffStarted && !cleanup.retained().isEmpty()) {
|
||||
failures.accept(new Failure(attempt.id, List.copyOf(cleanup.retained()), "cleanup-retained", null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var id : pending.keySet()) invalidate(id);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -271,16 +293,26 @@ class ManualCraftPlanNativeTest {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
final InventoryView view;
|
||||
HumanEntity human;
|
||||
boolean failMatrixRead;
|
||||
|
||||
Fixture(int width, int amount) {
|
||||
matrix = new TransientCraftingContainer(menu, width, width);
|
||||
var result = new ResultContainer();
|
||||
matrix.resultInventory = result;
|
||||
grid = new CraftInventoryCrafting(matrix, result);
|
||||
grid = new CraftInventoryCrafting(matrix, result) {
|
||||
@Override public ItemStack[] getMatrix() {
|
||||
if (failMatrixRead) throw new IllegalStateException("Simulated native matrix read failure");
|
||||
return super.getMatrix();
|
||||
}
|
||||
};
|
||||
view = new CraftAbstractInventoryView() {
|
||||
@Override public CraftInventoryCrafting getTopInventory() { return grid; }
|
||||
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
|
||||
@Override public HumanEntity getPlayer() { throw new AssertionError("Unexpected player/network access"); }
|
||||
@Override public HumanEntity getPlayer() {
|
||||
if (human == null) throw new AssertionError("Unexpected player/network access");
|
||||
return human;
|
||||
}
|
||||
@Override public InventoryType getType() { return grid.getType(); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingController;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ManualCraftingControllerNativeTest {
|
||||
@TempDir Path data;
|
||||
private final CopyOnWriteArrayList<ManualCraftingController.Failure> unexpected = new CopyOnWriteArrayList<>();
|
||||
@org.junit.jupiter.api.AfterEach void assertNoUnexpectedDiagnostics() {
|
||||
assertTrue(unexpected.isEmpty(), () -> "Unexpected diagnostics: " + unexpected);
|
||||
}
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void acceptedCraftIsContainedSavedAndDeliveredOnlyOnItsDeferredServerCallbackOnce() throws Exception {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var allocated = new ArrayList<CharacterRecord>();
|
||||
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
allocated.add(record); return record;
|
||||
}, tasks::add, player -> { throw new AssertionError("Not a drop craft"); }, failures::add)) {
|
||||
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
controller.onCraft(event);
|
||||
assertTrue(event.isCancelled(), "Vanilla must not consume the metadata-empty preview");
|
||||
assertEquals(1, allocated.size());
|
||||
var repeat = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
controller.onCraft(repeat);
|
||||
assertTrue(repeat.isCancelled());
|
||||
assertEquals(1, allocated.size(), "The pending attempt must not allocate a second batch");
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(complete);
|
||||
assertEquals(allocated.getFirst(), storage.find(allocated.getFirst().id()).join().orElseThrow());
|
||||
complete.run();
|
||||
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(allocated.getFirst().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
|
||||
assertEquals(1, actor.syncs);
|
||||
complete.run();
|
||||
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(1, actor.syncs);
|
||||
assertTrue(failures.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("try") // Exercise disable during an attempt and idempotent final resource cleanup.
|
||||
void closingDisconnectingDisablingOrChangingContextBeforeCompletionNeverConsumesOrDelivers() throws Exception {
|
||||
for (int scenario = 0; scenario < 5; scenario++) {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(complete);
|
||||
if (scenario == 0) controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
|
||||
if (scenario == 1) {
|
||||
actor.online = false;
|
||||
controller.onQuit(new org.bukkit.event.player.PlayerQuitEvent(actor.player, net.kyori.adventure.text.Component.empty()));
|
||||
}
|
||||
if (scenario == 2) controller.close();
|
||||
if (scenario == 3) actor.fixture.grid.getMatrix()[0].setAmount(7);
|
||||
if (scenario == 4) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
|
||||
complete.run();
|
||||
assertEquals(scenario == 3 ? 7 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
assertTrue(actor.current.getCursor().isEmpty());
|
||||
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
|
||||
complete.run();
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldCallbacksCannotDeliverIntoOrRemoveANewAttemptOnTheSameReopenedMenu() throws Exception {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var old = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(old);
|
||||
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var fresh = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(fresh);
|
||||
old.run();
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
assertEquals(2, records.size(), "Old completion must not clear the fresh reservation");
|
||||
fresh.run();
|
||||
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
|
||||
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
|
||||
assertTrue(storage.find(records.get(1).id()).join().isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void qAndCtrlQUseTheSavedDropPathEvenWithFullPlayerStorage() throws Exception {
|
||||
for (boolean bulk : new boolean[] {false, true}) {
|
||||
var actor = new Actor(3, 3);
|
||||
for (int slot = 0; slot < 36; slot++) actor.fixture.inventory.setItem(slot,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
var outputs = new ArrayList<org.bukkit.inventory.ItemStack>();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> (sheet, current) -> {
|
||||
assertSame(actor.player, player);
|
||||
assertTrue(current.getAsBoolean());
|
||||
outputs.add(sheet.clone());
|
||||
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
|
||||
}, unexpected::add)) {
|
||||
var event = actor.fixture.event(bulk ? ClickType.CONTROL_DROP : ClickType.DROP,
|
||||
bulk ? InventoryAction.DROP_ALL_SLOT : InventoryAction.DROP_ONE_SLOT);
|
||||
controller.onCraft(event);
|
||||
assertTrue(event.isCancelled());
|
||||
assertEquals(bulk ? 3 : 1, records.size());
|
||||
assertTrue(outputs.isEmpty());
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
complete.run();
|
||||
assertEquals(bulk ? 3 : 1, outputs.size());
|
||||
assertEquals(records.stream().map(CharacterRecord::id).toList(),
|
||||
outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList());
|
||||
var first = actor.fixture.grid.getMatrix()[0];
|
||||
assertEquals(bulk ? 0 : 2, first == null ? 0 : first.getAmount());
|
||||
assertEquals(64, actor.fixture.inventory.getItem(8).getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
assertEquals(1, actor.syncs);
|
||||
for (var record : records) assertTrue(storage.find(record.id()).join().isPresent());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryAndClientSyncKeepAReservationEvenAcrossCloseReopenAndReentrantCompletion() throws Exception {
|
||||
for (int scenario = 0; scenario < 3; scenario++) {
|
||||
var actor = new Actor(2, 3);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
|
||||
var listener = new java.util.concurrent.atomic.AtomicReference<ManualCraftingController>();
|
||||
var completion = new java.util.concurrent.atomic.AtomicReference<Runnable>();
|
||||
int mode = scenario;
|
||||
Runnable reopenAndCraft = () -> {
|
||||
listener.get().onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
|
||||
var again = actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
|
||||
listener.get().onCraft(again);
|
||||
assertTrue(again.isCancelled());
|
||||
};
|
||||
if (mode == 2) actor.onSync = reopenAndCraft;
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> (sheet, current) -> {
|
||||
if (mode == 0) completion.get().run();
|
||||
if (mode == 1) reopenAndCraft.run();
|
||||
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
|
||||
}, failures::add)) {
|
||||
listener.set(controller);
|
||||
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
|
||||
completion.set(tasks.poll(5, TimeUnit.SECONDS)); assertNotNull(completion.get());
|
||||
completion.get().run();
|
||||
assertEquals(1, records.size(), "Reentrant close must not open a second allocation window during delivery/sync");
|
||||
assertEquals(mode == 1 ? 3 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(1, actor.syncs);
|
||||
assertEquals(mode == 1 ? java.util.List.of("drop-unconfirmed") : java.util.List.of(),
|
||||
failures.stream().map(ManualCraftingController.Failure::operation).toList());
|
||||
assertTrue(tasks.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedSchedulingAbandonsUnissuedFilesWithoutWorkerPlayerAccessAndPermitsRetry() throws Exception {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
|
||||
var reject = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, task -> {
|
||||
if (reject.getAndSet(false)) throw new java.util.concurrent.RejectedExecutionException("scheduler unavailable");
|
||||
tasks.add(task);
|
||||
}, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var problem = problems.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(problem, "Scheduling rejection must not silently orphan the pending attempt");
|
||||
assertEquals("schedule", problem.operation());
|
||||
assertEquals(actor.id, problem.player());
|
||||
assertEquals(java.util.List.of(records.getFirst().id()), problem.characters());
|
||||
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
assertEquals(0, actor.syncs);
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
complete.run();
|
||||
assertEquals(2, records.size());
|
||||
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allocationAndSaveFailuresLeaveInventoryUnchangedAndDoNotBlockTheNextCraft() throws Exception {
|
||||
for (boolean failAllocation : new boolean[] {true, false}) {
|
||||
var directory = data.resolve(failAllocation ? "allocation" : "save");
|
||||
java.nio.file.Files.createDirectories(directory);
|
||||
if (!failAllocation) java.nio.file.Files.writeString(directory.resolve("characters"), "blocked directory fixture");
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
|
||||
var firstAllocation = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(directory), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
if (firstAllocation.getAndSet(false) && failAllocation) throw new IllegalStateException("allocation unavailable");
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
|
||||
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
assertDoesNotThrow(() -> controller.onCraft(event));
|
||||
assertTrue(event.isCancelled());
|
||||
if (!failAllocation) { var failed = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run(); }
|
||||
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
|
||||
assertEquals(failAllocation ? "prepare" : "save", problem.operation());
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
if (!failAllocation) java.nio.file.Files.delete(directory.resolve("characters"));
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var retry = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(retry); retry.run();
|
||||
assertEquals(records.getLast().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
|
||||
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryAndSyncExceptionsAreReportedWithoutReplayDeletionOrLeakingTheReservation() throws Exception {
|
||||
for (boolean syncFailure : new boolean[] {false, true}) {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var records = new ArrayList<CharacterRecord>();
|
||||
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
|
||||
if (syncFailure) actor.onSync = () -> { throw new IllegalStateException("sync failed"); };
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
|
||||
}, tasks::add, player -> (sheet, current) -> {
|
||||
if (!syncFailure) throw new IllegalStateException("world callback failed");
|
||||
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
|
||||
}, problems::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
assertDoesNotThrow(complete::run);
|
||||
assertEquals(java.util.List.of(syncFailure ? "sync" : "delivery"),
|
||||
problems.stream().map(ManualCraftingController.Failure::operation).toList());
|
||||
assertTrue(storage.find(records.getFirst().id()).join().isPresent());
|
||||
assertEquals(syncFailure ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
complete.run();
|
||||
assertEquals(1, problems.size());
|
||||
actor.onSync = () -> { };
|
||||
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
|
||||
assertEquals(2, records.size(), "An exception must release the reservation for a separate new intent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("try")
|
||||
void preservesOtherRecipesAndCancellationsAndAllocatesNothingForRejectedOrStaleIntents() throws Exception {
|
||||
var annotation = ManualCraftingController.class.getMethod("onCraft", org.bukkit.event.inventory.CraftItemEvent.class)
|
||||
.getAnnotation(org.bukkit.event.EventHandler.class);
|
||||
assertEquals(org.bukkit.event.EventPriority.HIGHEST, annotation.priority());
|
||||
assertTrue(annotation.ignoreCancelled());
|
||||
for (int scenario = 0; scenario < 7; scenario++) {
|
||||
var actor = new Actor(2, 2);
|
||||
var allocations = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
if (scenario == 0) event.setCancelled(true);
|
||||
if (scenario == 1) event = new org.bukkit.event.inventory.CraftItemEvent(new org.bukkit.inventory.ShapelessRecipe(
|
||||
new org.bukkit.NamespacedKey("unrelated", "character_sheet"), new org.bukkit.inventory.ItemStack(org.bukkit.Material.PAPER))
|
||||
.addIngredient(org.bukkit.Material.STICK), actor.fixture.view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
if (scenario == 2) actor.fixture.grid.setResult(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (scenario == 3) actor.fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (scenario == 4) actor.online = false;
|
||||
if (scenario == 5) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
|
||||
}, task -> { }, player -> { throw new AssertionError("Not a drop"); }, problem -> { })) {
|
||||
assertEquals(0, allocations.get());
|
||||
if (scenario == 6) controller.close();
|
||||
controller.onCraft(event);
|
||||
assertEquals(scenario != 1, event.isCancelled());
|
||||
assertEquals(0, allocations.get(), "Rejected intent " + scenario + " must not mint identities");
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptedOpenInvalidatesTheLifetimeButACancelledOpenDoesNotInventATransition() throws Exception {
|
||||
for (boolean cancelled : new boolean[] {false, true}) {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
|
||||
player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
var open = new org.bukkit.event.inventory.InventoryOpenEvent(actor.fixture.view);
|
||||
open.setCancelled(cancelled);
|
||||
controller.onOpen(open);
|
||||
complete.run();
|
||||
assertEquals(cancelled ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(cancelled, storage.find(record.id()).join().isPresent());
|
||||
assertEquals(!cancelled, actor.fixture.view.getCursor().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainedUnissuedFilesAreReportedOnceWithoutDeletingEditsOrTouchingThePlayerFromTheWorker() throws Exception {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
|
||||
player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
var file = data.resolve("characters").resolve(record.id() + ".yml");
|
||||
java.nio.file.Files.writeString(file, "\n", java.nio.file.StandardOpenOption.APPEND);
|
||||
var changed = java.nio.file.Files.readAllBytes(file);
|
||||
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
|
||||
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
|
||||
assertEquals("cleanup-retained", problem.operation());
|
||||
assertEquals(java.util.List.of(record.id()), problem.characters());
|
||||
complete.run(); complete.run();
|
||||
assertArrayEquals(changed, java.nio.file.Files.readAllBytes(file));
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(problems.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNativePlanningExceptionStillContainsVanillaAndReportsWithoutAllocating() throws Exception {
|
||||
var actor = new Actor(2, 2);
|
||||
var tasks = new LinkedBlockingQueue<Runnable>();
|
||||
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
|
||||
var allocations = new java.util.concurrent.atomic.AtomicInteger();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var controller = new ManualCraftingController(storage, () -> {
|
||||
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
|
||||
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
|
||||
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
actor.fixture.failMatrixRead = true;
|
||||
assertDoesNotThrow(() -> controller.onCraft(event));
|
||||
assertTrue(event.isCancelled(), "A planner exception must not allow vanilla to consume the plain preview");
|
||||
assertEquals(0, allocations.get());
|
||||
assertTrue(tasks.isEmpty());
|
||||
assertEquals(java.util.List.of("plan"), problems.stream().map(ManualCraftingController.Failure::operation).toList());
|
||||
actor.fixture.failMatrixRead = false;
|
||||
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
|
||||
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
|
||||
assertEquals(1, allocations.get());
|
||||
assertTrue(new CharacterSheets().identify(actor.fixture.view.getCursor()).isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
static final class Actor {
|
||||
final ManualCraftPlanNativeTest.Fixture fixture;
|
||||
final UUID id = UUID.randomUUID();
|
||||
final Player player;
|
||||
final Thread serverThread = Thread.currentThread();
|
||||
InventoryView current;
|
||||
boolean online = true;
|
||||
int syncs;
|
||||
Runnable onSync = () -> { };
|
||||
|
||||
Actor(int width, int amount) {
|
||||
fixture = new ManualCraftPlanNativeTest.Fixture(width, amount);
|
||||
current = fixture.view;
|
||||
player = Player.class.cast(Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[] {Player.class},
|
||||
(object, method, args) -> {
|
||||
assertSame(serverThread, Thread.currentThread(), "No native player access on the persistence worker");
|
||||
return switch (method.getName()) {
|
||||
case "getUniqueId" -> id;
|
||||
case "getOpenInventory" -> current;
|
||||
case "isOnline" -> online;
|
||||
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
|
||||
case "equals" -> object == args[0];
|
||||
case "hashCode" -> System.identityHashCode(object);
|
||||
case "toString" -> "Crafting actor fixture";
|
||||
default -> throw new AssertionError("Unexpected player call " + method.getName());
|
||||
};
|
||||
}));
|
||||
fixture.human = player;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in new issue
Block a user