Compare commits

...
1 Commits
Author SHA1 Message Date
dmg e7912d03ce feat(crafting): coordinate deferred manual craft lifecycles
Release / release (push) Successful in 5m0s
CI / build (push) Successful in 2m31s
2026-09-18 08:12:33 -04:00
4 changed files with 637 additions and 3 deletions

No files matched your search

+9 -1
View File
@@ -69,7 +69,15 @@ Q/Ctrl-Q remain supported scope; no shortcut restriction was accepted or enabled
Thirteen additional tests cover sequential/bulk/cancelled drops, exact remaining-input validation, stale context, changed/removed entities, same-batch reentry and exceptions. Tests combine real native matrices, serialized sheets, YAML and Paper's event dispatcher; world insertion itself remains an explicit API boundary double, supplemented by pinned-runtime bytecode inspection. No live world or packet dispatch is claimed.
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. That controller and recipe registration remain pending.
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
@@ -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);
}
}
@@ -293,16 +293,26 @@ class ManualCraftPlanNativeTest {
@Override public InventoryHolder getOwner() { return null; }
});
final InventoryView view;
HumanEntity human;
boolean failMatrixRead;
Fixture(int width, int amount) {
matrix = new TransientCraftingContainer(menu, width, width);
var result = new ResultContainer();
matrix.resultInventory = result;
grid = new CraftInventoryCrafting(matrix, result);
grid = new CraftInventoryCrafting(matrix, result) {
@Override public ItemStack[] getMatrix() {
if (failMatrixRead) throw new IllegalStateException("Simulated native matrix read failure");
return super.getMatrix();
}
};
view = new CraftAbstractInventoryView() {
@Override public CraftInventoryCrafting getTopInventory() { return grid; }
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
@Override public HumanEntity getPlayer() { throw new AssertionError("Unexpected player/network access"); }
@Override public HumanEntity getPlayer() {
if (human == null) throw new AssertionError("Unexpected player/network access");
return human;
}
@Override public InventoryType getType() { return grid.getType(); }
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
@@ -0,0 +1,441 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.crafting.ManualCraftingController;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.CopyOnWriteArrayList;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.InventoryView;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ManualCraftingControllerNativeTest {
@TempDir Path data;
private final CopyOnWriteArrayList<ManualCraftingController.Failure> unexpected = new CopyOnWriteArrayList<>();
@org.junit.jupiter.api.AfterEach void assertNoUnexpectedDiagnostics() {
assertTrue(unexpected.isEmpty(), () -> "Unexpected diagnostics: " + unexpected);
}
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void acceptedCraftIsContainedSavedAndDeliveredOnlyOnItsDeferredServerCallbackOnce() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var allocated = new ArrayList<CharacterRecord>();
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults());
allocated.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop craft"); }, failures::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
controller.onCraft(event);
assertTrue(event.isCancelled(), "Vanilla must not consume the metadata-empty preview");
assertEquals(1, allocated.size());
var repeat = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
controller.onCraft(repeat);
assertTrue(repeat.isCancelled());
assertEquals(1, allocated.size(), "The pending attempt must not allocate a second batch");
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
assertEquals(allocated.getFirst(), storage.find(allocated.getFirst().id()).join().orElseThrow());
complete.run();
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(allocated.getFirst().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertEquals(1, actor.syncs);
complete.run();
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, actor.syncs);
assertTrue(failures.isEmpty());
}
}
@Test
@SuppressWarnings("try") // Exercise disable during an attempt and idempotent final resource cleanup.
void closingDisconnectingDisablingOrChangingContextBeforeCompletionNeverConsumesOrDelivers() throws Exception {
for (int scenario = 0; scenario < 5; scenario++) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
if (scenario == 0) controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
if (scenario == 1) {
actor.online = false;
controller.onQuit(new org.bukkit.event.player.PlayerQuitEvent(actor.player, net.kyori.adventure.text.Component.empty()));
}
if (scenario == 2) controller.close();
if (scenario == 3) actor.fixture.grid.getMatrix()[0].setAmount(7);
if (scenario == 4) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
complete.run();
assertEquals(scenario == 3 ? 7 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertTrue(actor.current.getCursor().isEmpty());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
}
}
}
@Test
void oldCallbacksCannotDeliverIntoOrRemoveANewAttemptOnTheSameReopenedMenu() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var old = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(old);
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var fresh = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(fresh);
old.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
assertEquals(2, records.size(), "Old completion must not clear the fresh reservation");
fresh.run();
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
assertTrue(storage.find(records.get(1).id()).join().isPresent());
}
}
@Test
void qAndCtrlQUseTheSavedDropPathEvenWithFullPlayerStorage() throws Exception {
for (boolean bulk : new boolean[] {false, true}) {
var actor = new Actor(3, 3);
for (int slot = 0; slot < 36; slot++) actor.fixture.inventory.setItem(slot,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var outputs = new ArrayList<org.bukkit.inventory.ItemStack>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
assertSame(actor.player, player);
assertTrue(current.getAsBoolean());
outputs.add(sheet.clone());
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, unexpected::add)) {
var event = actor.fixture.event(bulk ? ClickType.CONTROL_DROP : ClickType.DROP,
bulk ? InventoryAction.DROP_ALL_SLOT : InventoryAction.DROP_ONE_SLOT);
controller.onCraft(event);
assertTrue(event.isCancelled());
assertEquals(bulk ? 3 : 1, records.size());
assertTrue(outputs.isEmpty());
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(bulk ? 3 : 1, outputs.size());
assertEquals(records.stream().map(CharacterRecord::id).toList(),
outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList());
var first = actor.fixture.grid.getMatrix()[0];
assertEquals(bulk ? 0 : 2, first == null ? 0 : first.getAmount());
assertEquals(64, actor.fixture.inventory.getItem(8).getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(1, actor.syncs);
for (var record : records) assertTrue(storage.find(record.id()).join().isPresent());
}
}
}
@Test
void deliveryAndClientSyncKeepAReservationEvenAcrossCloseReopenAndReentrantCompletion() throws Exception {
for (int scenario = 0; scenario < 3; scenario++) {
var actor = new Actor(2, 3);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
var listener = new java.util.concurrent.atomic.AtomicReference<ManualCraftingController>();
var completion = new java.util.concurrent.atomic.AtomicReference<Runnable>();
int mode = scenario;
Runnable reopenAndCraft = () -> {
listener.get().onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
var again = actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
listener.get().onCraft(again);
assertTrue(again.isCancelled());
};
if (mode == 2) actor.onSync = reopenAndCraft;
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
if (mode == 0) completion.get().run();
if (mode == 1) reopenAndCraft.run();
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, failures::add)) {
listener.set(controller);
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
completion.set(tasks.poll(5, TimeUnit.SECONDS)); assertNotNull(completion.get());
completion.get().run();
assertEquals(1, records.size(), "Reentrant close must not open a second allocation window during delivery/sync");
assertEquals(mode == 1 ? 3 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, actor.syncs);
assertEquals(mode == 1 ? java.util.List.of("drop-unconfirmed") : java.util.List.of(),
failures.stream().map(ManualCraftingController.Failure::operation).toList());
assertTrue(tasks.isEmpty());
}
}
}
@Test
void rejectedSchedulingAbandonsUnissuedFilesWithoutWorkerPlayerAccessAndPermitsRetry() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var reject = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, task -> {
if (reject.getAndSet(false)) throw new java.util.concurrent.RejectedExecutionException("scheduler unavailable");
tasks.add(task);
}, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var problem = problems.poll(5, TimeUnit.SECONDS);
assertNotNull(problem, "Scheduling rejection must not silently orphan the pending attempt");
assertEquals("schedule", problem.operation());
assertEquals(actor.id, problem.player());
assertEquals(java.util.List.of(records.getFirst().id()), problem.characters());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(0, actor.syncs);
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(2, records.size());
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
}
}
@Test
void allocationAndSaveFailuresLeaveInventoryUnchangedAndDoNotBlockTheNextCraft() throws Exception {
for (boolean failAllocation : new boolean[] {true, false}) {
var directory = data.resolve(failAllocation ? "allocation" : "save");
java.nio.file.Files.createDirectories(directory);
if (!failAllocation) java.nio.file.Files.writeString(directory.resolve("characters"), "blocked directory fixture");
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var firstAllocation = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var storage = new CharacterStorage(new YamlCharacterRepository(directory), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
if (firstAllocation.getAndSet(false) && failAllocation) throw new IllegalStateException("allocation unavailable");
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
assertDoesNotThrow(() -> controller.onCraft(event));
assertTrue(event.isCancelled());
if (!failAllocation) { var failed = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run(); }
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
assertEquals(failAllocation ? "prepare" : "save", problem.operation());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
if (!failAllocation) java.nio.file.Files.delete(directory.resolve("characters"));
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var retry = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(retry); retry.run();
assertEquals(records.getLast().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
}
}
}
@Test
void deliveryAndSyncExceptionsAreReportedWithoutReplayDeletionOrLeakingTheReservation() throws Exception {
for (boolean syncFailure : new boolean[] {false, true}) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
if (syncFailure) actor.onSync = () -> { throw new IllegalStateException("sync failed"); };
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
if (!syncFailure) throw new IllegalStateException("world callback failed");
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
assertDoesNotThrow(complete::run);
assertEquals(java.util.List.of(syncFailure ? "sync" : "delivery"),
problems.stream().map(ManualCraftingController.Failure::operation).toList());
assertTrue(storage.find(records.getFirst().id()).join().isPresent());
assertEquals(syncFailure ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
complete.run();
assertEquals(1, problems.size());
actor.onSync = () -> { };
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
assertEquals(2, records.size(), "An exception must release the reservation for a separate new intent");
}
}
}
@Test
@SuppressWarnings("try")
void preservesOtherRecipesAndCancellationsAndAllocatesNothingForRejectedOrStaleIntents() throws Exception {
var annotation = ManualCraftingController.class.getMethod("onCraft", org.bukkit.event.inventory.CraftItemEvent.class)
.getAnnotation(org.bukkit.event.EventHandler.class);
assertEquals(org.bukkit.event.EventPriority.HIGHEST, annotation.priority());
assertTrue(annotation.ignoreCancelled());
for (int scenario = 0; scenario < 7; scenario++) {
var actor = new Actor(2, 2);
var allocations = new java.util.concurrent.atomic.AtomicInteger();
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
if (scenario == 0) event.setCancelled(true);
if (scenario == 1) event = new org.bukkit.event.inventory.CraftItemEvent(new org.bukkit.inventory.ShapelessRecipe(
new org.bukkit.NamespacedKey("unrelated", "character_sheet"), new org.bukkit.inventory.ItemStack(org.bukkit.Material.PAPER))
.addIngredient(org.bukkit.Material.STICK), actor.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
if (scenario == 2) actor.fixture.grid.setResult(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
if (scenario == 3) actor.fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
if (scenario == 4) actor.online = false;
if (scenario == 5) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
}, task -> { }, player -> { throw new AssertionError("Not a drop"); }, problem -> { })) {
assertEquals(0, allocations.get());
if (scenario == 6) controller.close();
controller.onCraft(event);
assertEquals(scenario != 1, event.isCancelled());
assertEquals(0, allocations.get(), "Rejected intent " + scenario + " must not mint identities");
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
}
}
}
@Test
void acceptedOpenInvalidatesTheLifetimeButACancelledOpenDoesNotInventATransition() throws Exception {
for (boolean cancelled : new boolean[] {false, true}) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
var open = new org.bukkit.event.inventory.InventoryOpenEvent(actor.fixture.view);
open.setCancelled(cancelled);
controller.onOpen(open);
complete.run();
assertEquals(cancelled ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(cancelled, storage.find(record.id()).join().isPresent());
assertEquals(!cancelled, actor.fixture.view.getCursor().isEmpty());
}
}
}
@Test
void retainedUnissuedFilesAreReportedOnceWithoutDeletingEditsOrTouchingThePlayerFromTheWorker() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
var file = data.resolve("characters").resolve(record.id() + ".yml");
java.nio.file.Files.writeString(file, "\n", java.nio.file.StandardOpenOption.APPEND);
var changed = java.nio.file.Files.readAllBytes(file);
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
assertEquals("cleanup-retained", problem.operation());
assertEquals(java.util.List.of(record.id()), problem.characters());
complete.run(); complete.run();
assertArrayEquals(changed, java.nio.file.Files.readAllBytes(file));
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(problems.isEmpty());
}
}
@Test
void aNativePlanningExceptionStillContainsVanillaAndReportsWithoutAllocating() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
var allocations = new java.util.concurrent.atomic.AtomicInteger();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
actor.fixture.failMatrixRead = true;
assertDoesNotThrow(() -> controller.onCraft(event));
assertTrue(event.isCancelled(), "A planner exception must not allow vanilla to consume the plain preview");
assertEquals(0, allocations.get());
assertTrue(tasks.isEmpty());
assertEquals(java.util.List.of("plan"), problems.stream().map(ManualCraftingController.Failure::operation).toList());
actor.fixture.failMatrixRead = false;
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
assertEquals(1, allocations.get());
assertTrue(new CharacterSheets().identify(actor.fixture.view.getCursor()).isPresent());
}
}
static final class Actor {
final ManualCraftPlanNativeTest.Fixture fixture;
final UUID id = UUID.randomUUID();
final Player player;
final Thread serverThread = Thread.currentThread();
InventoryView current;
boolean online = true;
int syncs;
Runnable onSync = () -> { };
Actor(int width, int amount) {
fixture = new ManualCraftPlanNativeTest.Fixture(width, amount);
current = fixture.view;
player = Player.class.cast(Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[] {Player.class},
(object, method, args) -> {
assertSame(serverThread, Thread.currentThread(), "No native player access on the persistence worker");
return switch (method.getName()) {
case "getUniqueId" -> id;
case "getOpenInventory" -> current;
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "equals" -> object == args[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Crafting actor fixture";
default -> throw new AssertionError("Unexpected player call " + method.getName());
};
}));
fixture.human = player;
}
}
}