Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 14fc0dbde0 feat(crafting): deliver saved sheets to fixed destinations
Release / release (push) Successful in 5m10s
CI / build (push) Successful in 3m15s
2026-09-17 23:30:27 -04:00
dmg d10e804820 feat(crafting): allocate fresh named characters
Release / release (push) Successful in 5m4s
CI / build (push) Successful in 2m44s
2026-09-17 23:12:33 -04:00
dmg 7cf91baca4 fix(persistence): bound character file coordination
Release / release (push) Successful in 5m7s
CI / build (push) Successful in 3m1s
2026-09-17 22:56:15 -04:00
10 changed files with 539 additions and 16 deletions

No files matched your search

+14 -2
View File
@@ -37,19 +37,31 @@ File-level atomic replacement is **not** atomicity with Minecraft inventory/play
Abandonment skips remaining queued writes and schedules cleanup for acknowledged, never-issued creations. Cleanup checks the original file identity/creation and modification attributes and exact bytes under the repository lock. Changed, replaced, corrupt, symlinked, busy or unidentifiable files are retained. Partial-save failure cleans only acknowledged creations and reports uncertain targets without deleting them. Shutdown rejection preserves records rather than inventing successful cleanup. Repeated cleanup cannot delete a later file. Restart never reconstructs cleanup authority from revision zero or scans records for automatic deletion/reissue.
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. Empty coordination `.lock` files remain after YAML cleanup; they must not be unlinked casually because that can split locking between writers. Their file-count growth still needs a bounded coordination design before gameplay registration.
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. All create/update/cleanup mutations now share one fail-fast `characters/.records.lock` coordination file, so abandoning new characters does not accumulate one lock file per ID. Read-only atomic-file loads remain unlocked. The file stays present but its OS lock is held only for an operation, not an administrator-unlock flag. Never unlink it while writers may use it. Old per-ID lock files are left intact; no new ones are created.
**Upgrade boundary:** stop all older per-ID-protocol writers before opening the same data directory with this shared-lock implementation. Mixing versions or hot-reloading while old worker writes remain queued is unsafe. YAML format and record filenames are unchanged; no deployment or existing-server data migration has been performed. Contention between cooperating repository instances fails without mutation and can be retried, rather than blocking a server tick or deleting coordination files.
## Paper sheet and recipe definition
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The actual craft-event controller is still absent.
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations; register the guard with the eventual completed-crafting adapter before exposing the recipe. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
## Manual craft planning (not delivery)
`ManualCraftPlan` reads real crafting inventories without mutation. It calculates one result for cursor/number/offhand clicks, distinct empty destination slots for shift-click, and single/bulk intent for Q/Ctrl-Q. It bounds quantity by ingredients and storage capacity, preserves native reverse storage order, rejects cancelled/non-craft/wrong-recipe/malformed input, and snapshots full item data rather than retaining mutable native stack mirrors. Returned leftovers are detached copies; `matches` detects changed inputs, result, recipe, cursor or planned destinations without overwriting intervening items.
This is **not a transaction commit**. Nine native planner 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), name/ID allocation, 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.
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.
## Native equipment adapter
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.CharacterProgress;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
/** Allocation only; invoke for an accepted craft, never for recipe previews or activation. */
public final class CharacterFactory {
private static final String[] GIVEN_NAMES = {"Ari", "Bryn", "Elin", "Finn", "Iris", "Kael", "Mira", "Neris",
"Orin", "Rhea", "Soren", "Tala", "Vera", "Wren", "Yara", "Zev"};
private static final String[] FAMILY_NAMES = {"Ash", "Birch", "Brook", "Dawn", "Ember", "Fern", "Flint", "Glen",
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
private final Supplier<UUID> identities;
private final RandomGenerator names;
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
this.identities = Objects.requireNonNull(identities);
this.names = Objects.requireNonNull(names);
}
public synchronized CharacterRecord create(ProgressionRules rules) {
Objects.requireNonNull(rules);
var initial = new CharacterProgress(identities.get(), rules).snapshot();
String name = GIVEN_NAMES[names.nextInt(GIVEN_NAMES.length)] + " "
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
}
}
@@ -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;
}
}
@@ -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();
@@ -58,7 +58,7 @@ public final class YamlCharacterRepository {
Creation createTracked(CharacterRecord character) throws IOException {
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
Creation[] created = new Creation[1];
withLock(character.id(), () -> {
withLock(() -> {
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Character already exists: " + character.id());
}
@@ -71,7 +71,7 @@ public final class YamlCharacterRepository {
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
throw new IOException("Character update must advance exactly one revision");
}
withLock(character.id(), () -> {
withLock(() -> {
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
write(character);
@@ -81,7 +81,7 @@ public final class YamlCharacterRepository {
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
boolean discardUnissued(Creation created) throws IOException {
boolean[] removed = {false};
withLock(created.record.id(), () -> {
withLock(() -> {
var path = file(created.record.id());
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
removed[0] = true;
@@ -138,9 +138,10 @@ public final class YamlCharacterRepository {
}
}
private void withLock(UUID id, Operation operation) throws IOException {
/** Bounded coordination for all mutations; never mix with older per-ID writers in this directory. */
private void withLock(Operation operation) throws IOException {
Files.createDirectories(directory);
Path lockPath = directory.resolve(id + ".lock");
Path lockPath = directory.resolve(".records.lock");
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
@@ -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");
}
}
}
}
@@ -224,8 +224,46 @@ class ManualCraftPlanNativeTest {
assertEquals(2, fixture.grid.getResult().getAmount());
}
private static final class Fixture {
@Test
void plannedBatchCanPersistAndRenderDistinctNamedSheetsWithoutApplyingInventoryChanges(
@org.junit.jupiter.api.io.TempDir java.nio.file.Path data) throws Exception {
var fixture = new Fixture(3, 4);
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
var factory = new games.dmg.fantasystick.crafting.CharacterFactory();
var records = java.util.stream.IntStream.range(0, plan.quantity())
.mapToObj(index -> factory.create(games.dmg.fantasystick.progression.ProgressionRules.defaults())).toList();
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
var batch = storage.prepareCraft(records).ready().join(); // Test thread, never the runtime tick thread.
assertTrue(plan.matches(fixture.view));
var saved = batch.claimForDelivery().orElseThrow();
var identities = new java.util.HashSet<java.util.UUID>();
var sheets = new CharacterSheets();
var ops = net.minecraft.resources.RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
for (var record : saved) {
assertEquals(record, storage.find(record.id()).join().orElseThrow());
var sheet = sheets.create(record);
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops,
CraftItemStack.asNMSCopy(sheet)).getOrThrow();
var decoded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
assertTrue(identities.add(sheets.identify(decoded).orElseThrow()));
assertEquals(net.kyori.adventure.text.Component.text(record.name()), decoded.getItemMeta().displayName());
assertEquals(1, decoded.getAmount());
assertEquals(record, repository.load(record.id()).orElseThrow());
}
assertEquals(plan.quantity(), identities.size());
assertEquals(4, fixture.grid.getMatrix()[0].getAmount(), "This composition test does not commit a native craft");
assertNull(fixture.inventory.getItem(8));
assertTrue(sheets.identify(fixture.grid.getResult()).isEmpty());
assertTrue(batch.claimForDelivery().isEmpty());
}
}
static final class Fixture {
final Menu menu = new Menu();
Menu activeMenu = menu;
final TransientCraftingContainer matrix;
final CraftInventoryCrafting grid;
final CraftInventoryPlayer inventory = new CraftInventoryPlayer(
@@ -244,8 +282,8 @@ class ManualCraftPlanNativeTest {
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
@Override public HumanEntity getPlayer() { throw new AssertionError("Unexpected player/network access"); }
@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"); }
@@ -277,14 +315,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,64 @@
package games.dmg.fantasystick.crafting;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class CharacterFactoryTest {
@Test
void randomNamesVaryButNamesMayCollideWithoutSharingIdentity() {
var first = new CharacterFactory(UUID::randomUUID, new Random(7));
var replay = new CharacterFactory(UUID::randomUUID, new Random(7));
var names = new java.util.HashSet<String>();
var ids = new java.util.HashSet<UUID>();
for (int index = 0; index < 64; index++) {
var character = first.create(ProgressionRules.defaults());
assertEquals(character.name(), replay.create(ProgressionRules.defaults()).name());
assertTrue(ids.add(character.id()));
assertEquals(BigInteger.valueOf(40), character.unspentPoints());
names.add(character.name());
}
assertTrue(names.size() > 1, "Names must use the supplied randomness, not a constant placeholder");
var collisions = new CharacterFactory(UUID::randomUUID, () -> 0L);
var sameNameA = collisions.create(ProgressionRules.defaults());
var sameNameB = collisions.create(ProgressionRules.defaults());
assertEquals(sameNameA.name(), sameNameB.name(), "Name collisions do not require a retry or owner lock");
assertNotEquals(sameNameA.id(), sameNameB.id());
}
@Test
void allocatesOnlyWhenRequestedAndUsesTheApprovedFreshStateAndConfiguredPoints() {
var allocations = new AtomicInteger();
var id = UUID.randomUUID();
var factory = new CharacterFactory(() -> { allocations.incrementAndGet(); return id; }, new Random(17));
assertEquals(0, allocations.get(), "Constructing a factory must not create a character");
var rules = new ProgressionRules(BigInteger.TEN.pow(40), BigInteger.valueOf(100), 30);
var record = factory.create(rules);
assertNotNull(record, "An accepted allocation must produce a fresh named character");
assertEquals(1, allocations.get());
assertEquals(id, record.id());
assertFalse(record.name().isBlank());
assertEquals(BigInteger.ZERO, record.revision());
assertEquals(6, record.stats().size());
assertTrue(record.stats().values().stream().allMatch(BigInteger.ZERO::equals));
assertEquals(1, record.level());
assertEquals(BigInteger.ZERO, record.experience());
assertEquals(rules.startingPoints(), record.unspentPoints());
assertEquals(BigDecimal.TEN, record.health());
assertEquals(Set.of(), record.learned());
assertEquals(List.of("", "", "", "", "", "", ""), record.loadout());
assertEquals(Map.of(), record.cooldowns());
assertFalse(record.dead());
assertNull(record.deathId());
assertNull(record.diedAt());
}
}
@@ -267,17 +267,18 @@ class CraftPersistenceTest {
CharacterStorage.CraftCleanup cleanup;
if (scenario == 3) {
try (var channel = java.nio.channels.FileChannel.open(
data.resolve("characters").resolve(protectedRecord.id() + ".lock"),
data.resolve("characters").resolve(".records.lock"),
java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
cleanup = batch.abandon().join();
}
} else cleanup = batch.abandon().join();
assertEquals(Set.of(protectedRecord.id()), cleanup.retained());
assertEquals(Set.of(removable.id()), cleanup.removed());
assertEquals(scenario == 3 ? Set.of(protectedRecord.id(), removable.id())
: Set.of(protectedRecord.id()), cleanup.retained());
assertEquals(scenario == 3 ? Set.of() : Set.of(removable.id()), cleanup.removed());
assertTrue(java.nio.file.Files.exists(file, java.nio.file.LinkOption.NOFOLLOW_LINKS));
assertTrue(storage.find(removable.id()).join().isEmpty());
assertEquals(scenario != 3, storage.find(removable.id()).join().isEmpty());
if (scenario == 2) {
assertTrue(java.nio.file.Files.isSymbolicLink(file));
assertTrue(java.nio.file.Files.isRegularFile(data.resolve("protected-target.yml")));
@@ -43,6 +43,67 @@ class YamlCharacterRepositoryTest {
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
}
@Test
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
var repository = new YamlCharacterRepository(data);
for (int index = 0; index < 96; index++) {
var created = repository.createTracked(sample(UUID.randomUUID()));
assertTrue(repository.discardUnissued(created));
}
try (var paths = Files.list(data.resolve("characters"))) {
var remaining = paths.toList();
assertTrue(remaining.size() <= 1, "Abandoned crafts must not leave one coordination file per generated ID");
assertTrue(remaining.stream().allMatch(path -> path.getFileName().toString().endsWith(".lock")));
}
var legitimate = sample(UUID.randomUUID());
repository.create(legitimate);
assertEquals(legitimate, repository.load(legitimate.id()).orElseThrow());
}
@Test
void sharedCoordinationRefusesEveryMutationWhileBusyAndPermitsRetryAfterRelease() throws Exception {
var repository = new YamlCharacterRepository(data);
var original = sample(UUID.randomUUID());
var unissued = repository.createTracked(sample(UUID.randomUUID()));
var candidate = sample(UUID.randomUUID());
repository.create(original);
var update = rename(original, "Retry after contention", BigInteger.ONE);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
assertThrows(IOException.class, () -> new YamlCharacterRepository(data).create(candidate));
assertThrows(IOException.class, () -> repository.replace(update, BigInteger.ZERO));
assertThrows(IOException.class, () -> repository.discardUnissued(unissued));
assertEquals(original, repository.load(original.id()).orElseThrow());
assertEquals(unissued.record, repository.load(unissued.record.id()).orElseThrow());
assertTrue(repository.load(candidate.id()).isEmpty());
}
repository.create(candidate);
repository.replace(update, BigInteger.ZERO);
assertTrue(repository.discardUnissued(unissued));
assertEquals(update, repository.load(original.id()).orElseThrow());
}
@Test
void leavesLegacyCoordinationFilesIntactAndRefusesASymlinkedSharedLock() throws Exception {
var repository = new YamlCharacterRepository(data);
var original = sample(UUID.randomUUID());
repository.create(original);
var legacy = data.resolve("characters").resolve(original.id() + ".lock");
Files.writeString(legacy, "Legacy coordination fixture");
repository.replace(rename(original, "Updated", BigInteger.ONE), BigInteger.ZERO);
assertEquals("Legacy coordination fixture", Files.readString(legacy));
var shared = data.resolve("characters/.records.lock");
Files.delete(shared); // Test setup only: no writer is holding this fixture lock.
var outside = data.resolve("outside-lock");
Files.writeString(outside, "Protected outside fixture");
Files.createSymbolicLink(shared, outside);
assertThrows(IOException.class, () -> repository.create(sample(UUID.randomUUID())));
assertEquals("Protected outside fixture", Files.readString(outside));
assertTrue(Files.isSymbolicLink(shared));
}
@Test
void updatesExactlyTheExpectedRevisionAndNeverOverwritesWithAStaleCopy() throws Exception {
var repository = new YamlCharacterRepository(data);