Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10e804820 | ||
|
|
7cf91baca4 | ||
|
|
3215697e3f | ||
|
|
f4cb7a3914 |
No files matched your search
@@ -31,12 +31,30 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
|
||||
|
||||
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or player/session persistence.
|
||||
|
||||
## Craft preparation and conservative cleanup
|
||||
|
||||
`CharacterStorage.prepareCraft` accepts an immutable batch of distinct fresh character records and returns a process-local `CraftPreparation` handle. Allocation/configured starting points remain the caller's responsibility. Retain this handle until delivery or abandonment: `ready()` returns an observation copy, and cancelling/timing out that future does **not** cancel the owned operation. Use `abandon()` to invalidate the attempt, including while writes are queued/in progress. Writes acknowledge the complete prepared batch; `claimForDelivery()` exposes it once, immediately before native delivery. Claiming permanently rules out cleanup, even if later delivery throws.
|
||||
|
||||
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. 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**. 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.
|
||||
|
||||
## 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.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,144 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
|
||||
/** Server-thread planning only: does not save, mint, cancel, consume or deliver anything. */
|
||||
public final class ManualCraftPlan {
|
||||
public enum Route { CURSOR, INVENTORY, DROP }
|
||||
private final ItemStack[] matrix;
|
||||
private final InventoryView view;
|
||||
private final CraftingInventory grid;
|
||||
private final PlayerInventory inventory;
|
||||
private final ItemStack result;
|
||||
private final ItemStack cursor;
|
||||
private final int quantity;
|
||||
private final Route route;
|
||||
private final List<Integer> inventorySlots;
|
||||
|
||||
private ManualCraftPlan(CraftItemEvent event, ItemStack[] matrix, int quantity, Route route, List<Integer> inventorySlots) {
|
||||
this.view = event.getView();
|
||||
this.grid = event.getInventory();
|
||||
this.inventory = (PlayerInventory) view.getBottomInventory();
|
||||
this.result = copy(grid.getResult());
|
||||
this.cursor = copy(view.getCursor());
|
||||
this.matrix = copy(matrix);
|
||||
this.quantity = quantity;
|
||||
this.route = route;
|
||||
this.inventorySlots = List.copyOf(inventorySlots);
|
||||
}
|
||||
|
||||
public static Optional<ManualCraftPlan> capture(CraftItemEvent event) {
|
||||
if (event.isCancelled() || event.getRawSlot() != 0 || event.getSlotType() != InventoryType.SlotType.RESULT
|
||||
|| !isSheetRecipe(event.getRecipe())
|
||||
|| !(event.getView().getTopInventory() instanceof CraftingInventory)
|
||||
|| !isSheetRecipe(event.getInventory().getRecipe())
|
||||
|| !(event.getView().getBottomInventory() instanceof PlayerInventory)
|
||||
|| empty(event.getCurrentItem()) || !event.getRecipe().getResult().equals(event.getCurrentItem())
|
||||
|| !new CharacterSheets().recipe().getResult().equals(event.getCurrentItem())
|
||||
|| !event.getInventory().getRecipe().getResult().equals(event.getCurrentItem())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var matrix = event.getInventory().getMatrix();
|
||||
if (matrix.length != 4 && matrix.length != 9) return Optional.empty();
|
||||
var required = EnumSet.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
|
||||
int limit = Integer.MAX_VALUE;
|
||||
for (var item : matrix) {
|
||||
if (empty(item)) continue;
|
||||
if (!required.remove(item.getType()) || item.getAmount() > item.getMaxStackSize()) return Optional.empty();
|
||||
limit = Math.min(limit, item.getAmount());
|
||||
}
|
||||
if (!required.isEmpty()) return Optional.empty();
|
||||
if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY && event.isShiftClick()) {
|
||||
var slots = new ArrayList<Integer>();
|
||||
// Vanilla reverse result transfer: hotbar 8..0, then main storage 35..9.
|
||||
for (int slot = 8; slot >= 0 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
for (int slot = 35; slot >= 9 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
if (slots.isEmpty()) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, slots.size(), Route.INVENTORY, slots));
|
||||
}
|
||||
if (event.getAction() == InventoryAction.HOTBAR_SWAP) {
|
||||
int slot;
|
||||
if (event.getClick() == ClickType.SWAP_OFFHAND) slot = 40;
|
||||
else if (event.getClick() == ClickType.NUMBER_KEY && event.getHotbarButton() >= 0
|
||||
&& event.getHotbarButton() <= 8) slot = event.getHotbarButton();
|
||||
else return Optional.empty();
|
||||
if (!empty(event.getView().getBottomInventory().getItem(slot))) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.INVENTORY, List.of(slot)));
|
||||
}
|
||||
boolean singleDrop = event.getClick() == ClickType.DROP && event.getAction() == InventoryAction.DROP_ONE_SLOT;
|
||||
boolean bulkDrop = event.getClick() == ClickType.CONTROL_DROP && event.getAction() == InventoryAction.DROP_ALL_SLOT;
|
||||
if ((singleDrop || bulkDrop) && empty(event.getCursor())) {
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, bulkDrop ? limit : 1, Route.DROP, List.of()));
|
||||
}
|
||||
boolean cursorClick = event.getClick() == ClickType.LEFT && event.getAction() == InventoryAction.PICKUP_ALL
|
||||
|| event.getClick() == ClickType.RIGHT && event.getAction() == InventoryAction.PICKUP_HALF;
|
||||
if (!cursorClick || !empty(event.getCursor())) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.CURSOR, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Content check only, not a commit or session authorization. The caller must also invalidate
|
||||
* tokens on close/reopen, disconnect and disable, including reuse of the same native menu.
|
||||
* Never call this from a worker or use an old view to write a new menu's cursor.
|
||||
*/
|
||||
public boolean matches(InventoryView current) {
|
||||
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()))
|
||||
&& Objects.equals(cursor, copy(current.getCursor()))
|
||||
&& inventorySlots.stream().allMatch(slot -> empty(inventory.getItem(slot)));
|
||||
}
|
||||
|
||||
public int quantity() { return quantity; }
|
||||
public Route route() { return route; }
|
||||
public List<Integer> inventorySlots() { return inventorySlots; }
|
||||
|
||||
public ItemStack[] remainingMatrix() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private static boolean isSheetRecipe(Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed && CharacterSheets.RECIPE.equals(keyed.getKey());
|
||||
}
|
||||
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
private static ItemStack copy(ItemStack item) { return empty(item) ? null : item.clone(); }
|
||||
|
||||
private static ItemStack[] copy(ItemStack[] items) {
|
||||
var result = new ItemStack[items.length];
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
result[slot] = copy(items[slot]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -33,6 +37,152 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare already allocated fresh characters off-thread. The caller validates configured
|
||||
* starting points and retains the returned handle through success, cancellation and disable.
|
||||
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
|
||||
*/
|
||||
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
|
||||
var snapshot = List.copyOf(records);
|
||||
var ids = new HashSet<UUID>();
|
||||
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
|
||||
for (var record : snapshot) {
|
||||
if (!ids.add(record.id()) || record.revision().signum() != 0 || record.name().isBlank()
|
||||
|| record.dead() || record.deathId() != null || record.level() != 1
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
}
|
||||
return new CraftPreparation(snapshot);
|
||||
}
|
||||
|
||||
public final class CraftPreparation {
|
||||
private final List<CharacterRecord> requested;
|
||||
private final CompletableFuture<CraftBatch> completion;
|
||||
private volatile boolean abandoned;
|
||||
private CompletableFuture<CraftCleanup> cleanup;
|
||||
|
||||
private CraftPreparation(List<CharacterRecord> records) {
|
||||
requested = records;
|
||||
completion = writeCraftBatch(records, this);
|
||||
}
|
||||
|
||||
/** Observation only; cancelling/timing out this copy does not cancel the owned operation. */
|
||||
public CompletableFuture<CraftBatch> ready() { return completion.copy(); }
|
||||
|
||||
/** Retain this handle through completion; invalidate the attempt, not an observation future. */
|
||||
public synchronized CompletableFuture<CraftCleanup> abandon() {
|
||||
abandoned = true;
|
||||
if (cleanup == null) {
|
||||
cleanup = completion.handle((batch, failure) -> {
|
||||
if (batch != null) return batch.abandon();
|
||||
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
|
||||
var outcome = cause instanceof CraftPreparationException preparation ? preparation.cleanup()
|
||||
: retainAll(requested);
|
||||
return CompletableFuture.completedFuture(outcome);
|
||||
}).thenCompose(result -> result);
|
||||
}
|
||||
return cleanup.copy();
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<CraftBatch> writeCraftBatch(List<CharacterRecord> snapshot, CraftPreparation owner) {
|
||||
return submit(() -> {
|
||||
var created = new ArrayList<YamlCharacterRepository.Creation>();
|
||||
UUID attempted = null;
|
||||
try {
|
||||
for (var record : snapshot) {
|
||||
if (owner.abandoned) break;
|
||||
attempted = record.id();
|
||||
created.add(repository.createTracked(record));
|
||||
attempted = null;
|
||||
}
|
||||
return new CraftBatch(created, owner);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
var cleanup = discardUnissued(created);
|
||||
var retained = new HashSet<>(cleanup.retained());
|
||||
if (attempted != null) retained.add(attempted);
|
||||
throw new CraftPreparationException(failure, new CraftCleanup(cleanup.removed(), retained));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static final class CraftPreparationException extends IOException {
|
||||
@java.io.Serial private static final long serialVersionUID = 1L;
|
||||
private final transient CraftCleanup cleanup;
|
||||
|
||||
private CraftPreparationException(Throwable cause, CraftCleanup cleanup) {
|
||||
super("Character craft preparation failed; no batch was issued", cause);
|
||||
this.cleanup = cleanup;
|
||||
}
|
||||
|
||||
public CraftCleanup cleanup() { return cleanup; }
|
||||
}
|
||||
|
||||
/** Removed includes already-absent files; retained includes failed writes whose outcome is unknown. */
|
||||
public record CraftCleanup(Set<UUID> removed, Set<UUID> retained) {
|
||||
public CraftCleanup {
|
||||
removed = Set.copyOf(removed);
|
||||
retained = Set.copyOf(retained);
|
||||
}
|
||||
}
|
||||
|
||||
public final class CraftBatch {
|
||||
private final List<CharacterRecord> records;
|
||||
private final List<YamlCharacterRepository.Creation> created;
|
||||
private final CraftPreparation owner;
|
||||
private boolean resolved;
|
||||
private CompletableFuture<CraftCleanup> cleanup;
|
||||
|
||||
private CraftBatch(List<YamlCharacterRepository.Creation> created, CraftPreparation owner) {
|
||||
this.owner = owner;
|
||||
this.created = List.copyOf(created);
|
||||
this.records = created.stream().map(creation -> creation.record).toList();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
resolved = true;
|
||||
if (worker.isShutdown()) return Optional.empty();
|
||||
return Optional.of(records);
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<CraftCleanup> abandon() {
|
||||
if (cleanup == null) {
|
||||
if (resolved) {
|
||||
cleanup = CompletableFuture.completedFuture(retainAll(records));
|
||||
} else {
|
||||
resolved = true;
|
||||
cleanup = submit(() -> discardUnissued(created)).exceptionally(failure -> retainAll(records));
|
||||
}
|
||||
}
|
||||
return cleanup.copy();
|
||||
}
|
||||
}
|
||||
|
||||
private static CraftCleanup retainAll(List<CharacterRecord> records) {
|
||||
return new CraftCleanup(Set.of(), records.stream().map(CharacterRecord::id)
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
private CraftCleanup discardUnissued(List<YamlCharacterRepository.Creation> created) {
|
||||
var removed = new HashSet<UUID>();
|
||||
var retained = new HashSet<UUID>();
|
||||
for (var creation : created) {
|
||||
try {
|
||||
if (repository.discardUnissued(creation)) removed.add(creation.record.id());
|
||||
else retained.add(creation.record.id());
|
||||
} catch (IOException exception) {
|
||||
retained.add(creation.record.id());
|
||||
}
|
||||
}
|
||||
return new CraftCleanup(removed, retained);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> repository.load(id));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -37,26 +39,70 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
|
||||
public void create(CharacterRecord character) throws IOException {
|
||||
createTracked(character);
|
||||
}
|
||||
|
||||
/** Process-local proof; never reconstructed from a revision-zero file after restart. */
|
||||
static final class Creation {
|
||||
final CharacterRecord record;
|
||||
private final BasicFileAttributes attributes;
|
||||
private final byte[] bytes;
|
||||
|
||||
private Creation(CharacterRecord record, BasicFileAttributes attributes, byte[] bytes) {
|
||||
this.record = record;
|
||||
this.attributes = attributes;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
Creation createTracked(CharacterRecord character) throws IOException {
|
||||
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
|
||||
withLock(character.id(), () -> {
|
||||
Creation[] created = new Creation[1];
|
||||
withLock(() -> {
|
||||
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Character already exists: " + character.id());
|
||||
}
|
||||
write(character);
|
||||
created[0] = write(character);
|
||||
});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
|
||||
boolean discardUnissued(Creation created) throws IOException {
|
||||
boolean[] removed = {false};
|
||||
withLock(() -> {
|
||||
var path = file(created.record.id());
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
removed[0] = true;
|
||||
return;
|
||||
}
|
||||
var current = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
var expected = created.attributes;
|
||||
if (!current.isRegularFile() || expected.fileKey() == null
|
||||
|| !expected.fileKey().equals(current.fileKey())
|
||||
|| !expected.creationTime().equals(current.creationTime())
|
||||
|| !expected.lastModifiedTime().equals(current.lastModifiedTime())
|
||||
|| current.size() != created.bytes.length) return;
|
||||
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Arrays.equals(created.bytes, input.readNBytes(created.bytes.length + 1))) return;
|
||||
}
|
||||
Files.delete(path);
|
||||
removed[0] = true;
|
||||
});
|
||||
return removed[0];
|
||||
}
|
||||
|
||||
public Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
Path file = file(id);
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
@@ -73,7 +119,7 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private void write(CharacterRecord character) throws IOException {
|
||||
private Creation write(CharacterRecord character) throws IOException {
|
||||
byte[] bytes = CharacterYaml.encode(character).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
|
||||
Path temporary = Files.createTempFile(directory, ".character-", ".tmp");
|
||||
@@ -83,16 +129,19 @@ public final class YamlCharacterRepository {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
// Never fall back to an in-place, partially written character record.
|
||||
replacement.move(temporary, file(character.id()));
|
||||
return new Creation(character, attributes, bytes);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
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,327 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.ManualCraftPlan;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.inventory.AbstractContainerMenu;
|
||||
import net.minecraft.world.inventory.ResultContainer;
|
||||
import net.minecraft.world.inventory.TransientCraftingContainer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryCrafting;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.entity.HumanEntity;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.MenuType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManualCraftPlanNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void plansOneCursorSheetWithoutConsumingIngredientsOrMintingAPreviewIdentity() {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new Fixture(width, 2);
|
||||
var event = fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
var found = ManualCraftPlan.capture(event);
|
||||
assertTrue(found.isPresent(), "A manual result click with space must plan one character");
|
||||
var plan = found.orElseThrow();
|
||||
assertEquals(1, plan.quantity());
|
||||
assertEquals(ManualCraftPlan.Route.CURSOR, plan.route());
|
||||
assertTrue(plan.inventorySlots().isEmpty());
|
||||
var after = plan.remainingMatrix();
|
||||
for (int slot = 0; slot < 4; slot++) {
|
||||
assertEquals(1, after[slot].getAmount());
|
||||
assertEquals(2, fixture.grid.getMatrix()[slot].getAmount(), "Planning never debits live input");
|
||||
}
|
||||
assertTrue(new CharacterSheets().identify(fixture.grid.getResult()).isEmpty());
|
||||
assertTrue(fixture.view.getCursor().isEmpty());
|
||||
assertFalse(event.isCancelled(), "Planning itself does not alter event dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shiftCraftingUsesDistinctEmptyStorageSlotsInNativeOrderAndStopsAtEitherLimit() {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
for (int ingredientLimit : new int[] {3, 64}) {
|
||||
var fixture = new Fixture(width, 64);
|
||||
var matrix = fixture.grid.getMatrix();
|
||||
matrix[3].setAmount(ingredientLimit);
|
||||
fixture.grid.setMatrix(matrix);
|
||||
for (int slot = 0; slot < 36; slot++) fixture.inventory.setItem(slot, new ItemStack(Material.COBBLESTONE, 64));
|
||||
for (int slot : new int[] {8, 6, 35, 9}) fixture.inventory.setItem(slot, null);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
int count = Math.min(4, ingredientLimit);
|
||||
assertEquals(count, plan.quantity(), "One free slot per unique sheet, bounded by ingredients");
|
||||
assertEquals(ManualCraftPlan.Route.INVENTORY, plan.route());
|
||||
assertEquals(java.util.List.of(8, 6, 35, 9).subList(0, count), plan.inventorySlots());
|
||||
assertEquals(64 - count, plan.remainingMatrix()[0].getAmount());
|
||||
if (count == ingredientLimit) assertNull(plan.remainingMatrix()[3]);
|
||||
else assertEquals(ingredientLimit - count, plan.remainingMatrix()[3].getAmount());
|
||||
assertEquals(64, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertNull(fixture.inventory.getItem(8), "Planning must not deliver previews");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesCancelledNonCraftWrongRecipeAndMalformedInputWithoutChangingAnything() {
|
||||
var fixture = new Fixture(3, 2);
|
||||
var cancelled = fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
cancelled.setCancelled(true);
|
||||
assertTrue(ManualCraftPlan.capture(cancelled).isEmpty(), "Prior cancellation is authoritative");
|
||||
assertTrue(cancelled.isCancelled());
|
||||
for (var action : new InventoryAction[] {InventoryAction.NOTHING, InventoryAction.CLONE_STACK,
|
||||
InventoryAction.COLLECT_TO_CURSOR, InventoryAction.PLACE_ALL, InventoryAction.SWAP_WITH_CURSOR}) {
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, action)).isEmpty(), action.toString());
|
||||
}
|
||||
var unrelated = new org.bukkit.inventory.ShapelessRecipe(new org.bukkit.NamespacedKey("other", "character_sheet"),
|
||||
new ItemStack(Material.PAPER)).addIngredient(Material.STICK);
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(unrelated, fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(new CharacterSheets().recipe(), fixture.view,
|
||||
InventoryType.SlotType.CRAFTING, 1, ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
for (ItemStack bad : new ItemStack[] {null, new ItemStack(Material.DIAMOND),
|
||||
new ItemStack(Material.PAPER), new ItemStack(Material.STICK, 65)}) {
|
||||
var invalid = new Fixture(3, 2);
|
||||
var inputs = invalid.grid.getMatrix();
|
||||
inputs[0] = bad;
|
||||
invalid.grid.setMatrix(inputs);
|
||||
assertTrue(ManualCraftPlan.capture(invalid.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
}
|
||||
var extra = fixture.grid.getMatrix();
|
||||
extra[8] = new ItemStack(Material.PAPER);
|
||||
fixture.grid.setMatrix(extra);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(Material.PAPER, fixture.grid.getMatrix()[8].getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesMissingResultOccupiedCursorAndFullStorageButAllowsRightClick() {
|
||||
var fixture = new Fixture(2, 1);
|
||||
assertEquals(1, ManualCraftPlan.capture(fixture.event(ClickType.RIGHT,
|
||||
InventoryAction.PICKUP_HALF)).orElseThrow().quantity());
|
||||
fixture.view.setCursor(new ItemStack(Material.PAPER));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(Material.PAPER, fixture.view.getCursor().getType());
|
||||
fixture.view.setCursor(null);
|
||||
for (int slot = 0; slot < 36; slot++) fixture.inventory.setItem(slot, new ItemStack(Material.PAPER));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_RIGHT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).isEmpty(), "Unique identities cannot merge with ordinary Paper");
|
||||
fixture.grid.setResult(null);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).isEmpty());
|
||||
assertEquals(1, fixture.grid.getMatrix()[0].getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void numberAndOffhandKeysPlanOnlyOneSheetIntoAnEmptyExactDestination() {
|
||||
for (int slot : new int[] {0, 8, 40}) {
|
||||
var fixture = new Fixture(3, 12);
|
||||
var click = slot == 40 ? ClickType.SWAP_OFFHAND : ClickType.NUMBER_KEY;
|
||||
var event = new CraftItemEvent(new CharacterSheets().recipe(), fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, click, InventoryAction.HOTBAR_SWAP, slot == 40 ? -1 : slot);
|
||||
var plan = ManualCraftPlan.capture(event).orElseThrow();
|
||||
assertEquals(1, plan.quantity());
|
||||
assertEquals(ManualCraftPlan.Route.INVENTORY, plan.route());
|
||||
assertEquals(java.util.List.of(slot), plan.inventorySlots());
|
||||
fixture.inventory.setItem(slot, new ItemStack(Material.DIAMOND));
|
||||
assertTrue(ManualCraftPlan.capture(event).isEmpty(), "Never displace an existing hotbar/offhand stack");
|
||||
assertEquals(Material.DIAMOND, fixture.inventory.getItem(slot).getType());
|
||||
}
|
||||
var fixture = new Fixture(2, 1);
|
||||
for (int invalid : new int[] {-1, 9, 36, 40}) {
|
||||
assertTrue(ManualCraftPlan.capture(new CraftItemEvent(new CharacterSheets().recipe(), fixture.view,
|
||||
InventoryType.SlotType.RESULT, 0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, invalid)).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void distinguishesSingleDropFromControlDropBulkWithoutSpawningOrConsuming() {
|
||||
var fixture = new Fixture(3, 5);
|
||||
var single = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
|
||||
var bulk = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
assertEquals(1, single.quantity());
|
||||
assertEquals(5, bulk.quantity(), "Control-drop is a second native bulk route");
|
||||
assertEquals(ManualCraftPlan.Route.DROP, single.route());
|
||||
assertEquals(ManualCraftPlan.Route.DROP, bulk.route());
|
||||
assertTrue(bulk.inventorySlots().isEmpty());
|
||||
assertNull(bulk.remainingMatrix()[0]);
|
||||
assertEquals(5, fixture.grid.getMatrix()[0].getAmount());
|
||||
fixture.view.setCursor(new ItemStack(Material.DIAMOND));
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).isEmpty());
|
||||
fixture.view.setCursor(null);
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.MIDDLE, InventoryAction.CLONE_STACK)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DOUBLE_CLICK, InventoryAction.COLLECT_TO_CURSOR)).isEmpty());
|
||||
assertTrue(ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ALL_SLOT)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotsDoNotAliasNativeMirrorsAndRejectStaleInputResultCursorRecipeOrView() {
|
||||
var fixture = new Fixture(3, 3);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
assertTrue(plan.matches(fixture.view));
|
||||
plan.remainingMatrix()[0].setAmount(44);
|
||||
assertEquals(2, plan.remainingMatrix()[0].getAmount(), "Returned arrays/stacks cannot alter the saved plan");
|
||||
fixture.grid.getMatrix()[0].setAmount(2);
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount(), "Fixture exercises native mirrors, not fake copied stacks");
|
||||
assertFalse(plan.matches(fixture.view), "Live amount changes invalidate an async snapshot");
|
||||
assertEquals(2, plan.remainingMatrix()[0].getAmount());
|
||||
for (java.util.function.Consumer<Fixture> change : java.util.List.<java.util.function.Consumer<Fixture>>of(
|
||||
f -> { var item = f.grid.getMatrix()[0]; var meta = item.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Changed input")); item.setItemMeta(meta); },
|
||||
f -> f.grid.setResult(new ItemStack(Material.PAPER)),
|
||||
f -> f.view.setCursor(new ItemStack(Material.DIAMOND)),
|
||||
f -> f.matrix.setCurrentRecipe(null))) {
|
||||
var changed = new Fixture(2, 2);
|
||||
var pending = ManualCraftPlan.capture(changed.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
change.accept(changed);
|
||||
assertFalse(pending.matches(changed.view));
|
||||
assertEquals(1, pending.remainingMatrix()[0].getAmount());
|
||||
}
|
||||
var other = new Fixture(3, 3);
|
||||
assertFalse(plan.matches(other.view), "Identical contents in a different view are not the original transaction");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsOccupiedPlannedDestinationsWithoutBlockingUnrelatedInventoryChanges() {
|
||||
var fixture = new Fixture(3, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.SHIFT_LEFT,
|
||||
InventoryAction.MOVE_TO_OTHER_INVENTORY)).orElseThrow();
|
||||
assertEquals(java.util.List.of(8, 7), plan.inventorySlots());
|
||||
fixture.inventory.setItem(12, new ItemStack(Material.DIAMOND));
|
||||
assertTrue(plan.matches(fixture.view));
|
||||
fixture.inventory.setItem(7, new ItemStack(Material.GOLD_INGOT));
|
||||
assertFalse(plan.matches(fixture.view), "Sorting/new acquisitions must not be overwritten by a stale craft");
|
||||
assertEquals(Material.GOLD_INGOT, fixture.inventory.getItem(7).getType());
|
||||
assertThrows(UnsupportedOperationException.class, () -> plan.inventorySlots().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAReplacedRecipeResultEvenIfItReusesTheOwnedKey() {
|
||||
var fixture = new Fixture(2, 1);
|
||||
var changed = new CharacterSheets().recipe().getResult();
|
||||
changed.setAmount(2);
|
||||
fixture.grid.setResult(changed);
|
||||
var replaced = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE, changed)
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
var event = new CraftItemEvent(replaced, fixture.view, InventoryType.SlotType.RESULT,
|
||||
0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
assertTrue(ManualCraftPlan.capture(event).isEmpty(), "One canonical nonplayable preview, not an arbitrary same-key result");
|
||||
assertEquals(2, fixture.grid.getResult().getAmount());
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
final Menu menu = new Menu();
|
||||
final TransientCraftingContainer matrix;
|
||||
final CraftInventoryCrafting grid;
|
||||
final CraftInventoryPlayer inventory = new CraftInventoryPlayer(
|
||||
new net.minecraft.world.entity.player.Inventory(null, new EntityEquipment()) {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
final InventoryView view;
|
||||
|
||||
Fixture(int width, int amount) {
|
||||
matrix = new TransientCraftingContainer(menu, width, width);
|
||||
var result = new ResultContainer();
|
||||
matrix.resultInventory = result;
|
||||
grid = new CraftInventoryCrafting(matrix, result);
|
||||
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 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 String getTitle() { return "Crafting fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title change"); }
|
||||
@Override public MenuType getMenuType() { return null; }
|
||||
};
|
||||
menu.view = view;
|
||||
var contents = new ItemStack[width * width];
|
||||
var ingredients = new Material[] {Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC};
|
||||
for (int slot = 0; slot < ingredients.length; slot++) contents[slot] = new ItemStack(ingredients[slot], amount);
|
||||
grid.setMatrix(contents);
|
||||
grid.setResult(new CharacterSheets().recipe().getResult());
|
||||
var nativeRecipe = new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
net.minecraft.world.item.crafting.CraftingBookCategory.MISC, ""),
|
||||
net.minecraft.world.item.ItemStackTemplate.fromNonEmptyStack(CraftItemStack.asNMSCopy(grid.getResult())),
|
||||
java.util.List.of(
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.STICK),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.PAPER),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.FEATHER),
|
||||
net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.INK_SAC)));
|
||||
matrix.setCurrentRecipe(new net.minecraft.world.item.crafting.RecipeHolder<>(
|
||||
net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.parse(CharacterSheets.RECIPE.toString())), nativeRecipe));
|
||||
}
|
||||
|
||||
CraftItemEvent event(ClickType click, InventoryAction action) {
|
||||
return new CraftItemEvent(new CharacterSheets().recipe(), view, InventoryType.SlotType.RESULT, 0, click, action);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Menu extends AbstractContainerMenu {
|
||||
InventoryView view;
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.CharacterProgress;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CraftPersistenceTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void persistsTheWholeBatchBeforeExposingItForOneDeliveryAndRetainsPossiblyIssuedRecords() throws Exception {
|
||||
var first = fresh("Ari Ash");
|
||||
var second = fresh("Iris Vale");
|
||||
var records = List.of(first, second);
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(records).ready().join();
|
||||
for (var record : records) assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
assertEquals(records, batch.claimForDelivery().orElseThrow());
|
||||
assertTrue(batch.claimForDelivery().isEmpty(), "A duplicate callback cannot claim the same batch twice");
|
||||
var cleanup = batch.abandon().join();
|
||||
assertTrue(cleanup.removed().isEmpty());
|
||||
assertEquals(Set.of(first.id(), second.id()), cleanup.retained());
|
||||
for (var record : records) assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abandoningAnUnissuedBatchRemovesOnlyItsOwnFilesAndCannotReplayCleanup() throws Exception {
|
||||
var unused = fresh("Unused character");
|
||||
var unrelated = fresh("Existing character");
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(unrelated);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(List.of(unused)).ready().join();
|
||||
var cleanup = batch.abandon().join();
|
||||
assertEquals(Set.of(unused.id()), cleanup.removed(), "An unchanged never-issued record must be cleaned up");
|
||||
assertTrue(cleanup.retained().isEmpty());
|
||||
assertTrue(storage.find(unused.id()).join().isEmpty());
|
||||
assertEquals(unrelated, storage.find(unrelated.id()).join().orElseThrow());
|
||||
assertTrue(batch.claimForDelivery().isEmpty(), "Abandonment permanently invalidates delivery");
|
||||
repository.create(unused);
|
||||
assertEquals(cleanup, batch.abandon().join(), "Repeated callbacks return the original cleanup outcome");
|
||||
assertEquals(unused, storage.find(unused.id()).join().orElseThrow(), "Never replay deletion on a later file");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsEditedBytesAndReplacedFilesEvenWhenTheyDecodeToTheOriginalCharacter() throws Exception {
|
||||
for (boolean replaceFile : new boolean[] {false, true}) {
|
||||
var original = fresh("Do not discard edited or replaced state");
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(List.of(original)).ready().join();
|
||||
var file = data.resolve("characters").resolve(original.id() + ".yml");
|
||||
byte[] bytes = java.nio.file.Files.readAllBytes(file);
|
||||
if (replaceFile) {
|
||||
var replacement = java.nio.file.Files.createTempFile(file.getParent(), "external-", ".tmp");
|
||||
java.nio.file.Files.write(replacement, bytes);
|
||||
java.nio.file.Files.move(replacement, file, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
java.nio.file.Files.writeString(file, "\n# Operator annotation\n", java.nio.file.StandardOpenOption.APPEND);
|
||||
}
|
||||
assertEquals(original, storage.find(original.id()).join().orElseThrow());
|
||||
var cleanup = batch.abandon().join();
|
||||
assertEquals(Set.of(original.id()), cleanup.retained(), "Semantic equality is not ownership/unchanged-file proof");
|
||||
assertTrue(cleanup.removed().isEmpty());
|
||||
assertTrue(java.nio.file.Files.isRegularFile(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialSaveFailureCleansAcknowledgedUnissuedFilesAndAllowsANormalRetry() throws Exception {
|
||||
var first = fresh("First");
|
||||
var second = fresh("Second");
|
||||
var third = fresh("Never attempted");
|
||||
var records = List.of(first, second, third);
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var failing = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
if (writes.incrementAndGet() == 2) throw new java.io.IOException("Simulated second write failure");
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
|
||||
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.prepareCraft(records).ready().join());
|
||||
assertTrue(repository.load(first.id()).isEmpty(), "Clean the acknowledged first write, not a half-issued batch");
|
||||
assertTrue(repository.load(second.id()).isEmpty());
|
||||
assertTrue(repository.load(third.id()).isEmpty());
|
||||
assertEquals(2, writes.get());
|
||||
}
|
||||
try (var retry = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertEquals(records, retry.prepareCraft(records).ready().join().claimForDelivery().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsAnUnacknowledgedWriteInsteadOfGuessingWhetherTheMoveCommitted() throws Exception {
|
||||
var first = fresh("Acknowledged");
|
||||
var uncertain = fresh("Unacknowledged");
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
if (writes.incrementAndGet() == 2) throw new java.io.IOException("Simulated failure after rename");
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var error = assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> storage.prepareCraft(List.of(first, uncertain)).ready().join());
|
||||
var failure = assertInstanceOf(CharacterStorage.CraftPreparationException.class, error.getCause());
|
||||
assertEquals(Set.of(first.id()), failure.cleanup().removed());
|
||||
assertEquals(Set.of(uncertain.id()), failure.cleanup().retained());
|
||||
assertTrue(storage.find(first.id()).join().isEmpty());
|
||||
assertEquals(uncertain, storage.find(uncertain.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateEmptyOrNonFreshCraftBatchesBeforeAnyWrite() throws Exception {
|
||||
var first = fresh("New character");
|
||||
var advanced = YamlCharacterRepositoryTest.rename(first, "Already changed", java.math.BigInteger.ONE);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
writes.incrementAndGet();
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
for (var invalid : List.<List<CharacterRecord>>of(List.of(), List.of(first, first), List.of(first, advanced),
|
||||
List.of(fresh(" ")), List.of(YamlCharacterRepositoryTest.sample(UUID.randomUUID())))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> storage.prepareCraft(invalid));
|
||||
}
|
||||
assertEquals(0, writes.get());
|
||||
assertTrue(storage.find(first.id()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shutdownPreventsNewDeliveryAndRetainsRecordsWhenCleanupCannotRun() throws Exception {
|
||||
var original = fresh("Shutdown uncertainty");
|
||||
var other = fresh("No cleanup worker");
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var storage = new CharacterStorage(repository, Clock.systemUTC());
|
||||
try {
|
||||
var batch = storage.prepareCraft(List.of(original)).ready().join();
|
||||
var unclaimed = storage.prepareCraft(List.of(other)).ready().join();
|
||||
storage.close();
|
||||
assertTrue(batch.claimForDelivery().isEmpty(), "A late completion cannot begin delivery after disable");
|
||||
assertEquals(Set.of(original.id()), batch.abandon().join().retained());
|
||||
assertEquals(Set.of(other.id()), unclaimed.abandon().join().retained());
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
assertEquals(other, repository.load(other.id()).orElseThrow());
|
||||
assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> storage.prepareCraft(List.of(fresh("Rejected"))).ready().join());
|
||||
} finally {
|
||||
storage.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchWritesRunOffCallerThreadAndAcknowledgeOnlyAfterTheLastCommit() throws Exception {
|
||||
var original = List.of(fresh("First durable write"), fresh("Last durable write"));
|
||||
var input = new java.util.ArrayList<>(original);
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var allowCommit = new java.util.concurrent.CountDownLatch(1);
|
||||
var caller = Thread.currentThread();
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
if (writes.incrementAndGet() == 2) {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!allowCommit.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
|
||||
} catch (InterruptedException error) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(error);
|
||||
}
|
||||
}
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var ready = storage.prepareCraft(input).ready();
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
input.clear();
|
||||
assertFalse(ready.isDone(), "No usable batch before the final file is acknowledged");
|
||||
} finally {
|
||||
allowCommit.countDown();
|
||||
}
|
||||
assertEquals(original, ready.join().claimForDelivery().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCollisionAndAReopenedStoreCannotTreatExistingFilesAsOwnedUnissuedRecords() throws Exception {
|
||||
var existing = fresh("Existing possibly issued character");
|
||||
var newRecord = fresh("New record before collision");
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
storage.prepareCraft(List.of(existing)).ready().join(); // Deliberately lose the in-memory proof at shutdown.
|
||||
}
|
||||
try (var reopened = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertEquals(existing, reopened.find(existing.id()).join().orElseThrow());
|
||||
var error = assertThrows(java.util.concurrent.CompletionException.class,
|
||||
() -> reopened.prepareCraft(List.of(newRecord, existing)).ready().join());
|
||||
var failure = assertInstanceOf(CharacterStorage.CraftPreparationException.class, error.getCause());
|
||||
assertEquals(Set.of(newRecord.id()), failure.cleanup().removed());
|
||||
assertEquals(Set.of(existing.id()), failure.cleanup().retained());
|
||||
assertEquals(existing, reopened.find(existing.id()).join().orElseThrow());
|
||||
assertTrue(reopened.find(newRecord.id()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentDeliveryAndAbandonmentHaveOnlyOneWinner() throws Exception {
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
|
||||
var pool = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
for (int round = 0; round < 12; round++) {
|
||||
var record = fresh("Race " + round);
|
||||
var batch = storage.prepareCraft(List.of(record)).ready().join();
|
||||
var start = new java.util.concurrent.CountDownLatch(1);
|
||||
var delivery = pool.submit(() -> { start.await(); return batch.claimForDelivery(); });
|
||||
var abandonment = pool.submit(() -> { start.await(); return batch.abandon().join(); });
|
||||
start.countDown();
|
||||
var claimed = delivery.get();
|
||||
var cleanup = abandonment.get();
|
||||
if (claimed.isPresent()) {
|
||||
assertEquals(Set.of(record.id()), cleanup.retained());
|
||||
assertTrue(cleanup.removed().isEmpty());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
} else {
|
||||
assertEquals(Set.of(record.id()), cleanup.removed());
|
||||
assertTrue(cleanup.retained().isEmpty());
|
||||
assertTrue(storage.find(record.id()).join().isEmpty());
|
||||
}
|
||||
assertTrue(batch.claimForDelivery().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupRetainsChangedCorruptSymlinkedAndBusyRecordsWithoutStoppingOtherCleanup() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
for (int scenario = 0; scenario < 4; scenario++) {
|
||||
var protectedRecord = fresh("Retained " + scenario);
|
||||
var removable = fresh("Removable " + scenario);
|
||||
var batch = storage.prepareCraft(List.of(protectedRecord, removable)).ready().join();
|
||||
var file = data.resolve("characters").resolve(protectedRecord.id() + ".yml");
|
||||
if (scenario == 0) repository.replace(YamlCharacterRepositoryTest.rename(protectedRecord,
|
||||
"Updated progression revision", java.math.BigInteger.ONE), java.math.BigInteger.ZERO);
|
||||
if (scenario == 1) java.nio.file.Files.writeString(file, "unparseable character state");
|
||||
if (scenario == 2) {
|
||||
var target = data.resolve("protected-target.yml");
|
||||
java.nio.file.Files.copy(file, target);
|
||||
java.nio.file.Files.delete(file);
|
||||
java.nio.file.Files.createSymbolicLink(file, target);
|
||||
}
|
||||
CharacterStorage.CraftCleanup cleanup;
|
||||
if (scenario == 3) {
|
||||
try (var channel = java.nio.channels.FileChannel.open(
|
||||
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(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));
|
||||
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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellingTimingOutOrCompletingAnObservationCannotLosePendingOwnershipOrPreventAbandonment() throws Exception {
|
||||
for (int mutation = 0; mutation < 3; mutation++) {
|
||||
var first = fresh("Pending ownership");
|
||||
var second = fresh("Cancelled remainder");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(failure);
|
||||
}
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(List.of(first, second));
|
||||
var observation = attempt.ready();
|
||||
java.util.concurrent.CompletableFuture<CharacterStorage.CraftCleanup> cleanup;
|
||||
java.util.concurrent.CompletableFuture<Boolean> refusedDelivery;
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
if (mutation == 0) observation.cancel(true);
|
||||
if (mutation == 1) observation.completeExceptionally(new java.util.concurrent.TimeoutException("Observer timeout"));
|
||||
if (mutation == 2) observation.complete(null);
|
||||
assertFalse(attempt.ready().isDone(), "An observer must not mutate the owned persistence operation");
|
||||
cleanup = attempt.abandon();
|
||||
refusedDelivery = attempt.ready().thenApply(batch -> batch.claimForDelivery().isEmpty());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertTrue(refusedDelivery.join(), "A stale attempt must refuse even an early completion callback");
|
||||
assertEquals(Set.of(first.id()), cleanup.join().removed());
|
||||
assertTrue(cleanup.join().retained().isEmpty());
|
||||
assertTrue(storage.find(first.id()).join().isEmpty());
|
||||
assertTrue(storage.find(second.id()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void abandonmentBeforeExecutionSkipsWritesAndCancelledCleanupObserversCannotStopCleanup() throws Exception {
|
||||
var original = fresh("Already prepared");
|
||||
var blocker = fresh("Queue blocker");
|
||||
var neverWritten = fresh("Cancelled before worker start");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var writes = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var repository = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
writes.incrementAndGet();
|
||||
if (target.getFileName().toString().equals(blocker.id() + ".yml")) {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Gate timed out");
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(failure);
|
||||
}
|
||||
}
|
||||
java.nio.file.Files.move(temporary, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(List.of(original));
|
||||
attempt.ready().join();
|
||||
var busy = storage.create(blocker);
|
||||
CharacterStorage.CraftPreparation queued;
|
||||
try {
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
queued = storage.prepareCraft(List.of(neverWritten));
|
||||
queued.abandon();
|
||||
assertTrue(attempt.abandon().cancel(true));
|
||||
assertFalse(attempt.abandon().isDone());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
busy.join();
|
||||
assertTrue(queued.ready().join().claimForDelivery().isEmpty());
|
||||
assertTrue(queued.abandon().join().removed().isEmpty());
|
||||
assertTrue(queued.abandon().join().retained().isEmpty());
|
||||
assertEquals(Set.of(original.id()), attempt.abandon().join().removed());
|
||||
assertEquals(2, writes.get(), "The abandoned queued attempt must never reach the file writer");
|
||||
assertTrue(storage.find(neverWritten.id()).join().isEmpty());
|
||||
assertTrue(storage.find(original.id()).join().isEmpty());
|
||||
assertEquals(blocker, storage.find(blocker.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
private static CharacterRecord fresh(String name) {
|
||||
var initial = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults()).snapshot();
|
||||
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
|
||||
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(), Set.of(),
|
||||
List.of("", "", "", "", "", "", ""), Map.of(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user