Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7912d03ce | ||
|
|
fcf93fa966 | ||
|
|
14fc0dbde0 |
No files matched your search
@@ -55,6 +55,30 @@ The `fantasystick:character_sheet` shapeless recipe definition contains one stic
|
||||
|
||||
This is **not a transaction commit**. Ten native planner/composition tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. A controller still needs one-use lifecycle tokens (a native menu may be reused after close/reopen), wiring of name/ID allocation and asynchronous persistence, reentrant matrix-update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
|
||||
## Fixed-destination craft delivery (not registered)
|
||||
|
||||
`ManualCraftDelivery` can apply a validated plan and exact-sized saved batch to the cursor, selected hotbar/offhand slot or bulk storage destinations. It rechecks current contents before claiming delivery, constructs all sheets first, then debits fresh native input mirrors and places all outputs before triggering one matrix recomputation. This deliberately relies on the pinned Purpur 2618 mirror/count/setter behavior. No further adapter-planned writes occur after the prepare-callback boundary. Existing native setters may send packets; this is not packet-silent or exception/crash-atomic.
|
||||
|
||||
Nine native delivery tests cover normal/right-click, bulk and exact-slot delivery, partial ingredient metadata, empty first matrix cells, stale context refusal, mismatched/abandoned/reused batches, reentrancy, callback item changes, new-menu cursor safety and post-callback exceptions. The fixture uses actual containers/cursor stacks and intercepts `slotsChanged`; it does not execute a listening server's recipe manager or event dispatch. A mismatch refuses before consuming cleanup authority; an exception after claiming delivery conservatively retains records rather than replaying or rolling back.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
|
||||
/**
|
||||
* Fixed-destination adapter for the pinned Purpur runtime, not a listener or a portable transaction API.
|
||||
* Call on the server thread after event containment/lifecycle authorization. Supply the player's actual
|
||||
* current open view, never a retained event's view. Close/reopen generations remain the caller's duty.
|
||||
* World drops are deliberately unsupported here. The caller synchronizes the client after completion.
|
||||
*/
|
||||
public final class ManualCraftDelivery {
|
||||
public enum Result { DELIVERED, STALE, UNAVAILABLE, UNSUPPORTED_ROUTE }
|
||||
|
||||
private ManualCraftDelivery() { }
|
||||
|
||||
public static Result commit(ManualCraftPlan plan, CharacterStorage.CraftBatch batch, InventoryView current) {
|
||||
if (plan.route() == ManualCraftPlan.Route.DROP) return Result.UNSUPPORTED_ROUTE;
|
||||
if (!plan.matches(current)) return Result.STALE;
|
||||
var records = batch.claimForDelivery(plan.quantity());
|
||||
if (records.isEmpty()) return Result.UNAVAILABLE;
|
||||
var sheets = new CharacterSheets();
|
||||
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
|
||||
var grid = (CraftingInventory) current.getTopInventory();
|
||||
var inputs = grid.getMatrix();
|
||||
// Purpur 2618: these are native mirrors; count changes do not call slotsChanged.
|
||||
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - plan.quantity());
|
||||
if (plan.route() == ManualCraftPlan.Route.CURSOR) current.setCursor(outputs.getFirst());
|
||||
else for (int index = 0; index < outputs.size(); index++) {
|
||||
current.getBottomInventory().setItem(plan.inventorySlots().get(index), outputs.get(index));
|
||||
}
|
||||
// One adapter-triggered recomputation after ALL debits and placements, including exhausted cells.
|
||||
// Do not make further planned inventory writes after this synchronous callback boundary.
|
||||
grid.setItem(1, inputs[0]);
|
||||
return Result.DELIVERED;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,12 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
this.records = created.stream().map(creation -> creation.record).toList();
|
||||
}
|
||||
|
||||
/** Reject a wrong-sized batch before giving up its never-issued cleanup proof. */
|
||||
public synchronized Optional<List<CharacterRecord>> claimForDelivery(int expectedQuantity) {
|
||||
if (expectedQuantity < 1 || records.size() != expectedQuantity) return Optional.empty();
|
||||
return claimForDelivery();
|
||||
}
|
||||
|
||||
/** One-use handoff immediately before native delivery; an exception afterward cannot authorize deletion. */
|
||||
public synchronized Optional<List<CharacterRecord>> claimForDelivery() {
|
||||
if (resolved || owner.abandoned) return Optional.empty();
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftDelivery;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftPlan;
|
||||
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.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ManualCraftDeliveryNativeTest {
|
||||
@Test
|
||||
void shiftCraftPlacesDistinctSheetsInPlannedSlotsAndPreservesPartialInputMetadataAndCursor() throws Exception {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(width, 4);
|
||||
var inputs = fixture.grid.getMatrix();
|
||||
var arranged = new org.bukkit.inventory.ItemStack[width * width];
|
||||
for (int index = 0; index < 4; index++) {
|
||||
var meta = inputs[index].getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Named ingredient"));
|
||||
inputs[index].setItemMeta(meta);
|
||||
arranged[width == 2 ? index : 8 - index * 2] = inputs[index];
|
||||
}
|
||||
fixture.grid.setMatrix(arranged);
|
||||
for (int slot = 0; slot < 36; slot++) fixture.inventory.setItem(slot,
|
||||
new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
|
||||
fixture.inventory.setItem(8, null);
|
||||
fixture.inventory.setItem(33, null);
|
||||
fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND, 29));
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
assertEquals(List.of(8, 33), plan.inventorySlots());
|
||||
var factory = new CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, plan.quantity())
|
||||
.mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(records).ready().join();
|
||||
var changes = new AtomicInteger();
|
||||
fixture.menu.onChange = () -> {
|
||||
changes.incrementAndGet();
|
||||
for (var item : fixture.grid.getMatrix()) if (item != null && !item.isEmpty()) {
|
||||
assertEquals(2, item.getAmount());
|
||||
assertEquals(net.kyori.adventure.text.Component.text("Named ingredient"), item.getItemMeta().displayName());
|
||||
}
|
||||
for (int index = 0; index < records.size(); index++) assertEquals(records.get(index).id(),
|
||||
new CharacterSheets().identify(fixture.inventory.getItem(plan.inventorySlots().get(index))).orElseThrow());
|
||||
};
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(1, changes.get());
|
||||
assertFalse(fixture.inventory.getItem(8).isSimilar(fixture.inventory.getItem(33)));
|
||||
assertEquals(64, fixture.inventory.getItem(12).getAmount());
|
||||
assertEquals(org.bukkit.Material.COBBLESTONE, fixture.inventory.getItem(12).getType());
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(29, fixture.view.getCursor().getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchedSavedBatchDoesNotConsumeAnythingOrClaimDelivery() throws Exception {
|
||||
for (int quantity : new int[] {1, 3}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
var factory = new CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, quantity)
|
||||
.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();
|
||||
assertEquals(ManualCraftDelivery.Result.UNAVAILABLE, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertNull(fixture.inventory.getItem(8));
|
||||
assertEquals(quantity, attempt.abandon().join().removed().size(),
|
||||
"An invalid batch must remain provably unissued and cleanable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberAndOffhandDeliveryChangeOnlyTheExactDestinationAndPreserveTheCursor() throws Exception {
|
||||
for (int slot : new int[] {0, 8, 40}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(3, 2);
|
||||
fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND, 3));
|
||||
var event = new org.bukkit.event.inventory.CraftItemEvent(new CharacterSheets().recipe(), fixture.view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
|
||||
slot == 40 ? ClickType.SWAP_OFFHAND : ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP,
|
||||
slot == 40 ? -1 : slot);
|
||||
var plan = ManualCraftPlan.capture(event).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();
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(record.id(), new CharacterSheets().identify(fixture.inventory.getItem(slot)).orElseThrow());
|
||||
assertEquals(1, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(3, fixture.view.getCursor().getAmount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleInputsDestinationsRecipeCursorOrViewLeaveAllCurrentItemsAndCleanupProofUntouched() throws Exception {
|
||||
for (int scenario = 0; scenario < 5; scenario++) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
var factory = new CharacterFactory();
|
||||
var records = List.of(factory.create(ProgressionRules.defaults()), factory.create(ProgressionRules.defaults()));
|
||||
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 == 1) fixture.inventory.setItem(8, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (scenario == 2) fixture.matrix.setCurrentRecipe(null);
|
||||
if (scenario == 3) fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.GOLD_INGOT));
|
||||
var current = scenario == 4 ? new ManualCraftPlanNativeTest.Fixture(2, 2).view : fixture.view;
|
||||
var inputBefore = copy(fixture.grid.getMatrix());
|
||||
var inventoryBefore = copy(fixture.inventory.getContents());
|
||||
var cursorBefore = fixture.view.getCursor().clone();
|
||||
fixture.menu.onChange = () -> fail("A refused craft must not recompute or mutate");
|
||||
assertEquals(ManualCraftDelivery.Result.STALE, ManualCraftDelivery.commit(plan, batch, current));
|
||||
assertArrayEquals(inputBefore, fixture.grid.getMatrix());
|
||||
assertArrayEquals(inventoryBefore, fixture.inventory.getContents());
|
||||
assertEquals(cursorBefore, fixture.view.getCursor());
|
||||
assertEquals(2, attempt.abandon().join().removed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recomputationCanReenterAndChangeItemsWithoutASecondDeliveryOrLaterOverwrite() throws Exception {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = ManualCraftPlan.capture(new org.bukkit.event.inventory.CraftItemEvent(new CharacterSheets().recipe(),
|
||||
fixture.view, org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, ClickType.NUMBER_KEY,
|
||||
InventoryAction.HOTBAR_SWAP, 8)).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 changes = new AtomicInteger();
|
||||
fixture.menu.onChange = () -> {
|
||||
assertEquals(1, changes.incrementAndGet());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(fixture.inventory.getItem(8)).orElseThrow());
|
||||
var repeat = ManualCraftPlan.capture(new org.bukkit.event.inventory.CraftItemEvent(new CharacterSheets().recipe(),
|
||||
fixture.view, org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, ClickType.NUMBER_KEY,
|
||||
InventoryAction.HOTBAR_SWAP, 7)).orElseThrow();
|
||||
assertEquals(ManualCraftDelivery.Result.UNAVAILABLE, ManualCraftDelivery.commit(repeat, batch, fixture.view));
|
||||
assertNull(fixture.inventory.getItem(7));
|
||||
// A post-commit listener owns these later changes; never rewrite a stored snapshot over them.
|
||||
fixture.inventory.setItem(8, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
fixture.grid.getMatrix()[2].setAmount(22);
|
||||
};
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.inventory.getItem(8).getType());
|
||||
assertEquals(22, fixture.grid.getMatrix()[2].getAmount());
|
||||
assertEquals(1, changes.get());
|
||||
assertEquals(java.util.Set.of(record.id()), attempt.abandon().join().retained());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abandonedBatchAndUnsupportedWorldDropDoNotChangeInputsOrDestinations() throws Exception {
|
||||
for (boolean drop : new boolean[] {false, true}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 1);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(drop ? ClickType.DROP : ClickType.LEFT,
|
||||
drop ? InventoryAction.DROP_ONE_SLOT : InventoryAction.PICKUP_ALL)).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();
|
||||
if (!drop) attempt.abandon();
|
||||
assertEquals(drop ? ManualCraftDelivery.Result.UNSUPPORTED_ROUTE : ManualCraftDelivery.Result.UNAVAILABLE,
|
||||
ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(1, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(fixture.view.getCursor().isEmpty());
|
||||
assertEquals(java.util.Set.of(record.id()), attempt.abandon().join().removed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMenuChangeDuringRecomputationDoesNotWriteIntoTheNewMenusCursor() throws Exception {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 1);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.RIGHT, InventoryAction.PICKUP_HALF)).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();
|
||||
fixture.menu.onChange = () -> {
|
||||
fixture.activeMenu = new ManualCraftPlanNativeTest.Menu();
|
||||
fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
|
||||
};
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asCraftMirror(fixture.menu.getCarried())).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExceptionAfterRecomputationBeginsNeverAuthorizesDeletionOrInventoryRollback() throws Exception {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 1);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).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();
|
||||
fixture.menu.onChange = () -> { throw new IllegalStateException("Simulated post-commit callback failure"); };
|
||||
assertThrows(IllegalStateException.class, () -> ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(record.id(), new CharacterSheets().identify(fixture.view.getCursor()).orElseThrow());
|
||||
assertTrue(fixture.grid.getMatrix()[0] == null || fixture.grid.getMatrix()[0].isEmpty());
|
||||
assertEquals(java.util.Set.of(record.id()), attempt.abandon().join().retained());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.ItemStack[] copy(org.bukkit.inventory.ItemStack[] items) {
|
||||
return java.util.Arrays.stream(items).map(item -> item == null ? null : item.clone())
|
||||
.toArray(org.bukkit.inventory.ItemStack[]::new);
|
||||
}
|
||||
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void cursorCraftDebitsOneIngredientSetAndDeliversTheSavedSheetBeforeOneRecomputation() throws Exception {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(width, 1);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
var character = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(List.of(character)).ready().join();
|
||||
var changes = new AtomicInteger();
|
||||
fixture.menu.onChange = () -> {
|
||||
changes.incrementAndGet();
|
||||
assertTrue(java.util.Arrays.stream(fixture.grid.getMatrix()).allMatch(item -> item == null || item.isEmpty()),
|
||||
"A prepare callback must not observe a partially debited ingredient set");
|
||||
assertEquals(character.id(), new CharacterSheets().identify(fixture.view.getCursor()).orElseThrow(),
|
||||
"The final output must exist before recomputation callbacks run");
|
||||
};
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(1, changes.get());
|
||||
assertEquals(character.id(), new CharacterSheets().identify(fixture.view.getCursor()).orElseThrow());
|
||||
assertEquals(character, storage.find(character.id()).join().orElseThrow());
|
||||
assertNotEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(1, changes.get(), "Duplicate completion must not debit or notify again");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,8 +261,31 @@ class ManualCraftPlanNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
@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;
|
||||
final TransientCraftingContainer matrix;
|
||||
final CraftInventoryCrafting grid;
|
||||
final CraftInventoryPlayer inventory = new CraftInventoryPlayer(
|
||||
@@ -270,19 +293,29 @@ 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(menu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { menu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
@Override public String getTitle() { return "Crafting fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title change"); }
|
||||
@@ -314,14 +347,15 @@ class ManualCraftPlanNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Menu extends AbstractContainerMenu {
|
||||
static final class Menu extends AbstractContainerMenu {
|
||||
InventoryView view;
|
||||
Runnable onChange = () -> { };
|
||||
Menu() { super(null, 0); }
|
||||
@Override public InventoryView getBukkitView() { return view; }
|
||||
@Override public boolean stillValid(net.minecraft.world.entity.player.Player player) { return true; }
|
||||
@Override public net.minecraft.world.item.ItemStack quickMoveStack(net.minecraft.world.entity.player.Player player, int slot) {
|
||||
throw new AssertionError("This fixture does not execute vanilla result consumption");
|
||||
}
|
||||
@Override public void slotsChanged(net.minecraft.world.Container inventory) { }
|
||||
@Override public void slotsChanged(net.minecraft.world.Container inventory) { onChange.run(); }
|
||||
}
|
||||
}
|
||||
@@ -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