Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf93fa966 | ||
|
|
14fc0dbde0 | ||
|
|
d10e804820 | ||
|
|
7cf91baca4 | ||
|
|
3215697e3f | ||
|
|
f4cb7a3914 | ||
|
|
6aa7ea469b | ||
|
|
272acfde11 | ||
|
|
c215dc1990 | ||
|
|
b35cac3d1c |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. It is not yet connected to crafting, native health, the book or persistence.
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. None is yet wired into playable crafting, health, session or spell interactions.
|
||||
|
||||
## Build and verify
|
||||
|
||||
@@ -15,10 +15,70 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
./gradlew check jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime **without starting a listening server or creating a gameplay world**. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime and loads vanilla registries/components **without starting a listening server or creating a gameplay world**. The item tests decode signed written books with Minecraft's real codec, without a plugin instance or YAML lookup. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Bootstrap registers no commands, permissions, recipes, sessions or gameplay listeners.
|
||||
|
||||
## Character storage and keepsakes
|
||||
|
||||
`YamlCharacterRepository` stores schema-version-1 records at `<plugin data directory>/characters/<character UUID>.yml`. Character identity, name, progress, seven-slot loadout, ability cooldowns, explicit `dead` flag and latest death ID/time are preserved; the record has no holder-owned equipment or ordinary-health/nutrition snapshot. Integer values and decimal health retain exact precision, and timestamps use UTC RFC 3339 strings. Unknown learned ability IDs and unlearned cooldowns survive reload.
|
||||
|
||||
The file adapter uses safe YAML construction, duplicate-key rejection, bounded reads (1 MiB per record), typed validation, UUID filenames and no symlinked record reads. Replacement writes a complete temporary file, flushes it and atomically renames it; expected revisions and transient OS file locks reject duplicate/stale writers. Empty `.lock` files are coordination files, not administrator-unlock flags. Unsupported atomic replacement fails rather than falling back to a partial overwrite. Malformed files are retained, not reset.
|
||||
|
||||
`CharacterStorage` queues disk operations on its persistence worker and acknowledges successful writes only after commit. Its backend restoration checks the saved dead flag, matching current death ID and death age strictly under 24 hours at the request/selection time. It preserves progress/cooldowns and restores full character health; old books do not become eligible after a later death. This method is **not** a complete Revive spell: the engine must still validate the caster and native dialog, spend its cooldown, recheck item possession and perform physical conversion safely.
|
||||
|
||||
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The factory does not yet deliver books from a death event.
|
||||
|
||||
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.
|
||||
|
||||
## Fixed-destination craft delivery (not registered)
|
||||
|
||||
`ManualCraftDelivery` can apply a validated plan and exact-sized saved batch to the cursor, selected hotbar/offhand slot or bulk storage destinations. It rechecks current contents before claiming delivery, constructs all sheets first, then debits fresh native input mirrors and places all outputs before triggering one matrix recomputation. This deliberately relies on the pinned Purpur 2618 mirror/count/setter behavior. No further adapter-planned writes occur after the prepare-callback boundary. Existing native setters may send packets; this is not packet-silent or exception/crash-atomic.
|
||||
|
||||
Nine native delivery tests cover normal/right-click, bulk and exact-slot delivery, partial ingredient metadata, empty first matrix cells, stale context refusal, mismatched/abandoned/reused batches, reentrancy, callback item changes, new-menu cursor safety and post-callback exceptions. The fixture uses actual containers/cursor stacks and intercepts `slotsChanged`; it does not execute a listening server's recipe manager or event dispatch. A mismatch refuses before consuming cleanup authority; an exception after claiming delivery conservatively retains records rather than replaying or rolling back.
|
||||
|
||||
The caller still must contain vanilla consumption, validate a one-use lifecycle/attempt token, schedule on the server thread, pass the player's actual current view and synchronize the appropriate client menu afterward. World-drop plans return `UNSUPPORTED_ROUTE` unchanged in this component; that is not a registered gameplay policy. No completed-craft listener or recipe is exposed yet.
|
||||
|
||||
## Q/Ctrl-Q craft delivery (not registered)
|
||||
|
||||
Q/Ctrl-Q remain supported scope; no shortcut restriction was accepted or enabled. `ManualDropDelivery` executes saved drop plans one unit at a time, checking original contents minus completed debits before every next unit. `PlayerSheetDrops` creates an unspawned item, dispatches `PlayerDropItemEvent`, revalidates, and uses native `Item.spawnAt` for the cancellable `ItemSpawnEvent` path. It never uses inventory-source dropping or cancellation's return-to-inventory fallback. A successful unit consumes one ingredient set, then recomputes; cancellation stops the suffix without charging it.
|
||||
|
||||
Thirteen additional tests cover sequential/bulk/cancelled drops, exact remaining-input validation, stale context, changed/removed entities, same-batch reentry and exceptions. Tests combine real native matrices, serialized sheets, YAML and Paper's event dispatcher; world insertion itself remains an explicit API boundary double, supplemented by pinned-runtime bytecode inspection. No live world or packet dispatch is claimed.
|
||||
|
||||
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. That controller and recipe registration remain pending.
|
||||
|
||||
## 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.
|
||||
|
||||
Version-1 box metadata uses `fantasystick:storage_version`, `storage_owner_id`, `storage_session_id` and `storage_slots`. Box positions 0–8 contain original hotbar slots 0–8; positions 9–12 contain boots, leggings, chest and head from player slots 36–39. These metadata fields are for identity/restoration, not the only copy of the items. Restoration searches main slots 9–35, so sorting relocation does not lose the box. Repeated requests, wrong identity, duplicate/malformed boxes, unexpected extra contents or occupied restoration slots do not overwrite/delete items. These are adapter error results, not a persistent administrator lock or completed recovery policy.
|
||||
|
||||
The caller must remove authenticated fantasy controls before restoration. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The adapter does not yet route sheet/cursor events, emit entry warnings, manage character/player snapshots or register a playable session. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
|
||||
## CI and publication
|
||||
|
||||
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ val nativeRuntimeJars = files(fileTree(nativeRuntime) {
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
|
||||
description = "Verifies packaged metadata against the actual Purpur runtime without starting a server"
|
||||
description = "Verifies packaged metadata and native item serialization without starting a server"
|
||||
dependsOn(tasks.jar)
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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) {
|
||||
return matches(current, matrix);
|
||||
}
|
||||
|
||||
private boolean matches(InventoryView current, ItemStack[] expected) {
|
||||
if (current != view || current.getTopInventory() != grid || current.getBottomInventory() != inventory) return false;
|
||||
var recipe = grid.getRecipe();
|
||||
return isSheetRecipe(recipe) && Objects.equals(result, recipe.getResult())
|
||||
&& Arrays.equals(expected, copy(grid.getMatrix())) && Objects.equals(result, copy(grid.getResult()))
|
||||
&& Objects.equals(cursor, copy(current.getCursor()))
|
||||
&& inventorySlots.stream().allMatch(slot -> empty(inventory.getItem(slot)));
|
||||
}
|
||||
|
||||
/** Revalidate the original drop intent after exactly this many completed units, not a new snapshot. */
|
||||
public boolean matchesAfterDrops(InventoryView current, int completed) {
|
||||
return route == Route.DROP && completed >= 0 && completed < quantity
|
||||
&& matches(current, remainingMatrix(completed));
|
||||
}
|
||||
|
||||
public int quantity() { return quantity; }
|
||||
public Route route() { return route; }
|
||||
public List<Integer> inventorySlots() { return inventorySlots; }
|
||||
|
||||
public ItemStack[] remainingMatrix() {
|
||||
return remainingMatrix(quantity);
|
||||
}
|
||||
|
||||
private ItemStack[] remainingMatrix(int consumed) {
|
||||
var remaining = copy(matrix);
|
||||
for (int slot = 0; slot < remaining.length; slot++) {
|
||||
var item = remaining[slot];
|
||||
if (item != null) {
|
||||
if (item.getAmount() == consumed) remaining[slot] = null;
|
||||
else item.setAmount(item.getAmount() - consumed);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
|
||||
/** Register alongside the completed manual-crafting adapter, before exposing the recipe. */
|
||||
public final class ManualCraftingGuard implements Listener {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onAutomatedCraft(CrafterCraftEvent event) {
|
||||
if (CharacterSheets.RECIPE.equals(event.getRecipe().getKey())) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* Server-thread drop executor. The caller owns vanilla containment and a one-use lifecycle/attempt guard,
|
||||
* including refusing NEW attempts during callbacks against these same still-undebited inputs.
|
||||
* UNCONFIRMED means an output may have escaped without a safely authorized debit; stop/report, never replay.
|
||||
* Public callbacks consume the batch's cleanup proof, including any unattempted suffix. This is not atomic
|
||||
* with arbitrary plugin mutations, native insertion failures or Minecraft/file saves.
|
||||
*/
|
||||
public final class ManualDropDelivery {
|
||||
public enum Status { COMPLETE, CANCELLED, STALE, UNCONFIRMED, UNAVAILABLE, UNSUPPORTED_ROUTE }
|
||||
/** completed counts units with both confirmed insertion and ingredient debit, not every possibly issued sheet. */
|
||||
public record Result(Status status, int completed) { }
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DropTarget {
|
||||
/** COMPLETE means one matching sheet was inserted; callbacks must recheck context before insertion. */
|
||||
Status drop(ItemStack sheet, BooleanSupplier current);
|
||||
}
|
||||
|
||||
private ManualDropDelivery() { }
|
||||
|
||||
public static Result commit(ManualCraftPlan plan, CharacterStorage.CraftBatch batch,
|
||||
Supplier<InventoryView> currentView, BooleanSupplier authorized, DropTarget target) {
|
||||
if (plan.route() != ManualCraftPlan.Route.DROP) return new Result(Status.UNSUPPORTED_ROUTE, 0);
|
||||
BooleanSupplier current = () -> authorized.getAsBoolean() && plan.matchesAfterDrops(currentView.get(), 0);
|
||||
if (!current.getAsBoolean()) return new Result(Status.STALE, 0);
|
||||
var records = batch.claimForDelivery(plan.quantity());
|
||||
if (records.isEmpty()) return new Result(Status.UNAVAILABLE, 0);
|
||||
var sheets = new CharacterSheets();
|
||||
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
|
||||
for (int completed = 0; completed < outputs.size(); completed++) {
|
||||
int expectedCompleted = completed;
|
||||
BooleanSupplier next = () -> authorized.getAsBoolean()
|
||||
&& plan.matchesAfterDrops(currentView.get(), expectedCompleted);
|
||||
if (!next.getAsBoolean()) return new Result(Status.STALE, completed);
|
||||
var status = java.util.Objects.requireNonNull(target.drop(outputs.get(completed), next));
|
||||
if (status != Status.COMPLETE) return new Result(status, completed);
|
||||
// A spawn callback may close/replace the menu after an item has escaped. Never charge a stale snapshot.
|
||||
if (!next.getAsBoolean()) return new Result(Status.UNCONFIRMED, completed);
|
||||
var grid = (CraftingInventory) currentView.get().getTopInventory();
|
||||
var inputs = grid.getMatrix();
|
||||
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - 1);
|
||||
grid.setItem(1, inputs[0]);
|
||||
// Revalidate against the original plan's expected remaining matrix after this callback boundary.
|
||||
}
|
||||
return new Result(Status.COMPLETE, outputs.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* World boundary for saved sheets; no inventory-source drop or cancellation return-to-inventory fallback.
|
||||
* Invoke on the server thread only after the batch claim, since listeners receive a playable identity.
|
||||
* Native spawnAt supplies ItemSpawnEvent; do not dispatch that event separately or infer success from a wrapper.
|
||||
*/
|
||||
public final class PlayerSheetDrops implements ManualDropDelivery.DropTarget {
|
||||
private final Player player;
|
||||
private final Consumer<PlayerDropItemEvent> events;
|
||||
|
||||
/** events must dispatch synchronously through the server's PluginManager.callEvent in the registered path. */
|
||||
public PlayerSheetDrops(Player player, Consumer<PlayerDropItemEvent> events) {
|
||||
this.player = java.util.Objects.requireNonNull(player);
|
||||
this.events = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ManualDropDelivery.Status drop(ItemStack sheet, BooleanSupplier current) {
|
||||
if (sheet == null || sheet.getAmount() != 1 || new CharacterSheets().identify(sheet).isEmpty()) {
|
||||
throw new IllegalArgumentException("Exactly one identified character sheet is required");
|
||||
}
|
||||
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
|
||||
var location = player.getEyeLocation().subtract(0, .3, 0);
|
||||
var item = location.getWorld().createEntity(location, Item.class);
|
||||
item.setItemStack(sheet.clone());
|
||||
item.setPickupDelay(40);
|
||||
item.setThrower(player.getUniqueId());
|
||||
var random = ThreadLocalRandom.current();
|
||||
double angle = random.nextDouble() * Math.PI * 2;
|
||||
double spread = random.nextDouble() * .02;
|
||||
var velocity = location.getDirection().multiply(.3);
|
||||
velocity.add(new org.bukkit.util.Vector(Math.cos(angle) * spread,
|
||||
.1 + (random.nextDouble() - random.nextDouble()) * .1, Math.sin(angle) * spread));
|
||||
item.setVelocity(velocity);
|
||||
var event = new PlayerDropItemEvent(player, item);
|
||||
events.accept(event);
|
||||
if (item.isInWorld() || item.isValid() || item.isDead() || !sheet.equals(item.getItemStack())) {
|
||||
return ManualDropDelivery.Status.UNCONFIRMED;
|
||||
}
|
||||
if (event.isCancelled()) return ManualDropDelivery.Status.CANCELLED;
|
||||
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
|
||||
boolean inserted = item.spawnAt(item.getLocation(), SpawnReason.CUSTOM);
|
||||
return inserted && item.isValid() && item.isInWorld() && sheet.equals(item.getItemStack())
|
||||
? ManualDropDelivery.Status.COMPLETE : ManualDropDelivery.Status.UNCONFIRMED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
|
||||
public final class CharacterSheets {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
|
||||
|
||||
public ItemStack create(CharacterRecord character) {
|
||||
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
|
||||
var item = paper(character.name());
|
||||
var meta = item.getItemMeta();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
|
||||
public ShapelessRecipe recipe() {
|
||||
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
}
|
||||
|
||||
private static ItemStack paper(String name) {
|
||||
var item = new ItemStack(Material.PAPER);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(name));
|
||||
meta.lore(List.of(Component.text(INSTRUCTION)));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
|
||||
public Optional<UUID> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER)
|
||||
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|
||||
|| !data.has(CHARACTER, PersistentDataType.STRING)) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(UUID.fromString(data.get(CHARACTER, PersistentDataType.STRING)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.ShulkerBox;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.meta.BlockStateMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Server-thread inventory operations; session eligibility and event routing belong to the caller. */
|
||||
public final class EquipmentStorage {
|
||||
public enum PackResult { PACKED, NO_SPACE, INELIGIBLE_EQUIPMENT, ALREADY_PACKED }
|
||||
|
||||
private static final int[] EQUIPMENT_SLOTS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
|
||||
private static final NamespacedKey VERSION = key("storage_version");
|
||||
private static final NamespacedKey OWNER = key("storage_owner_id");
|
||||
private static final NamespacedKey SESSION = key("storage_session_id");
|
||||
private static final NamespacedKey SLOTS = key("storage_slots");
|
||||
|
||||
public PackResult pack(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) return PackResult.ALREADY_PACKED;
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
var item = inventory.getItem(slot);
|
||||
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
}
|
||||
int available = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (empty(inventory.getItem(slot))) { available = slot; break; }
|
||||
}
|
||||
if (available == -1) return PackResult.NO_SPACE;
|
||||
var item = new ItemStack(Material.SHULKER_BOX);
|
||||
var meta = (BlockStateMeta) item.getItemMeta();
|
||||
var box = (ShulkerBox) meta.getBlockState();
|
||||
var contents = box.getInventory();
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) {
|
||||
var equipment = inventory.getItem(EQUIPMENT_SLOTS[i]);
|
||||
if (!empty(equipment) && equipment.getAmount() > Math.min(equipment.getMaxStackSize(), contents.getMaxStackSize())) {
|
||||
return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
}
|
||||
contents.setItem(i, equipment);
|
||||
}
|
||||
meta.setBlockState(box);
|
||||
meta.displayName(Component.text("Fantasy Storage Box"));
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOTS, PersistentDataType.INTEGER_ARRAY, EQUIPMENT_SLOTS.clone());
|
||||
item.setItemMeta(meta);
|
||||
for (int slot : EQUIPMENT_SLOTS) inventory.setItem(slot, null);
|
||||
inventory.setItem(available, item);
|
||||
return PackResult.PACKED;
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
int found = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) {
|
||||
if (found != -1) return RestoreResult.AMBIGUOUS;
|
||||
found = slot;
|
||||
}
|
||||
}
|
||||
if (found == -1) return RestoreResult.NOT_FOUND;
|
||||
var item = inventory.getItem(found);
|
||||
if (item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return RestoreResult.INVALID_BOX;
|
||||
var data = meta.getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER) || data.get(VERSION, PersistentDataType.INTEGER) != 1
|
||||
|| !data.has(SLOTS, PersistentDataType.INTEGER_ARRAY)
|
||||
|| !Arrays.equals(EQUIPMENT_SLOTS, data.get(SLOTS, PersistentDataType.INTEGER_ARRAY))) {
|
||||
return RestoreResult.INVALID_BOX;
|
||||
}
|
||||
var contents = box.getInventory();
|
||||
for (int slot = EQUIPMENT_SLOTS.length; slot < contents.getSize(); slot++) {
|
||||
if (!empty(contents.getItem(slot))) return RestoreResult.INVALID_BOX;
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
if (slot != 39 && !empty(inventory.getItem(slot))) return RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
var currentHead = inventory.getItem(39);
|
||||
var displacedHead = empty(currentHead) ? null : currentHead.clone();
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) inventory.setItem(EQUIPMENT_SLOTS[i], contents.getItem(i));
|
||||
inventory.setItem(found, displacedHead);
|
||||
return RestoreResult.RESTORED;
|
||||
}
|
||||
|
||||
private static boolean matches(ItemStack item, UUID owner, UUID session) {
|
||||
if (empty(item)) return false;
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
return data.has(OWNER, PersistentDataType.STRING) && data.has(SESSION, PersistentDataType.STRING)
|
||||
&& owner.toString().equals(data.get(OWNER, PersistentDataType.STRING))
|
||||
&& session.toString().equals(data.get(SESSION, PersistentDataType.STRING));
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) { return new NamespacedKey("fantasystick", name); }
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Native readable keepsake; revival uses only its IDs to consult authoritative saved character data. */
|
||||
public final class TailBooks {
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "tail_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final NamespacedKey DEATH = new NamespacedKey("fantasystick", "death_id");
|
||||
|
||||
public record Identity(UUID characterId, UUID deathId) { }
|
||||
|
||||
public ItemStack create(CharacterRecord character) {
|
||||
if (!character.dead()) throw new IllegalArgumentException("A Tail book requires a dead character record");
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
String fullTitle = "Tail of " + character.name();
|
||||
int titleEnd = Math.min(32, fullTitle.length());
|
||||
if (titleEnd < fullTitle.length() && Character.isHighSurrogate(fullTitle.charAt(titleEnd - 1))) titleEnd--;
|
||||
if (!meta.setTitle(fullTitle.substring(0, titleEnd))) {
|
||||
throw new IllegalStateException("Native written-book title was rejected");
|
||||
}
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
meta.setGeneration(BookMeta.Generation.ORIGINAL);
|
||||
meta.displayName(Component.text(fullTitle));
|
||||
List<Component> pages = new ArrayList<>();
|
||||
pages.add(Component.text(fullTitle));
|
||||
pages.add(Component.text("Level: " + character.level()));
|
||||
pages.add(Component.text("XP: " + character.experience()));
|
||||
pages.add(Component.text("Unspent points: " + character.unspentPoints()));
|
||||
for (Stat stat : Stat.values()) pages.add(Component.text(stat + ": " + character.stats().get(stat)));
|
||||
pages.add(Component.text("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
|
||||
character.learned().stream().sorted().forEach(ability -> pages.add(Component.text(ability)));
|
||||
pages.add(Component.text("Fell: " + character.diedAt()));
|
||||
meta.pages(pages);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
data.set(DEATH, PersistentDataType.STRING, character.deathId().toString());
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public Optional<Identity> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER)
|
||||
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|
||||
|| !data.has(CHARACTER, PersistentDataType.STRING) || !data.has(DEATH, PersistentDataType.STRING)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(new Identity(UUID.fromString(data.get(CHARACTER, PersistentDataType.STRING)),
|
||||
UUID.fromString(data.get(DEATH, PersistentDataType.STRING))));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Immutable character data, never a holder's ordinary equipment or session snapshot. */
|
||||
public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
Objects.requireNonNull(revision);
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(unspentPoints);
|
||||
Objects.requireNonNull(health);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
|| health.signum() < 0
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
for (String ability : loadout) {
|
||||
if (!ability.isEmpty() && !learned.contains(ability)) {
|
||||
throw new IllegalArgumentException("Loadout references an unlearned ability");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
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;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/** Asynchronous character persistence; this does not cast Revive or consume/deliver inventory items. */
|
||||
public final class CharacterStorage implements AutoCloseable {
|
||||
private final YamlCharacterRepository repository;
|
||||
private final Clock clock;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> create(CharacterRecord character) {
|
||||
return submit(() -> {
|
||||
repository.create(character);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
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));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Instant diedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
if (old.dead() || !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), true, UUID.randomUUID(), diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
Duration age = Duration.between(old.diedAt(), selectedAt);
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DiskOperation<T> { T run() throws IOException; }
|
||||
|
||||
private <T> CompletableFuture<T> submit(DiskOperation<T> operation) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return operation.run();
|
||||
} catch (IOException exception) {
|
||||
throw new CompletionException(exception);
|
||||
}
|
||||
}, worker);
|
||||
} catch (RejectedExecutionException exception) {
|
||||
return CompletableFuture.failedFuture(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
|
||||
@Override
|
||||
public void close() {
|
||||
worker.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Plain values only: no Bukkit item deserialization or arbitrary Java YAML tags. */
|
||||
final class CharacterYaml {
|
||||
private CharacterYaml() { }
|
||||
|
||||
private static Yaml yaml() {
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(0);
|
||||
options.setCodePointLimit(1_048_576);
|
||||
return new Yaml(new SafeConstructor(options));
|
||||
}
|
||||
|
||||
static String encode(CharacterRecord record) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("schema-version", 1);
|
||||
root.put("character-id", record.id().toString());
|
||||
root.put("name", record.name());
|
||||
root.put("revision", record.revision().toString());
|
||||
Map<String, String> stats = new LinkedHashMap<>();
|
||||
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
|
||||
root.put("stats", stats);
|
||||
root.put("level", record.level());
|
||||
root.put("experience", record.experience().toString());
|
||||
root.put("unspent-points", record.unspentPoints().toString());
|
||||
root.put("health", record.health().toString());
|
||||
root.put("learned-abilities", record.learned().stream().sorted().toList());
|
||||
root.put("loadout", record.loadout());
|
||||
List<Map<String, String>> cooldowns = new ArrayList<>();
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
root.put("cooldowns", cooldowns);
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
root.put("died-at", record.diedAt().toString());
|
||||
}
|
||||
return yaml().dump(root);
|
||||
}
|
||||
|
||||
static CharacterRecord decode(String input) {
|
||||
Map<?, ?> root = object(yaml().load(input));
|
||||
if (!number(root.get("schema-version")).equals(BigInteger.ONE)) {
|
||||
throw new IllegalArgumentException("Unsupported character schema");
|
||||
}
|
||||
Map<?, ?> storedStats = object(root.get("stats"));
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (var entry : storedStats.entrySet()) {
|
||||
stats.put(Stat.valueOf(text(entry.getKey())), number(entry.getValue()));
|
||||
}
|
||||
List<String> learnedList = strings(root.get("learned-abilities"));
|
||||
var learned = new LinkedHashSet<>(learnedList);
|
||||
if (learned.size() != learnedList.size()) throw new IllegalArgumentException("Duplicate learned ability");
|
||||
Map<String, Instant> cooldowns = new LinkedHashMap<>();
|
||||
for (Object value : list(root.get("cooldowns"))) {
|
||||
Map<?, ?> row = object(value);
|
||||
if (cooldowns.putIfAbsent(text(row.get("ability")), instant(row.get("ready-at"))) != null) {
|
||||
throw new IllegalArgumentException("Duplicate cooldown");
|
||||
}
|
||||
}
|
||||
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
}
|
||||
|
||||
private static Map<?, ?> object(Object value) {
|
||||
if (value instanceof Map<?, ?> map) return map;
|
||||
throw new IllegalArgumentException("Expected YAML mapping");
|
||||
}
|
||||
|
||||
private static List<?> list(Object value) {
|
||||
if (value instanceof List<?> result) return result;
|
||||
throw new IllegalArgumentException("Expected YAML list");
|
||||
}
|
||||
|
||||
private static List<String> strings(Object value) {
|
||||
return list(value).stream().map(CharacterYaml::text).toList();
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof String string) return string;
|
||||
throw new IllegalArgumentException("Expected YAML string");
|
||||
}
|
||||
|
||||
private static BigInteger number(Object value) {
|
||||
if (value instanceof Integer || value instanceof Long || value instanceof BigInteger) {
|
||||
return new BigInteger(value.toString());
|
||||
}
|
||||
return new BigInteger(text(value));
|
||||
}
|
||||
|
||||
private static Instant instant(Object value) {
|
||||
String string = text(value);
|
||||
if (!string.endsWith("Z")) throw new IllegalArgumentException("Expected UTC timestamp");
|
||||
return Instant.parse(string);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
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;
|
||||
|
||||
/** Blocking disk adapter: callers must use the persistence worker, never the server tick thread. */
|
||||
public final class YamlCharacterRepository {
|
||||
private static final int MAX_FILE_BYTES = 1_048_576;
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Replacement { void move(Path source, Path target) throws IOException; }
|
||||
@FunctionalInterface
|
||||
private interface Operation { void run() throws IOException; }
|
||||
|
||||
public YamlCharacterRepository(Path pluginDataDirectory) {
|
||||
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
YamlCharacterRepository(Path pluginDataDirectory, Replacement replacement) {
|
||||
directory = pluginDataDirectory.resolve("characters").toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
}
|
||||
|
||||
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");
|
||||
Creation[] created = new Creation[1];
|
||||
withLock(() -> {
|
||||
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Character already exists: " + character.id());
|
||||
}
|
||||
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(() -> {
|
||||
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();
|
||||
try (var input = Files.newInputStream(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] bytes = input.readNBytes(MAX_FILE_BYTES + 1);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
|
||||
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
|
||||
CharacterRecord record = CharacterYaml.decode(text);
|
||||
if (!record.id().equals(id)) throw new IllegalArgumentException("Character ID mismatch");
|
||||
return Optional.of(record);
|
||||
} catch (RuntimeException exception) {
|
||||
// Parser diagnostics may contain user-controlled data; preserve the file, not its text in logs.
|
||||
throw new IOException("Invalid character YAML for " + id);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(".records.lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Character record busy; retry the operation");
|
||||
operation.run();
|
||||
} catch (OverlappingFileLockException exception) {
|
||||
throw new IOException("Character record busy; retry the operation");
|
||||
}
|
||||
}
|
||||
|
||||
private Path file(UUID id) {
|
||||
return directory.resolve(Objects.requireNonNull(id) + ".yml");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.RecipeChoice;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CharacterSheetsNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CharacterRecord character() {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
return new CharacterRecord(UUID.randomUUID(), "Ari of Ash", BigInteger.ZERO, stats, 1,
|
||||
BigInteger.ZERO, BigInteger.valueOf(40), BigDecimal.TEN, Set.of(), Collections.nCopies(7, ""),
|
||||
Map.of(), false, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void presentationEditsPreserveIdentityButLookalikesAndMalformedReferencesAreRejected() {
|
||||
var sheets = new CharacterSheets();
|
||||
var record = character();
|
||||
var sheet = sheets.create(record);
|
||||
var meta = sheet.getItemMeta();
|
||||
meta.displayName(Component.text("Different presentation"));
|
||||
sheet.setItemMeta(meta);
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
var fake = new ItemStack(Material.PAPER);
|
||||
var fakeMeta = fake.getItemMeta();
|
||||
fakeMeta.displayName(meta.displayName());
|
||||
fakeMeta.lore(meta.lore());
|
||||
fake.setItemMeta(fakeMeta);
|
||||
assertTrue(sheets.identify(fake).isEmpty());
|
||||
assertTrue(sheets.identify(new ItemStack(Material.STICK)).isEmpty());
|
||||
assertTrue(sheets.identify(null).isEmpty());
|
||||
var version = new NamespacedKey("fantasystick", "sheet_version");
|
||||
for (int variant = 0; variant < 3; variant++) {
|
||||
var invalid = sheet.clone();
|
||||
var invalidMeta = invalid.getItemMeta();
|
||||
var data = invalidMeta.getPersistentDataContainer();
|
||||
if (variant == 0) data.set(version, PersistentDataType.INTEGER, 2);
|
||||
else if (variant == 1) data.set(version, PersistentDataType.STRING, "1");
|
||||
else data.set(new NamespacedKey("fantasystick", "character_id"), PersistentDataType.STRING, "not-a-uuid");
|
||||
invalid.setItemMeta(invalidMeta);
|
||||
assertTrue(sheets.identify(invalid).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fourIngredientShapelessRecipeOnlyExposesAnUnidentifiedPaperPreview() {
|
||||
var sheets = new CharacterSheets();
|
||||
var recipe = sheets.recipe();
|
||||
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
|
||||
assertEquals(4, recipe.getChoiceList().size());
|
||||
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
|
||||
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
|
||||
assertEquals(Set.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC),
|
||||
recipe.getChoiceList().stream().map(choice -> ((RecipeChoice.MaterialChoice) choice).getChoices().getFirst())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
for (int i = 0; i < 10; i++) {
|
||||
var preview = sheets.recipe().getResult();
|
||||
assertEquals(Material.PAPER, preview.getType());
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertTrue(sheets.identify(preview).isEmpty());
|
||||
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeadRecordCannotBeTurnedIntoASheetByTheItemFactory() {
|
||||
var original = character();
|
||||
var dead = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
|
||||
original.level(), original.experience(), original.unspentPoints(), BigDecimal.ZERO,
|
||||
original.learned(), original.loadout(), original.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
|
||||
assertThrows(IllegalArgumentException.class, () -> new CharacterSheets().create(dead));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sheetShowsSavedNameAndInstructionAndSerializesIdentityWithoutAnOwnerLock() {
|
||||
var record = character();
|
||||
var sheets = new CharacterSheets();
|
||||
var sheet = sheets.create(record);
|
||||
assertEquals(Material.PAPER, sheet.getType());
|
||||
assertEquals(1, sheet.getAmount());
|
||||
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
|
||||
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
|
||||
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(sheet)).getOrThrow();
|
||||
var reloaded = CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(reloaded).orElseThrow());
|
||||
assertEquals(sheet, reloaded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.minecraft.core.NonNullList;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.minecraft.world.entity.EntityEquipment;
|
||||
import net.minecraft.world.entity.player.Inventory;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.block.ShulkerBox;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftInventoryPlayer;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BlockStateMeta;
|
||||
import org.bukkit.inventory.meta.Damageable;
|
||||
import org.bukkit.inventory.meta.BundleMeta;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EquipmentStorageNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CraftInventoryPlayer inventory() {
|
||||
// Real native player slot/equipment storage. Only the absent network recipient is stubbed.
|
||||
return new CraftInventoryPlayer(new Inventory(null, new EntityEquipment()) {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedAmbiguousAndOccupiedRestorationPathsLeaveAllItemsUntouched() {
|
||||
for (int variant = 0; variant < 6; variant++) {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var item = inventory.getItem(9);
|
||||
var meta = item.getItemMeta();
|
||||
var expected = EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
switch (variant) {
|
||||
case 0 -> {
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "storage_version"), PersistentDataType.INTEGER, 2);
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
case 1 -> {
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "storage_slots"), PersistentDataType.INTEGER_ARRAY, new int[]{35, 35});
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
case 2 -> {
|
||||
inventory.setItem(0, new ItemStack(Material.EMERALD));
|
||||
expected = EquipmentStorage.RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
case 3 -> {
|
||||
inventory.setItem(10, item.clone());
|
||||
expected = EquipmentStorage.RestoreResult.AMBIGUOUS;
|
||||
}
|
||||
case 4 -> item.setAmount(2);
|
||||
case 5 -> {
|
||||
var impostor = new ItemStack(Material.SHULKER_BOX);
|
||||
var impostorMeta = impostor.getItemMeta();
|
||||
impostorMeta.displayName(Component.text("Fantasy Storage Box"));
|
||||
impostor.setItemMeta(impostorMeta);
|
||||
inventory.setItem(9, impostor);
|
||||
expected = EquipmentStorage.RestoreResult.NOT_FOUND;
|
||||
}
|
||||
default -> throw new AssertionError();
|
||||
}
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(expected, storage.restore(inventory, owner, session), "variant " + variant);
|
||||
assertArrayEquals(before, inventory.getContents(), "variant " + variant);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eligibilityMatchesTheActualVanillaShulkerSlotForEveryNativeItemMaterial() {
|
||||
var nativeSlot = new net.minecraft.world.inventory.ShulkerBoxSlot(new net.minecraft.world.SimpleContainer(27), 0, 0, 0);
|
||||
var storage = new EquipmentStorage();
|
||||
for (Material material : Material.values()) {
|
||||
if (material.isLegacy() || !material.isItem() || material.isAir()) continue;
|
||||
var item = new ItemStack(material);
|
||||
boolean allowed = nativeSlot.mayPlace(CraftItemStack.asNMSCopy(item));
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, item);
|
||||
assertEquals(allowed ? EquipmentStorage.PackResult.PACKED : EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()), material.name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedStacksBeforeAnyEquipmentMoves() {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
inventory.setItem(8, new ItemStack(Material.ARROW, 65));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
new EquipmentStorage().pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullMainInventoryRefusesUnchangedAndOnlyAMainSlotCountsAsSpace() {
|
||||
var inventory = inventory();
|
||||
for (int slot = 9; slot < 36; slot++) inventory.setItem(slot, new ItemStack(Material.STONE, 64));
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND, 12));
|
||||
inventory.setItemInOffHand(new ItemStack(Material.PAPER));
|
||||
var before = snapshot(inventory);
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.NO_SPACE, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
inventory.setItem(35, null);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
assertEquals(Material.SHULKER_BOX, inventory.getItem(35).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesUnexpectedBoxContentsInsteadOfDeletingThemOnRestoration() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var item = inventory.getItem(9);
|
||||
var meta = (BlockStateMeta) item.getItemMeta();
|
||||
var box = (ShulkerBox) meta.getBlockState();
|
||||
box.getInventory().setItem(26, new ItemStack(Material.EMERALD, 64));
|
||||
meta.setBlockState(box);
|
||||
item.setItemMeta(meta);
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.RestoreResult.INVALID_BOX, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedPackingAndWrongOwnerOrSessionCannotDuplicateOrRestoreEquipment() {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var packed = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.ALREADY_PACKED, storage.pack(inventory, owner, session));
|
||||
assertArrayEquals(packed, inventory.getContents());
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, UUID.randomUUID(), session));
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, owner, UUID.randomUUID()));
|
||||
assertArrayEquals(packed, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsNewHeadInTheFreedBoxSlotEvenWhenMainInventoryIsFull() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
var helmet = new ItemStack(Material.DIAMOND_HELMET);
|
||||
var meta = (Damageable) helmet.getItemMeta();
|
||||
meta.setDamage(93);
|
||||
meta.addEnchant(Enchantment.PROTECTION, 4, true);
|
||||
helmet.setItemMeta(meta);
|
||||
inventory.setHelmet(helmet);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
for (int slot = 10; slot < 36; slot++) inventory.setItem(slot, new ItemStack(Material.COBBLESTONE, 64));
|
||||
var pumpkin = new ItemStack(Material.CARVED_PUMPKIN);
|
||||
inventory.setHelmet(pumpkin);
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, storage.restore(inventory, owner, session));
|
||||
assertEquals(helmet, inventory.getHelmet(), "Existing damage and protection are not repaired or discarded");
|
||||
assertEquals(pumpkin, inventory.getItem(9));
|
||||
for (int slot = 10; slot < 36; slot++) assertEquals(new ItemStack(Material.COBBLESTONE, 64), inventory.getItem(slot));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nativeSerializedContentsNeedNoPluginAndRestoreAfterTheBoxMoves() {
|
||||
var inventory = inventory();
|
||||
var owner = UUID.randomUUID();
|
||||
var session = UUID.randomUUID();
|
||||
var storage = new EquipmentStorage();
|
||||
for (int slot = 0; slot < 9; slot++) inventory.setItem(slot, new ItemStack(Material.ARROW, slot + 1));
|
||||
var nested = new ItemStack(Material.BUNDLE);
|
||||
var bundle = (BundleMeta) nested.getItemMeta();
|
||||
bundle.addItem(new ItemStack(Material.EMERALD, 12));
|
||||
nested.setItemMeta(bundle);
|
||||
inventory.setItem(3, nested);
|
||||
inventory.setHelmet(new ItemStack(Material.DIAMOND_HELMET));
|
||||
inventory.setLeggings(new ItemStack(Material.IRON_LEGGINGS));
|
||||
inventory.setChestplate(new ItemStack(Material.ELYTRA));
|
||||
inventory.setBoots(new ItemStack(Material.GOLDEN_BOOTS));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops,
|
||||
CraftItemStack.asNMSCopy(inventory.getItem(9))).getOrThrow();
|
||||
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
|
||||
// Minecraft's native container component, not a Fantasy Stick reconstruction receipt.
|
||||
var contents = NonNullList.withSize(27, net.minecraft.world.item.ItemStack.EMPTY);
|
||||
assertNotNull(decoded.get(DataComponents.CONTAINER));
|
||||
decoded.get(DataComponents.CONTAINER).copyInto(contents);
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(nested), contents.get(3)));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(before[36]), contents.get(9)));
|
||||
assertTrue(net.minecraft.world.item.ItemStack.matches(CraftItemStack.asNMSCopy(before[39]), contents.get(12)));
|
||||
inventory.setItem(9, null);
|
||||
inventory.setItem(35, CraftItemStack.asBukkitCopy(decoded));
|
||||
assertEquals(EquipmentStorage.RestoreResult.RESTORED, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, owner, session));
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesEveryShulkerColorInPackedSlotsWithoutAnyPartialMutation() {
|
||||
for (Material material : Material.values()) {
|
||||
if (!material.name().endsWith("SHULKER_BOX") || material.isLegacy()) continue;
|
||||
for (int slot : new int[]{0, 8, 36, 39}) {
|
||||
var inventory = inventory();
|
||||
inventory.setItem(1, new ItemStack(Material.DIAMOND, 12));
|
||||
inventory.setItem(slot, new ItemStack(material));
|
||||
var before = snapshot(inventory);
|
||||
assertEquals(EquipmentStorage.PackResult.INELIGIBLE_EQUIPMENT,
|
||||
new EquipmentStorage().pack(inventory, UUID.randomUUID(), UUID.randomUUID()), material + " slot " + slot);
|
||||
assertArrayEquals(before, inventory.getContents());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemStack[] snapshot(CraftInventoryPlayer inventory) {
|
||||
return Arrays.stream(inventory.getContents()).map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
@Test
|
||||
void movesActualHotbarAndArmorIntoOneNativeShulkerWithoutTouchingMainItemsOrSheet() {
|
||||
var inventory = inventory();
|
||||
var sword = new ItemStack(Material.DIAMOND_SWORD);
|
||||
var meta = (Damageable) sword.getItemMeta();
|
||||
meta.setDamage(137);
|
||||
meta.displayName(Component.text("Old sword"));
|
||||
sword.setItemMeta(meta);
|
||||
var arrows = new ItemStack(Material.ARROW, 47);
|
||||
var boots = new ItemStack(Material.DIAMOND_BOOTS);
|
||||
inventory.setItem(0, sword);
|
||||
inventory.setItem(8, arrows);
|
||||
inventory.setBoots(boots);
|
||||
var unrelated = new ItemStack(Material.RED_SHULKER_BOX);
|
||||
var sheet = new ItemStack(Material.PAPER);
|
||||
inventory.setItem(9, unrelated);
|
||||
inventory.setItemInOffHand(sheet);
|
||||
var storage = new EquipmentStorage();
|
||||
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
|
||||
for (int i = 0; i < 9; i++) assertNull(inventory.getItem(i));
|
||||
assertTrue(inventory.getBoots().isEmpty());
|
||||
assertEquals(unrelated, inventory.getItem(9));
|
||||
assertEquals(sheet, inventory.getItemInOffHand());
|
||||
var boxItem = inventory.getItem(10);
|
||||
assertNotNull(boxItem);
|
||||
assertEquals(Material.SHULKER_BOX, boxItem.getType());
|
||||
assertEquals(Component.text("Fantasy Storage Box"), boxItem.getItemMeta().displayName());
|
||||
var box = (ShulkerBox) ((BlockStateMeta) boxItem.getItemMeta()).getBlockState();
|
||||
assertEquals(sword, box.getInventory().getItem(0));
|
||||
assertEquals(arrows, box.getInventory().getItem(8));
|
||||
assertEquals(boots, box.getInventory().getItem(9));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkDropRevalidationAcceptsOnlyTheExpectedRemainingInputsBeforeEachNextUnit() {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new Fixture(width, 3);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
assertTrue(plan.matchesAfterDrops(fixture.view, 0));
|
||||
for (int completed = 1; completed < 3; completed++) {
|
||||
for (var item : fixture.grid.getMatrix()) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - 1);
|
||||
assertTrue(plan.matchesAfterDrops(fixture.view, completed));
|
||||
assertFalse(plan.matchesAfterDrops(fixture.view, completed - 1));
|
||||
assertFalse(plan.matches(fixture.view));
|
||||
}
|
||||
fixture.grid.getMatrix()[0].setAmount(2);
|
||||
assertFalse(plan.matchesAfterDrops(fixture.view, 2), "Do not recapture unrelated callback changes as new authorized input");
|
||||
assertFalse(plan.matchesAfterDrops(fixture.view, -1));
|
||||
assertFalse(plan.matchesAfterDrops(fixture.view, 3));
|
||||
var cursor = new Fixture(width, 3);
|
||||
var cursorPlan = ManualCraftPlan.capture(cursor.event(ClickType.LEFT, InventoryAction.PICKUP_ALL)).orElseThrow();
|
||||
assertFalse(cursorPlan.matchesAfterDrops(cursor.view, 0));
|
||||
}
|
||||
}
|
||||
|
||||
static final class Fixture {
|
||||
final Menu menu = new Menu();
|
||||
Menu activeMenu = menu;
|
||||
final TransientCraftingContainer matrix;
|
||||
final CraftInventoryCrafting grid;
|
||||
final CraftInventoryPlayer inventory = new CraftInventoryPlayer(
|
||||
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(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"); }
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
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) { onChange.run(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.lang.reflect.Proxy;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapelessRecipe;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ManualCraftingGuardNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static Block untouchedBlock() {
|
||||
// The event boundary supplies a block, but cancellation must not access/mutate world or inventory.
|
||||
return (Block) Proxy.newProxyInstance(Block.class.getClassLoader(), new Class<?>[]{Block.class},
|
||||
(proxy, method, arguments) -> { throw new AssertionError("Unexpected block access: " + method.getName()); });
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesOtherRecipesAndExistingCancellationsAlone() {
|
||||
var guard = new ManualCraftingGuard();
|
||||
for (var key : new NamespacedKey[]{new NamespacedKey("minecraft", "paper"), new NamespacedKey("other", "character_sheet")}) {
|
||||
var recipe = new ShapelessRecipe(key, new ItemStack(Material.PAPER)).addIngredient(Material.SUGAR_CANE);
|
||||
for (boolean cancelled : new boolean[]{false, true}) {
|
||||
var event = new CrafterCraftEvent(untouchedBlock(), recipe, recipe.getResult());
|
||||
event.setCancelled(cancelled);
|
||||
guard.onAutomatedCraft(event);
|
||||
assertEquals(cancelled, event.isCancelled());
|
||||
assertEquals(recipe.getResult(), event.getResult());
|
||||
}
|
||||
}
|
||||
var ours = new CharacterSheets().recipe();
|
||||
var alreadyCancelled = new CrafterCraftEvent(untouchedBlock(), ours, ours.getResult());
|
||||
alreadyCancelled.setCancelled(true);
|
||||
guard.onAutomatedCraft(alreadyCancelled);
|
||||
assertTrue(alreadyCancelled.isCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresTheNativeEventHookWithoutOverridingPriorCancellations() throws Exception {
|
||||
var annotation = ManualCraftingGuard.class.getMethod("onAutomatedCraft", CrafterCraftEvent.class).getAnnotation(EventHandler.class);
|
||||
assertNotNull(annotation);
|
||||
assertEquals(EventPriority.HIGHEST, annotation.priority());
|
||||
assertTrue(annotation.ignoreCancelled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelsAutomatedSheetCraftWithoutTouchingTheBlockOrResult() {
|
||||
var recipe = new CharacterSheets().recipe();
|
||||
var result = recipe.getResult();
|
||||
var event = new CrafterCraftEvent(untouchedBlock(), recipe, result);
|
||||
new ManualCraftingGuard().onAutomatedCraft(event);
|
||||
assertTrue(event.isCancelled(), "The native Crafter must stop before consumption/ejection");
|
||||
assertEquals(result, event.getResult());
|
||||
assertEquals(Material.PAPER, event.getResult().getType());
|
||||
assertTrue(new CharacterSheets().identify(event.getResult()).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftPlan;
|
||||
import games.dmg.fantasystick.crafting.ManualDropDelivery;
|
||||
import games.dmg.fantasystick.crafting.ManualDropDelivery.Status;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real matrix/item/persistence boundaries; DropTarget is the external world-insertion seam, not a live world. */
|
||||
class ManualDropDeliveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void ctrlQCompletesDistinctUnitsAndStopsAtCancellationWithoutChargingTheCancelledUnit() throws Exception {
|
||||
for (int cancelAfter : new int[] {0, 2, 4}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(3, 4);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
var factory = new CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, 4).mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(records);
|
||||
var batch = attempt.ready().join();
|
||||
var outputs = new ArrayList<ItemStack>();
|
||||
var changes = new AtomicInteger();
|
||||
var calls = new AtomicInteger();
|
||||
fixture.menu.onChange = () -> changes.incrementAndGet();
|
||||
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, (sheet, current) -> {
|
||||
calls.incrementAndGet();
|
||||
assertTrue(current.getAsBoolean());
|
||||
assertEquals(4 - outputs.size(), fixture.grid.getMatrix()[0].getAmount());
|
||||
if (outputs.size() == cancelAfter) return Status.CANCELLED;
|
||||
outputs.add(ItemStack.deserializeBytes(sheet.serializeAsBytes()));
|
||||
return Status.COMPLETE;
|
||||
});
|
||||
assertEquals(new ManualDropDelivery.Result(cancelAfter == 4 ? Status.COMPLETE : Status.CANCELLED, cancelAfter), result);
|
||||
assertEquals(Math.min(4, cancelAfter + 1), calls.get());
|
||||
assertEquals(cancelAfter, changes.get());
|
||||
var first = fixture.grid.getMatrix()[0];
|
||||
assertEquals(4 - cancelAfter, first == null ? 0 : first.getAmount());
|
||||
assertEquals(records.subList(0, cancelAfter).stream().map(record -> record.id()).toList(),
|
||||
outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList());
|
||||
assertEquals(cancelAfter, outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).distinct().count());
|
||||
assertEquals(4, attempt.abandon().join().retained().size(), "Never delete records after public drop callbacks may have exposed sheets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusedAttemptsNeverReachTheWorldOrConsumeCleanupAuthority() throws Exception {
|
||||
for (int scenario = 0; scenario < 5; scenario++) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(scenario == 4 ? ClickType.LEFT : ClickType.CONTROL_DROP,
|
||||
scenario == 4 ? InventoryAction.PICKUP_ALL : InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
var factory = new CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, scenario == 2 ? 1 : 2)
|
||||
.mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(records);
|
||||
var batch = attempt.ready().join();
|
||||
if (scenario == 0) fixture.grid.getMatrix()[0].setAmount(7);
|
||||
if (scenario == 3) attempt.abandon();
|
||||
boolean allowed = scenario != 1;
|
||||
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> allowed,
|
||||
(sheet, current) -> { fail("A refused attempt must not reach the world"); return Status.COMPLETE; });
|
||||
assertEquals(scenario < 2 ? Status.STALE : scenario == 4 ? Status.UNSUPPORTED_ROUTE : Status.UNAVAILABLE, result.status());
|
||||
assertEquals(0, result.completed());
|
||||
assertEquals(scenario == 0 ? 7 : 2, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(records.size(), attempt.abandon().join().removed().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void callbackChangesStopBulkWithoutChargingNewContentsOrRewritingTheNewCursor() throws Exception {
|
||||
for (boolean duringSpawn : new boolean[] {true, false}) {
|
||||
for (int mutation = 0; mutation < 4; mutation++) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 3);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.CONTROL_DROP, InventoryAction.DROP_ALL_SLOT)).orElseThrow();
|
||||
var factory = new CharacterFactory();
|
||||
var records = java.util.stream.IntStream.range(0, 3).mapToObj(index -> factory.create(ProgressionRules.defaults())).toList();
|
||||
var view = new java.util.concurrent.atomic.AtomicReference<org.bukkit.inventory.InventoryView>(fixture.view);
|
||||
var authorized = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
int change = mutation;
|
||||
Runnable mutate = () -> {
|
||||
if (change == 0) fixture.grid.getMatrix()[0].setAmount(7);
|
||||
if (change == 1) fixture.view.setCursor(new ItemStack(org.bukkit.Material.DIAMOND));
|
||||
if (change == 2) view.set(new ManualCraftPlanNativeTest.Fixture(2, 3).view);
|
||||
if (change == 3) authorized.set(false); // Same-menu close/reopen is not detected by contents alone.
|
||||
};
|
||||
var calls = new AtomicInteger();
|
||||
if (!duringSpawn) fixture.menu.onChange = mutate;
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(records);
|
||||
var result = ManualDropDelivery.commit(plan, attempt.ready().join(), view::get, authorized::get, (sheet, current) -> {
|
||||
assertEquals(1, calls.incrementAndGet());
|
||||
assertTrue(current.getAsBoolean());
|
||||
if (duringSpawn) mutate.run();
|
||||
return Status.COMPLETE;
|
||||
});
|
||||
assertEquals(new ManualDropDelivery.Result(duringSpawn ? Status.UNCONFIRMED : Status.STALE, duringSpawn ? 0 : 1), result);
|
||||
assertEquals(change == 0 ? 7 : duringSpawn ? 3 : 2, fixture.grid.getMatrix()[0].getAmount());
|
||||
if (change == 1) assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(3, attempt.abandon().join().retained().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedReentrantAndExceptionalCompletionsNeverReuseABatchOrDeletePossiblyIssuedRecords() throws Exception {
|
||||
for (int failure : new int[] {0, 1, 2}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(List.of(record));
|
||||
var batch = attempt.ready().join();
|
||||
var calls = new AtomicInteger();
|
||||
int mode = failure;
|
||||
if (mode == 2) fixture.menu.onChange = () -> { throw new IllegalStateException("prepare failure"); };
|
||||
ManualDropDelivery.DropTarget target = (sheet, current) -> {
|
||||
assertEquals(1, calls.incrementAndGet());
|
||||
var reentrant = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true,
|
||||
(other, valid) -> { fail("Batch replay reached world insertion"); return Status.COMPLETE; });
|
||||
assertEquals(Status.UNAVAILABLE, reentrant.status());
|
||||
if (mode == 1) throw new IllegalStateException("drop failure");
|
||||
return Status.COMPLETE;
|
||||
};
|
||||
if (mode == 0) assertEquals(Status.COMPLETE,
|
||||
ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target).status());
|
||||
else assertThrows(IllegalStateException.class,
|
||||
() -> ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target));
|
||||
assertEquals(mode == 1 ? 2 : 1, fixture.grid.getMatrix()[0].getAmount());
|
||||
assertEquals(1, attempt.abandon().join().retained().size());
|
||||
assertNotEquals(Status.COMPLETE, ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, target).status());
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void qCraftDropsOneSavedSheetAndDebitsExactlyOneSetBeforeRecomputation() throws Exception {
|
||||
for (int width : new int[] {2, 3}) {
|
||||
var fixture = new ManualCraftPlanNativeTest.Fixture(width, 3);
|
||||
var plan = ManualCraftPlan.capture(fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT)).orElseThrow();
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
var batch = storage.prepareCraft(List.of(record)).ready().join();
|
||||
var outputs = new ArrayList<ItemStack>();
|
||||
var changes = new AtomicInteger();
|
||||
fixture.menu.onChange = () -> {
|
||||
changes.incrementAndGet();
|
||||
assertEquals(1, outputs.size());
|
||||
assertEquals(2, fixture.grid.getMatrix()[0].getAmount());
|
||||
};
|
||||
var result = ManualDropDelivery.commit(plan, batch, () -> fixture.view, () -> true, (sheet, current) -> {
|
||||
assertTrue(current.getAsBoolean());
|
||||
assertEquals(3, fixture.grid.getMatrix()[0].getAmount(), "Do not debit before cancellable drop callbacks");
|
||||
outputs.add(ItemStack.deserializeBytes(sheet.serializeAsBytes()));
|
||||
return Status.COMPLETE;
|
||||
});
|
||||
assertEquals(new ManualDropDelivery.Result(Status.COMPLETE, 1), result);
|
||||
assertEquals(record.id(), new CharacterSheets().identify(outputs.getFirst()).orElseThrow());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
assertEquals(1, changes.get());
|
||||
assertTrue(fixture.view.getCursor().isEmpty());
|
||||
assertNull(fixture.inventory.getItem(8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Vanilla registry/tag/component bootstrap, shared in approach with the other Purpur native suites. */
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() { }
|
||||
private static boolean ready;
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName("org.bukkit.Registry"); // Initialize the fixed API registry before loading data packs.
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.ManualDropDelivery.Status;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.UUID;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.ItemSpawnEvent;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Real native sheet data and API events; world insertion/player are explicit boundary doubles, not a live server. */
|
||||
class PlayerSheetDropsNativeTest {
|
||||
@org.junit.jupiter.api.io.TempDir java.nio.file.Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void publishesOnePlayerDropEventBeforeNativeSpawningWithARealSavedSheetAndPlayerDropDefaults() {
|
||||
var fixture = new WorldFixture();
|
||||
var sheet = sheet();
|
||||
var events = new AtomicInteger();
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> {
|
||||
events.incrementAndGet();
|
||||
assertSame(fixture.player, event.getPlayer());
|
||||
assertEquals(sheet, event.getItemDrop().getItemStack());
|
||||
assertFalse(event.getItemDrop().isInWorld());
|
||||
assertEquals(0, fixture.spawnCalls);
|
||||
});
|
||||
assertEquals(Status.COMPLETE, drops.drop(sheet, () -> true));
|
||||
assertEquals(1, events.get());
|
||||
assertEquals(1, fixture.spawnCalls);
|
||||
assertEquals(1, fixture.spawnEvents);
|
||||
assertEquals(sheet, fixture.stack);
|
||||
assertEquals(fixture.playerId, fixture.thrower);
|
||||
assertEquals(40, fixture.pickupDelay);
|
||||
assertEquals(65.32, fixture.location.getY(), 0.00001);
|
||||
assertTrue(Math.abs(fixture.velocity.getX()) <= .02);
|
||||
assertTrue(fixture.velocity.getY() >= 0 && fixture.velocity.getY() <= .2);
|
||||
assertTrue(fixture.velocity.getZ() >= .28 && fixture.velocity.getZ() <= .32);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCancelledPlayerDropNeverSpawnsOrUsesTheNativeInventoryReturnFallback() {
|
||||
var fixture = new WorldFixture();
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> event.setCancelled(true));
|
||||
assertEquals(Status.CANCELLED, drops.drop(sheet(), () -> true));
|
||||
assertEquals(1, fixture.creates);
|
||||
assertEquals(0, fixture.spawnCalls);
|
||||
assertEquals(0, fixture.spawnEvents);
|
||||
assertFalse(fixture.inWorld);
|
||||
// The player boundary rejects any getInventory/dropItem call: no cancelled sheet is inserted there.
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleContextBeforeOrDuringThePlayerDropEventPreventsWorldInsertion() {
|
||||
for (boolean staleInitially : new boolean[] {false, true}) {
|
||||
var fixture = new WorldFixture();
|
||||
var current = new java.util.concurrent.atomic.AtomicBoolean(!staleInitially);
|
||||
var events = new AtomicInteger();
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> { events.incrementAndGet(); current.set(false); });
|
||||
assertEquals(Status.STALE, drops.drop(sheet(), current::get));
|
||||
assertEquals(staleInitially ? 0 : 1, fixture.creates);
|
||||
assertEquals(staleInitially ? 0 : 1, events.get());
|
||||
assertEquals(0, fixture.spawnCalls);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedRemovedOrAlreadyPublishedEntitiesAreNotSpawnedAgainOrOverwritten() {
|
||||
for (int scenario = 0; scenario < 4; scenario++) {
|
||||
var fixture = new WorldFixture();
|
||||
int mutation = scenario;
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> {
|
||||
if (mutation == 0) fixture.stack.setAmount(2);
|
||||
if (mutation == 1) fixture.removed = true;
|
||||
if (mutation == 2) { fixture.inWorld = true; event.setCancelled(true); }
|
||||
if (mutation == 3) fixture.stack = sheet();
|
||||
});
|
||||
assertEquals(Status.UNCONFIRMED, drops.drop(sheet(), () -> true));
|
||||
assertEquals(0, fixture.spawnCalls);
|
||||
if (mutation == 0) assertEquals(2, fixture.stack.getAmount());
|
||||
if (mutation == 2) assertTrue(fixture.inWorld);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledNativeSpawnsAndPostInsertionChangesAreUnconfirmedRatherThanSuccessfulDrops() {
|
||||
for (int scenario = 0; scenario < 3; scenario++) {
|
||||
var fixture = new WorldFixture();
|
||||
if (scenario == 0) fixture.onSpawn = event -> event.setCancelled(true);
|
||||
if (scenario == 1) fixture.onAdded = () -> fixture.removed = true;
|
||||
if (scenario == 2) fixture.onAdded = () -> fixture.stack = new ItemStack(org.bukkit.Material.DIAMOND);
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> { });
|
||||
assertEquals(Status.UNCONFIRMED, drops.drop(sheet(), () -> true));
|
||||
assertEquals(1, fixture.spawnCalls);
|
||||
assertEquals(1, fixture.spawnEvents);
|
||||
if (scenario == 0) assertFalse(fixture.inWorld);
|
||||
if (scenario == 2) assertEquals(org.bukkit.Material.DIAMOND, fixture.stack.getType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonSheetsAndWrongStackCountsBeforeAnyPublicCallback() {
|
||||
var multiple = sheet();
|
||||
multiple.setAmount(2);
|
||||
for (var invalid : List.of(new ItemStack(org.bukkit.Material.PAPER), multiple)) {
|
||||
var fixture = new WorldFixture();
|
||||
var drops = new PlayerSheetDrops(fixture.player, event -> fail("Invalid sheet reached an event"));
|
||||
assertThrows(IllegalArgumentException.class, () -> drops.drop(invalid, () -> true));
|
||||
assertEquals(0, fixture.creates);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void realDispatcherCancellationFlowsThroughSavedDropExecutionWithoutChargingCancelledUnits() throws Exception {
|
||||
var server = proxy(org.bukkit.Server.class, (name, args) -> switch (name) {
|
||||
case "isPrimaryThread" -> true;
|
||||
default -> throw new AssertionError("Unexpected server call " + name);
|
||||
});
|
||||
var dispatcher = new io.papermc.paper.plugin.manager.NativeEventDispatcher(server);
|
||||
var plugin = proxy(org.bukkit.plugin.Plugin.class, (name, args) -> switch (name) {
|
||||
case "isEnabled" -> true;
|
||||
default -> throw new AssertionError("Unexpected plugin call " + name);
|
||||
});
|
||||
for (int mode = 0; mode < 3; mode++) {
|
||||
var listener = new org.bukkit.event.Listener() { };
|
||||
var trace = new java.util.ArrayList<String>();
|
||||
int cancellation = mode;
|
||||
PlayerDropItemEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener, (ignored, raw) -> {
|
||||
trace.add("player-veto");
|
||||
if (cancellation == 1) ((PlayerDropItemEvent) raw).setCancelled(true);
|
||||
}, org.bukkit.event.EventPriority.HIGH, plugin, false));
|
||||
PlayerDropItemEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener,
|
||||
(ignored, raw) -> trace.add("player-allowed"), org.bukkit.event.EventPriority.HIGHEST, plugin, true));
|
||||
ItemSpawnEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener, (ignored, raw) -> {
|
||||
trace.add("spawn-veto");
|
||||
if (cancellation == 2) ((ItemSpawnEvent) raw).setCancelled(true);
|
||||
}, org.bukkit.event.EventPriority.HIGH, plugin, false));
|
||||
ItemSpawnEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(listener,
|
||||
(ignored, raw) -> trace.add("spawn-allowed"), org.bukkit.event.EventPriority.HIGHEST, plugin, true));
|
||||
try {
|
||||
var fixture = new WorldFixture();
|
||||
fixture.onSpawn = dispatcher::accept;
|
||||
var grid = new ManualCraftPlanNativeTest.Fixture(2, 2);
|
||||
var plan = games.dmg.fantasystick.crafting.ManualCraftPlan.capture(grid.event(
|
||||
org.bukkit.event.inventory.ClickType.DROP, org.bukkit.event.inventory.InventoryAction.DROP_ONE_SLOT)).orElseThrow();
|
||||
var record = new CharacterFactory().create(ProgressionRules.defaults());
|
||||
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data), java.time.Clock.systemUTC())) {
|
||||
var attempt = storage.prepareCraft(List.of(record));
|
||||
var result = games.dmg.fantasystick.crafting.ManualDropDelivery.commit(plan, attempt.ready().join(),
|
||||
() -> grid.view, () -> true, new PlayerSheetDrops(fixture.player, dispatcher::accept));
|
||||
assertEquals(mode == 0 ? Status.COMPLETE : mode == 1 ? Status.CANCELLED : Status.UNCONFIRMED, result.status());
|
||||
assertEquals(mode == 0 ? 1 : 0, result.completed());
|
||||
assertEquals(mode == 0 ? 1 : 2, grid.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(grid.view.getCursor().isEmpty());
|
||||
assertNull(grid.inventory.getItem(8));
|
||||
assertEquals(mode == 1 ? List.of("player-veto") : mode == 2 ? List.of("player-veto", "player-allowed", "spawn-veto")
|
||||
: List.of("player-veto", "player-allowed", "spawn-veto", "spawn-allowed"), trace);
|
||||
assertEquals(1, attempt.abandon().join().retained().size());
|
||||
}
|
||||
} finally {
|
||||
org.bukkit.event.HandlerList.unregisterAll(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemStack sheet() {
|
||||
return new CharacterSheets().create(new CharacterFactory().create(ProgressionRules.defaults()));
|
||||
}
|
||||
|
||||
static final class WorldFixture {
|
||||
final UUID playerId = UUID.randomUUID();
|
||||
final World world;
|
||||
final Player player;
|
||||
final Item item;
|
||||
ItemStack stack;
|
||||
Location location;
|
||||
Vector velocity;
|
||||
UUID thrower;
|
||||
int pickupDelay;
|
||||
int creates;
|
||||
int spawnCalls;
|
||||
int spawnEvents;
|
||||
boolean inWorld;
|
||||
boolean removed;
|
||||
Consumer<ItemSpawnEvent> onSpawn = event -> { };
|
||||
Runnable onAdded = () -> { };
|
||||
|
||||
WorldFixture() {
|
||||
item = proxy(Item.class, (name, args) -> switch (name) {
|
||||
case "setItemStack" -> { stack = ((ItemStack) args[0]).clone(); yield null; }
|
||||
case "getItemStack" -> stack.clone();
|
||||
case "setPickupDelay" -> { pickupDelay = (int) args[0]; yield null; }
|
||||
case "setThrower" -> { thrower = (UUID) args[0]; yield null; }
|
||||
case "setVelocity" -> { velocity = ((Vector) args[0]).clone(); yield null; }
|
||||
case "getLocation" -> location.clone();
|
||||
case "isInWorld", "isValid" -> inWorld && !removed;
|
||||
case "isDead" -> removed;
|
||||
case "spawnAt" -> {
|
||||
spawnCalls++;
|
||||
assertEquals(org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason.CUSTOM, args[1]);
|
||||
var event = new ItemSpawnEvent(argsItem());
|
||||
spawnEvents++;
|
||||
onSpawn.accept(event);
|
||||
if (event.isCancelled() || removed) { removed = true; yield false; }
|
||||
inWorld = true;
|
||||
onAdded.run();
|
||||
yield true;
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected item call " + name);
|
||||
});
|
||||
world = proxy(World.class, (name, args) -> switch (name) {
|
||||
case "createEntity" -> {
|
||||
creates++;
|
||||
assertEquals(Item.class, args[1]);
|
||||
location = ((Location) args[0]).clone();
|
||||
returnItemUnspawned();
|
||||
yield item;
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected world call " + name);
|
||||
});
|
||||
player = proxy(Player.class, (name, args) -> switch (name) {
|
||||
case "getUniqueId" -> playerId;
|
||||
case "getEyeLocation" -> new Location(world, 10, 65.62, 10, 0, 0);
|
||||
default -> throw new AssertionError("Unexpected player call " + name);
|
||||
});
|
||||
}
|
||||
|
||||
private Item argsItem() { return item; }
|
||||
private void returnItemUnspawned() { inWorld = false; removed = false; }
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Call { Object invoke(String name, Object[] args); }
|
||||
|
||||
private static <T> T proxy(Class<T> type, Call call) {
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, (object, method, args) -> {
|
||||
if (method.getDeclaringClass() == Object.class) return switch (method.getName()) {
|
||||
case "equals" -> object == args[0];
|
||||
case "hashCode" -> System.identityHashCode(object);
|
||||
case "toString" -> "Boundary fixture " + type.getSimpleName();
|
||||
default -> throw new AssertionError(method.getName());
|
||||
};
|
||||
return call.invoke(method.getName(), args);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TailBooksNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CharacterRecord fallen() {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.CON, BigInteger.TEN);
|
||||
return new CharacterRecord(UUID.randomUUID(), "Ari", BigInteger.ONE, stats, 2,
|
||||
BigInteger.valueOf(100), BigInteger.valueOf(30), BigDecimal.ZERO,
|
||||
Set.of("second_wind"), List.of("second_wind", "", "", "", "", "", ""),
|
||||
Map.of(), true, UUID.randomUUID(), Instant.parse("2026-09-17T00:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void respectsTheVanillaTitleLimitWithoutLosingTheFullCharacterName() {
|
||||
var original = fallen();
|
||||
String name = "A".repeat(23) + "\uD83D\uDD25" + "Long Character Name";
|
||||
var saved = new CharacterRecord(original.id(), name, original.revision(), original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.cooldowns(), true, original.deathId(), original.diedAt());
|
||||
var book = new TailBooks().create(saved);
|
||||
var meta = (BookMeta) book.getItemMeta();
|
||||
assertNotNull(meta.getTitle(), "A long character name must not leave an unsigned/missing book title");
|
||||
assertFalse(meta.getTitle().isEmpty(), "The native title setter must not silently reject a long name");
|
||||
assertTrue(meta.getTitle().length() <= 32);
|
||||
assertFalse(Character.isHighSurrogate(meta.getTitle().charAt(meta.getTitle().length() - 1)));
|
||||
assertEquals(Component.text("Tail of " + name), meta.displayName());
|
||||
assertTrue(meta.pages().contains(Component.text("Tail of " + name)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityComesOnlyFromTypedMetadataNotEditablePagesOrANameOnlyLookalike() {
|
||||
var saved = fallen();
|
||||
var books = new TailBooks();
|
||||
var book = books.create(saved);
|
||||
var identity = books.identify(book).orElseThrow();
|
||||
var meta = (BookMeta) book.getItemMeta();
|
||||
meta.setTitle("Fake progression");
|
||||
meta.displayName(Component.text("Changed name"));
|
||||
meta.pages(List.of(Component.text("Level: 999999")));
|
||||
book.setItemMeta(meta);
|
||||
assertEquals(identity, books.identify(book).orElseThrow());
|
||||
assertTrue(books.identify(new ItemStack(Material.WRITTEN_BOOK)).isEmpty());
|
||||
assertTrue(books.identify(new ItemStack(Material.WRITABLE_BOOK)).isEmpty());
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "death_id"), PersistentDataType.STRING, "not-a-uuid");
|
||||
book.setItemMeta(meta);
|
||||
assertTrue(books.identify(book).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void vanillaWrittenBookContentSurvivesNativeSerializationWithoutLoadingAPluginOrYaml() {
|
||||
var saved = fallen();
|
||||
var books = new TailBooks();
|
||||
var book = books.create(saved);
|
||||
assertEquals(Material.WRITTEN_BOOK, book.getType(), "Tail must be a signed vanilla book, not a writable book or paper");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
|
||||
// Read using Minecraft's codec/components only; no Fantasy Stick lookup or plugin instance.
|
||||
var restored = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
|
||||
var content = restored.get(DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
assertEquals("Tail of Ari", content.title().raw());
|
||||
assertEquals("Fantasy Stick", content.author());
|
||||
String pages = content.pages().stream().map(page -> page.raw().getString()).reduce("", (a, b) -> a + "\n" + b);
|
||||
assertTrue(pages.contains("Ari"));
|
||||
assertTrue(pages.contains("Level: 2"));
|
||||
assertTrue(pages.contains("XP: 100"));
|
||||
assertTrue(pages.contains("CON: 10"));
|
||||
assertTrue(pages.contains("second_wind"));
|
||||
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), books.identify(CraftItemStack.asBukkitCopy(restored)).orElseThrow());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package io.papermc.paper.plugin.manager;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.event.Event;
|
||||
|
||||
/** Test-only access to the pinned package-private dispatcher; never included in the plugin artifact. */
|
||||
public final class NativeEventDispatcher implements Consumer<Event> {
|
||||
private final PaperEventManager manager;
|
||||
|
||||
public NativeEventDispatcher(Server server) { manager = new PaperEventManager(server); }
|
||||
|
||||
@Override public void accept(Event event) { manager.callEvent(event); }
|
||||
}
|
||||
@@ -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,191 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterStorageTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void performsFileWritesOffTheCallerThreadAndAcknowledgesOnlyAfterCommit() throws Exception {
|
||||
Thread caller = Thread.currentThread();
|
||||
var entered = new CountDownLatch(1);
|
||||
var allowCommit = new CountDownLatch(1);
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data, (source, target) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!allowCommit.await(5, TimeUnit.SECONDS)) throw new IOException("Test gate timed out");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted", exception);
|
||||
}
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.create(saved);
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone(), "No success acknowledgment before the file commit");
|
||||
} finally {
|
||||
allowCommit.countDown();
|
||||
}
|
||||
pending.join();
|
||||
assertEquals(saved, storage.find(saved.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("Simulated failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
assertThrows(CompletionException.class, () -> storage.restoreDeathRecord(saved.id(), saved.deathId()).join());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
var retry = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC));
|
||||
assertTrue(retry.restoreDeathRecord(saved.id(), saved.deathId()).join().isPresent());
|
||||
retry.close();
|
||||
assertThrows(CompletionException.class, () -> retry.find(saved.id()).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentRequestsAndAReopenedStoreCannotRestoreTheSameDeathTwice() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
var clock = Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var first = storage.restoreDeathRecord(saved.id(), saved.deathId());
|
||||
var second = storage.restoreDeathRecord(saved.id(), saved.deathId());
|
||||
assertTrue(first.join().isPresent());
|
||||
assertTrue(second.join().isEmpty());
|
||||
}
|
||||
try (var reopened = new CharacterStorage(new YamlCharacterRepository(data), clock)) {
|
||||
assertTrue(reopened.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterDeathDoesNotMakeThePreviousBookEligibleAgain() throws Exception {
|
||||
var original = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var clock = Clock.fixed(original.diedAt().plusSeconds(60), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var alive = storage.restoreDeathRecord(original.id(), original.deathId()).join().orElseThrow();
|
||||
var later = new CharacterRecord(alive.id(), "Saved current name", alive.revision().add(BigInteger.ONE),
|
||||
alive.stats(), alive.level(), alive.experience(), alive.unspentPoints(), java.math.BigDecimal.ZERO,
|
||||
alive.learned(), alive.loadout(), alive.cooldowns(), true, UUID.randomUUID(), original.diedAt().plusSeconds(30));
|
||||
repository.replace(later, alive.revision());
|
||||
assertTrue(storage.restoreDeathRecord(original.id(), original.deathId()).join().isEmpty());
|
||||
var restored = storage.restoreDeathRecord(later.id(), later.deathId()).join().orElseThrow();
|
||||
assertEquals("Saved current name", restored.name(), "Use saved state, not the old book's snapshot");
|
||||
assertFalse(restored.dead());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsEachDeathOnceWithANewIdentityAndNeverReplaysAnOldDefeat() throws Exception {
|
||||
var template = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var live = new CharacterRecord(template.id(), template.name(), BigInteger.ZERO, template.stats(),
|
||||
template.level(), template.experience(), template.unspentPoints(), template.maximumHealth(),
|
||||
template.learned(), template.loadout(), template.cooldowns(), false, null, null);
|
||||
var now = template.diedAt().plusSeconds(60);
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(now, ZoneOffset.UTC))) {
|
||||
storage.create(live).join();
|
||||
var first = storage.recordDeath(live.id(), live.revision()).join();
|
||||
assertTrue(first.isPresent(), "Death must persist the dead flag and current death identity");
|
||||
var dead = first.orElseThrow();
|
||||
assertTrue(dead.dead());
|
||||
assertEquals(java.math.BigDecimal.ZERO, dead.health());
|
||||
assertNotNull(dead.deathId());
|
||||
assertEquals(now, dead.diedAt());
|
||||
assertTrue(storage.recordDeath(live.id(), live.revision()).join().isEmpty());
|
||||
assertEquals(dead, storage.find(live.id()).join().orElseThrow());
|
||||
var revived = storage.restoreDeathRecord(live.id(), dead.deathId()).join().orElseThrow();
|
||||
assertTrue(storage.recordDeath(live.id(), live.revision()).join().isEmpty(), "An old callback cannot kill a revived life");
|
||||
var later = storage.recordDeath(live.id(), revived.revision()).join().orElseThrow();
|
||||
assertNotEquals(dead.deathId(), later.deathId());
|
||||
assertTrue(storage.restoreDeathRecord(live.id(), dead.deathId()).join().isEmpty());
|
||||
assertTrue(storage.restoreDeathRecord(live.id(), later.deathId()).join().isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAnOldOrUnknownDeathBookWithoutChangingTheSavedCharacter() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), UUID.randomUUID()).join().isEmpty());
|
||||
assertTrue(storage.restoreDeathRecord(UUID.randomUUID(), saved.deathId()).join().isEmpty());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresDeathAgeStrictlyBelowTwentyFourHoursAndNotInTheFuture() throws Exception {
|
||||
for (long seconds : new long[] {-1, 86400, 86401}) {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(seconds), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository,
|
||||
Clock.fixed(saved.diedAt().plusSeconds(86400).minusNanos(1), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresOnlyFromTheSavedDeadCharacterAndPreservesProgressAndCooldowns() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
var result = storage.restoreDeathRecord(saved.id(), saved.deathId()).join();
|
||||
assertTrue(result.isPresent(), "A current, recently dead saved character must be restored");
|
||||
var restored = result.orElseThrow();
|
||||
assertFalse(restored.dead());
|
||||
assertEquals(saved.maximumHealth(), restored.health());
|
||||
assertEquals(saved.id(), restored.id());
|
||||
assertEquals(saved.name(), restored.name());
|
||||
assertEquals(saved.stats(), restored.stats());
|
||||
assertEquals(saved.level(), restored.level());
|
||||
assertEquals(saved.experience(), restored.experience());
|
||||
assertEquals(saved.unspentPoints(), restored.unspentPoints());
|
||||
assertEquals(saved.learned(), restored.learned());
|
||||
assertEquals(saved.loadout(), restored.loadout());
|
||||
assertEquals(saved.cooldowns(), restored.cooldowns());
|
||||
assertEquals(saved.deathId(), restored.deathId());
|
||||
assertEquals(saved.diedAt(), restored.diedAt());
|
||||
assertEquals(saved.revision().add(BigInteger.ONE), restored.revision());
|
||||
assertEquals(restored, new YamlCharacterRepository(data).load(saved.id()).orElseThrow());
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class YamlCharacterRepositoryTest {
|
||||
@TempDir Path data;
|
||||
private static final Instant DEATH = Instant.parse("2026-09-17T00:00:00Z"); // synthetic test clock
|
||||
|
||||
static CharacterRecord sample(UUID id) {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.STR, BigInteger.TEN.pow(100));
|
||||
stats.put(Stat.CON, BigInteger.valueOf(6));
|
||||
return new CharacterRecord(id, "Ari 'Ash' ☃", BigInteger.ZERO, stats, 7,
|
||||
BigInteger.valueOf(600), BigInteger.TEN.pow(99), BigDecimal.ZERO,
|
||||
Set.of("fireball", "revive", "future.unknown"),
|
||||
List.of("fireball", "", "", "future.unknown", "revive", "", ""),
|
||||
Map.of("revive", DEATH.plusSeconds(86400), "unlearned.ability", DEATH.plusSeconds(60)),
|
||||
true, UUID.randomUUID(), DEATH);
|
||||
}
|
||||
|
||||
static CharacterRecord rename(CharacterRecord original, String name, BigInteger revision) {
|
||||
return new CharacterRecord(original.id(), name, revision, original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
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);
|
||||
var original = sample(UUID.randomUUID());
|
||||
repository.create(original);
|
||||
var updated = rename(original, "New name", BigInteger.ONE);
|
||||
repository.replace(updated, BigInteger.ZERO);
|
||||
assertEquals(updated, repository.load(original.id()).orElseThrow());
|
||||
assertThrows(IOException.class, () -> repository.replace(rename(original, "Stale name", BigInteger.ONE), BigInteger.ZERO));
|
||||
assertThrows(IOException.class, () -> repository.create(original));
|
||||
assertEquals(updated, repository.load(original.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAtomicReplacementKeepsOriginalStateAndCleansTemporaryData() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var failing = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
assertTrue(Files.isRegularFile(temporary));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
throw new IOException("Simulated atomic-move failure");
|
||||
});
|
||||
assertThrows(IOException.class,
|
||||
() -> failing.replace(rename(original, "Unsaved", BigInteger.ONE), BigInteger.ZERO));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
try (var paths = Files.list(data.resolve("characters"))) {
|
||||
assertFalse(paths.anyMatch(path -> path.getFileName().toString().endsWith(".tmp")));
|
||||
}
|
||||
// A normal retry succeeds; there is no persistent administrator-unlock flag.
|
||||
repository.replace(rename(original, "Retry", BigInteger.ONE), BigInteger.ZERO);
|
||||
assertEquals("Retry", repository.load(original.id()).orElseThrow().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void separateRepositoryInstancesCannotCommitTheSameRevisionTwice() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
new YamlCharacterRepository(data).create(original);
|
||||
Callable<Boolean> change = () -> {
|
||||
try {
|
||||
new YamlCharacterRepository(data).replace(rename(original, "Winner", BigInteger.ONE), BigInteger.ZERO);
|
||||
return true;
|
||||
} catch (IOException conflict) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
int successes = 0;
|
||||
for (var future : pool.invokeAll(Collections.nCopies(8, change))) if (future.get()) successes++;
|
||||
assertEquals(1, successes);
|
||||
}
|
||||
assertEquals(BigInteger.ONE, new YamlCharacterRepository(data).load(original.id()).orElseThrow().revision());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesExactDecimalHealthWithoutExpandingHugeScalesOnSave() throws Exception {
|
||||
var template = sample(UUID.randomUUID());
|
||||
var tiny = new CharacterRecord(template.id(), template.name(), template.revision(), template.stats(),
|
||||
template.level(), template.experience(), template.unspentPoints(), new BigDecimal("1E-10000"),
|
||||
template.learned(), template.loadout(), template.cooldowns(), template.dead(), template.deathId(), template.diedAt());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(tiny);
|
||||
assertTrue(Files.size(data.resolve("characters").resolve(tiny.id() + ".yml")) < 4096,
|
||||
"Persist decimal notation without allocating thousands of padding zeros");
|
||||
assertEquals(tiny, repository.load(tiny.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundsFileReadsAndWritesWithoutReplacingExistingData() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
Path file = data.resolve("characters").resolve(original.id() + ".yml");
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
String oversized = "x".repeat(1_048_577);
|
||||
assertThrows(IOException.class,
|
||||
() -> repository.replace(rename(original, oversized, BigInteger.ONE), BigInteger.ZERO));
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
Files.writeString(file, oversized);
|
||||
assertThrows(IOException.class, () -> repository.load(original.id()));
|
||||
assertEquals(oversized.length(), Files.size(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCharacterDataWithoutChangingTheOriginalFile() throws Exception {
|
||||
var character = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(character);
|
||||
Path file = data.resolve("characters").resolve(character.id() + ".yml");
|
||||
String valid = Files.readString(file);
|
||||
for (String invalid : List.of(
|
||||
valid.replaceAll("(?m)^revision:.*$", "revision: '-1'"),
|
||||
valid.replaceAll("(?m)^health:.*$", "health: '17'"),
|
||||
valid.replaceAll("(?m)^level:.*$", "level: 0"),
|
||||
valid.replaceAll("(?m)^death-id:.*\\R", ""),
|
||||
valid.replaceAll("(?m)^died-at:.*\\R", ""),
|
||||
valid.replace("schema-version: 1", "schema-version: 2"),
|
||||
valid.replace("dead: true", "dead: true\ndead: false"),
|
||||
valid.replace(character.id().toString(), UUID.randomUUID().toString()),
|
||||
"!!java.net.URL ['https://invalid.example/']")) {
|
||||
Files.writeString(file, invalid);
|
||||
IOException error = assertThrows(IOException.class, () -> repository.load(character.id()));
|
||||
assertEquals("Invalid character YAML for " + character.id(), error.getMessage());
|
||||
assertEquals(invalid, Files.readString(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSymlinkedRecordsInsteadOfReadingOutsideTheCharacterDirectory() throws Exception {
|
||||
var character = sample(UUID.randomUUID());
|
||||
Files.createDirectories(data.resolve("characters"));
|
||||
Path other = data.resolve("outside.yml");
|
||||
Files.writeString(other, CharacterYaml.encode(character));
|
||||
Files.createSymbolicLink(data.resolve("characters").resolve(character.id() + ".yml"), other);
|
||||
assertThrows(IOException.class, () -> new YamlCharacterRepository(data).load(character.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesACompleteVersionedCharacterYamlAndReloadsItWithoutItemOrHolderState() throws Exception {
|
||||
CharacterRecord character = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(character);
|
||||
Path file = data.resolve("characters").resolve(character.id() + ".yml");
|
||||
assertTrue(Files.isRegularFile(file), "Create must persist character YAML in the plugin data directory");
|
||||
String yaml = Files.readString(file);
|
||||
assertTrue(yaml.contains("schema-version: 1"));
|
||||
assertTrue(yaml.contains("dead: true"));
|
||||
assertTrue(yaml.contains("2026-09-17T00:00:00Z"));
|
||||
assertFalse(yaml.contains("!!"), "Use plain YAML data, never Java object tags");
|
||||
assertEquals(character, new YamlCharacterRepository(data).load(character.id()).orElseThrow());
|
||||
assertTrue(repository.load(UUID.randomUUID()).isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user