Compare commits

...
14 Commits
Author SHA1 Message Date
dmg f7d01381ec feat(equipment): complete safe session packing and restoration
Release / release (push) Successful in 6m29s
CI / build (push) Successful in 3m27s
2026-09-18 18:52:31 -04:00
dmg db87505142 feat(health): honor native ceilings without capping CON
Release / release (push) Successful in 5m14s
CI / build (push) Successful in 2m50s
2026-09-18 11:35:40 -04:00
dmg 9771d1e7c3 feat(sessions): reserve character and player session identities
Release / release (push) Successful in 5m13s
CI / build (push) Successful in 3m11s
2026-09-18 09:44:04 -04:00
dmg 259cd8cf29 feat(characters): persist accepted anvil names
Release / release (push) Successful in 5m28s
CI / build (push) Successful in 2m54s
2026-09-18 09:24:10 -04:00
dmg 1e7cdf888a feat(crafting): register verified manual character crafting
Release / release (push) Successful in 5m0s
CI / build (push) Successful in 2m40s
2026-09-18 08:39:33 -04:00
dmg e7912d03ce feat(crafting): coordinate deferred manual craft lifecycles
Release / release (push) Successful in 5m0s
CI / build (push) Successful in 2m31s
2026-09-18 08:12:33 -04:00
dmg fcf93fa966 feat(crafting): support cancellable Q and Ctrl-Q delivery
Release / release (push) Successful in 5m26s
CI / build (push) Successful in 2m56s
2026-09-18 07:37:14 -04:00
dmg 14fc0dbde0 feat(crafting): deliver saved sheets to fixed destinations
Release / release (push) Successful in 5m10s
CI / build (push) Successful in 3m15s
2026-09-17 23:30:27 -04:00
dmg d10e804820 feat(crafting): allocate fresh named characters
Release / release (push) Successful in 5m4s
CI / build (push) Successful in 2m44s
2026-09-17 23:12:33 -04:00
dmg 7cf91baca4 fix(persistence): bound character file coordination
Release / release (push) Successful in 5m7s
CI / build (push) Successful in 3m1s
2026-09-17 22:56:15 -04:00
dmg 3215697e3f feat(crafting): retain ownership through async craft saves
Release / release (push) Successful in 4m58s
CI / build (push) Successful in 2m33s
2026-09-17 22:47:35 -04:00
dmg f4cb7a3914 feat(crafting): plan manual character sheet crafts
Release / release (push) Successful in 5m0s
CI / build (push) Successful in 2m33s
2026-09-17 22:13:19 -04:00
dmg 6aa7ea469b feat(crafting): guard against automated character crafting
CI / build (push) Successful in 2m29s
Release / release (push) Successful in 4m37s
2026-09-17 21:14:52 -04:00
dmg 272acfde11 feat(characters): add native sheets and safe recipe previews
Release / release (push) Successful in 4m48s
CI / build (push) Successful in 3m13s
2026-09-17 20:57:55 -04:00
50 changed files with 11731 additions and 34 deletions

No files matched your search

+108 -7
View File
@@ -1,8 +1,8 @@
# Fantasy Stick
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
**Equipment packing/restoration (US-002) is complete with automated verification. The full RPG remains in development and is not deployment-ready.**
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.
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. Crafting and equipment-only offhand sessions are assembled; fantasy health/nutrition, display identity, book UI, combat progression and spells remain incomplete. 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. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health, display and spell interactions remain incomplete.
## Build and verify
@@ -17,7 +17,7 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
`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.
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`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
## Character storage and keepsakes
@@ -27,9 +27,93 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
`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.
`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 equipment coordinator now intercepts active-session native death, restores gear and normal health, attempts the entry-location return, and saves authoritative death before guarded one-book conversion. Stale/offline/uncertain delivery is reported without reissue; this is not the complete Revive spell or fantasy-health integration.
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.
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. Player/session receipts and proof-based equipment reconciliation are implemented below, not an administrator lock, automatic overflow drops or guaranteed cross-save recovery.
## 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 backend alone is **not native inventory delivery**: the assembled controller separately contains vanilla consumption, validates current view/lifecycle/content and commits 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 assembled craft-event controller now invokes this allocator only for accepted current attempts.
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. Startup installs this guard and the manual controller before exposing the recipe. Its preview has no character metadata and allocates no identity/name. Manual completion, bulk crafting, cancellation/save handling and accepted anvil-name persistence are registered. 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**. Eleven 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. The assembled controller separately supplies one-use lifecycle tokens (a native menu may be reused after close/reopen), allocation/persistence wiring, reentrant 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
`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. The registered controller uses this component only for fixed destinations and routes Q/Ctrl-Q separately.
## Q/Ctrl-Q craft delivery
Q/Ctrl-Q remain supported scope; no shortcut restriction was accepted or enabled. `ManualDropDelivery` executes saved drop plans one unit at a time, checking original contents minus completed debits before every next unit. `PlayerSheetDrops` creates an unspawned item, dispatches `PlayerDropItemEvent`, revalidates, and uses native `Item.spawnAt` for the cancellable `ItemSpawnEvent` path. It never uses inventory-source dropping or cancellation's return-to-inventory fallback. A successful unit consumes one ingredient set, then recomputes; cancellation stops the suffix without charging it.
Thirteen additional tests cover sequential/bulk/cancelled drops, exact remaining-input validation, stale context, changed/removed entities, same-batch reentry and exceptions. Tests combine real native matrices, serialized sheets, YAML and Paper's event dispatcher; world insertion itself remains an explicit API boundary double, supplemented by pinned-runtime bytecode inspection. No live world or packet dispatch is claimed.
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. The registered controller supplies these guards and has native lifecycle coverage as described below.
## Manual craft controller
`ManualCraftingController` contains vanilla handling before deferred work, allocates/saves accepted plans, returns delivery to an always-deferred server-thread callback and synchronizes the player's actual current inventory afterward. It uses attempt-object identity plus a delivery reservation that survives close/reopen and remains held through client-sync callbacks. It invalidates on close, accepted open, quit and disable; stale callbacks cannot clear a newer attempt, and rejected scheduling abandons owned unissued records without worker-side player access.
Twelve native controller tests cover saved deferred delivery, Q/Ctrl-Q routing with full storage, replay/reentrancy, menu reuse, client-sync reentry, pre-existing cancellation/unrelated recipes, rejected intents, allocation/save/scheduler failures, postclaim exceptions and retained-file reporting. Review reproduced a planning exception escaping before vanilla cancellation; capture now always cancels in `finally`. Async diagnostics are collected and asserted on the test thread rather than lost inside discarded futures. These tests invoke controller callbacks with real native grids/items/YAML and explicit player/scheduler/world boundaries, not a listening server. The controller checkpoint passed all 153 tests, audit and static analysis. The newer registration assembly is described below.
Scheduler injection must always enqueue (never run inline) or throw on rejection. Diagnostic callbacks must be nonthrowing, thread-safe and avoid native player access. The existing cancellation boolean cannot distinguish this handler's containment from a later plugin's independent veto; prior cancellations are preserved. No new Q/Ctrl-Q restriction, global inventory lock or automatic uncertain-delivery replay is introduced.
## Startup, configuration and shutdown
The packaged `FantasyStickPlugin` now wires storage, allocation, deferred scheduling, native drop-event dispatch and logger diagnostics. Both protective listeners are installed before `fantasystick:character_sheet` is exposed. Recipe-key collisions and partial registration failures clean up this enable attempt without replacing an existing recipe. Shutdown invalidates attempts before closing storage, unregisters its listeners and removes its recipe; removal exceptions are reported while remaining cleanup continues.
An optional `<plugin data>/config.yml` setting controls **new** characters' initial points:
```yaml
starting-points: 40
```
The default is 40; nonnegative arbitrary-precision integers are supported. Invalid values fail before gameplay registration. Existing saved characters are not rewritten. Configuration is read on enable; no reload command or automatic data migration is supplied.
Nine native lifecycle tests load the actual packaged entrypoint through a test configured classloader and native `JavaPlugin.setEnabled`, then register/dispatch through Paper. They cover registration ordering, configured points, all craft routes, distinct persisted sheets, installed Crafter cancellation, failed startup and shutdown/queued-callback cleanup. Server registry/scheduler/player/world insertion are explicit test ports; this is not a listening world/client test. The assembled checkpoint passes clean/incremental packaging, all 162 tests, audit and static analysis; remote publication is tracked separately. This historical crafting checkpoint predates the equipment-session integration below; the remaining RPG is not complete.
## Accepted anvil names
`CharacterAnvilNames` observes Purpur's non-cancellable accepted-take event, not anvil previews. Matching input/result character identities and changed displayed text are captured on the server thread; `CharacterStorage.renameCharacter` then changes only name/revision on the persistence worker against the latest live record. It preserves stats, XP, levels, points, health, loadout, cooldowns and historical death identity. Same-name requests do not rewrite; missing/dead records cannot be created or revived by renamed items. Clearing the custom name uses the native item-factory name, including an explicit item-name component where present.
Vanilla retains responsibility for item movement, XP charging and anvil damage. The inspected event fires at the start of native accepted-take processing, before that continuation; the observer does not make native completion contingent on YAML acknowledgment. Failure/crash may leave item presentation ahead of the saved name. The original saved progress is retained and captured IDs are logged; there is no automatic item/XP rollback, reissue or guaranteed retry.
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation and fantasy sessions remain incomplete. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
## Native health ceiling (activation still unwired)
`CharacterProgress` now limits base maximum health to `min(10 + CON, supplied native limit)` without limiting CON purchases. A purchase adds only the actual maximum-health gain to current health: fractional gain when crossing the ceiling, zero gain once capped. Injury and revision/idempotency protections remain intact. The two-argument initializer uses the pinned runtime's default 1,024 HP; runtime adapters must pass their actual ceiling explicitly.
`NativeHealthLimit.read()` reads the current native ranged attribute through the pinned runtime's public Craft/native accessors, without setting attributes or changing configuration. It must run on the server thread after registry initialization. Incompatible accessors or nonpositive/nonfinite/unrepresentable native ranges fail explicitly instead of guessing. This is not a refusal based on CON. Tests cover core purchases and actual native attribute compatibility at 10.5, 1,024 and 2,048 HP; native tests restore their temporary in-process range changes in `finally`.
No virtual health, global configuration change, stored-data migration or active-player health adapter is introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility; bounded reload/revival, native floating-point round trips, unrelated modifiers and lifecycle application still need integration. Current-health gain is calculated against the core instance's fixed supplied limit; callers must obtain/revalidate the applicable limit at their native operation boundary. This slice passes 186 tests, clean/incremental packaging, audit and static analysis. Remote publication is tracked separately.
## Session claims
`SessionClaims` supplies process-local exclusion for pending/active character sessions. A single shared registry atomically prevents simultaneous claims for either the same character identity or the same player's active mode. This does not limit how many sheets a player may own or permanently bind a character to a holder. Released characters can transfer; a stale token cannot release a replacement claim. Private claim objects carry distinct session UUIDs for future equipment/callback correlation. Dead/zero-health DTOs refuse unchanged; shutdown invalidates all claims and refuses new ones.
Six unit tests cover exclusion, transfer/switch release, stale callbacks, invalid input, shutdown/restart boundaries, same-name identities, uncapped values and 24 rounds of 16 competing callbacks. The registry does no persistence or native work and does not impose a native-health cap. It must be shared across entry routes; separate processes do not coordinate. Callers still must load current authoritative state, validate sheet possession/context/deadlines, preflight native equipment/health, retain/release claims across their lifecycle and reconcile interrupted state. `isCurrent()` is a lifetime check, not saved-state or effect authorization. The equipment coordinator now uses one shared registry for its entry/exit routes. The historical foundation passed 182 tests, clean/incremental packaging, audit and static analysis; remote publication is tracked separately.
## Native equipment adapter
@@ -37,7 +121,24 @@ File-level atomic replacement is **not** atomicity with Minecraft inventory/play
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.
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers equipment-only sessions. Fantasy-health, nutrition and display snapshots remain separate unfinished features. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
## Equipment sessions and safe box movement
US-002 passes all acceptance criteria and **359 automated tests** (315 Java/native and 44 release-tool tests), clean/incremental packaging, dependency audit and static analysis. The new equipment path loads authoritative characters, claims player/character identity, and acknowledges `<plugin data>/sessions/<player UUID>.yml` before packing. Preparation failure leaves gear unchanged. Nine typed bound controls occupy the hotbar; their book/ability actions are not implemented yet.
The genuine **Fantasy Storage Box** contains actual original items. Picking it up for a move leaves it in main storage and issues a harmless bound cursor move-control. Confirming a valid main-storage destination exchanges the real box and that destination's contents; closing the inventory cancels the move. Sorting, stale callbacks and lifecycle changes cannot confer movement authority on a copied control. Full storage needs no extra space or overflow drops. Ordinary sheet movements, including Q/Ctrl-Q, remain supported.
Exit removes controls and restores original slots once, locating the genuine box by owner/session rather than original index. A new head uses the freed box slot. Native player phase markers and the persisted receipt must agree for recovery; ambiguous state is preserved/reported, never guessed into replacement gear. Shutdown is best-effort and nonblocking; accepted cleanup drains before its receipt worker closes. Equipment stays vanilla-readable without this plugin.
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. Settings are read on enable; there is no reload command or deployed configuration change.
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fantasystick recover <player-uuid>` — validated online equipment restoration only; no forced overwrite, progression grant or ambiguous replay.
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
## CI and publication
@@ -55,4 +156,4 @@ The release credential must be a repository-write credential authorized for `dmg
Stories, design, open decisions and evidence are in the private [SoMC wiki project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md). See [AGENTS.md](AGENTS.md) for the shared process. Source builds do not need wiki access.
No inventory recovery guarantees, gameplay balance, multiplayer behavior, or production compatibility have been verified by this bootstrap. Production rollout and GitOps changes remain separately approved; the development cycle never pushes GitOps.
The documented crafting/cleanup boundaries are automated-test evidence, not cross-save atomicity, complete RPG balance, live multiplayer or production-compatibility verification. Production rollout and GitOps changes remain separately approved; the development cycle never pushes GitOps.
@@ -1,7 +1,161 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
import games.dmg.fantasystick.crafting.ManualCraftingController;
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import games.dmg.fantasystick.session.EquipmentAdminCommand;
import games.dmg.fantasystick.session.EquipmentSessionEvents;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.time.Clock;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Build foundation only; gameplay is not yet registered or ready for deployment. */
/** Crafting, names and equipment-only sessions; health, nutrition, display and spells remain inactive. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
private ManualCraftingGuard guard;
private CharacterAnvilNames anvilNames;
private boolean recipeRegistered;
private EquipmentSessionStore receipts;
private EquipmentSessions sessions;
private EquipmentSessionEvents sessionEvents;
private EquipmentAdminCommand admin;
private volatile Object lifetime;
@Override public void onEnable() {
try {
Object generation = new Object();
lifetime = generation;
var switchDelay = switchDelay();
var defaults = ProgressionRules.defaults();
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
defaults.xpPerLevel(), defaults.maximumLevel());
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
task -> getServer().getScheduler().runTask(this, task),
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
guard = new ManualCraftingGuard();
anvilNames = new CharacterAnvilNames(characters,
problem -> getLogger().log(Level.WARNING, "Character name update was refused or failed for player "
+ problem.player() + "; character " + problem.character(), problem.cause()), getServer().getItemFactory());
receipts = new EquipmentSessionStore(getDataFolder().toPath(), Clock.systemUTC());
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
java.util.function.Consumer<EquipmentSessions.Failure> diagnostics = problem ->
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
+ "; session " + problem.session(), problem.cause());
sessions = new EquipmentSessions(characters, receipts, switchDelay, nextTick,
notice -> notifyPlayer(generation, notice), diagnostics);
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics);
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()));
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
getServer().getPluginManager().registerEvents(sessionEvents, this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
} catch (RuntimeException failure) {
try { onDisable(); }
catch (RuntimeException cleanupFailure) { failure.addSuppressed(cleanupFailure); }
throw failure;
}
}
@Override public void onDisable() {
lifetime = null;
if (admin != null) { admin.close(); admin = null; }
if (sessionEvents != null) sessionEvents.close();
if (crafting != null) crafting.close();
var closingSessions = sessions;
var closingReceipts = receipts;
sessions = null;
receipts = null; // A reentrant disable cannot close the store ahead of this sweep.
if (closingSessions != null) closingSessions.close();
if (sessionEvents != null) {
org.bukkit.event.HandlerList.unregisterAll(sessionEvents);
sessionEvents = null;
}
if (recipeRegistered) {
try {
getServer().removeRecipe(CharacterSheets.RECIPE);
recipeRegistered = false;
} catch (RuntimeException failure) {
getLogger().log(Level.WARNING, "Could not remove the character-sheet recipe; continuing shutdown cleanup", failure);
}
}
if (crafting != null) {
org.bukkit.event.HandlerList.unregisterAll(crafting);
crafting = null;
}
if (guard != null) {
org.bukkit.event.HandlerList.unregisterAll(guard);
guard = null;
}
if (anvilNames != null) {
org.bukkit.event.HandlerList.unregisterAll(anvilNames);
anvilNames = null;
}
if (characters != null) {
characters.close();
characters = null;
}
// Do not await IO on the server thread. Capture this lifetime's store: an old
// preparation may still acknowledge and clear its never-packed receipt after re-enable.
if (closingReceipts != null) {
if (closingSessions == null) closingReceipts.close();
else closingSessions.drained().whenComplete((ignored, failure) -> closingReceipts.close());
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
if (!value.matches("[0-9]+")) {
throw new IllegalArgumentException("character-swap-cooldown-seconds must be a nonnegative integer");
}
try {
var delay = java.time.Duration.ofSeconds(new java.math.BigInteger(value).longValueExact());
var deadline = java.time.Instant.now().plus(delay);
if (deadline.atOffset(java.time.ZoneOffset.UTC).getYear() > 9999) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the persisted UTC timestamp range");
}
return delay;
} catch (ArithmeticException | java.time.DateTimeException invalid) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the supported duration range", invalid);
}
}
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
Runnable delivery = () -> {
if (lifetime != generation) return;
var player = getServer().getPlayer(notice.player());
if (player == null || !player.isOnline()) return;
String message = switch (notice.code()) {
case "NO_SPACE" -> "Equipment entry refused: free one slot in your main inventory for the Fantasy Storage Box.";
case "INELIGIBLE_EQUIPMENT" -> "Equipment entry refused: a hotbar or armor item cannot fit in a vanilla shulker box. Move it before retrying.";
default -> notice.message();
};
player.sendMessage(message);
player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
};
if (lifetime != generation) return;
if (getServer().isPrimaryThread()) delivery.run();
else getServer().getScheduler().runTask(this, delivery);
}
}
@@ -0,0 +1,49 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.items.CharacterSheets;
import org.bukkit.inventory.ItemStack;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent;
/** Observes the native accepted-take boundary; does not replace vanilla movement, XP cost or anvil damage. */
public final class CharacterAnvilNames implements Listener {
public record Failure(UUID player, UUID character, Throwable cause) { }
private final CharacterStorage storage;
private final Consumer<Failure> failures;
private final org.bukkit.inventory.ItemFactory items;
/** Diagnostics may run on the persistence worker and must not access native player state. */
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
this.items = java.util.Objects.requireNonNull(items);
this.storage = java.util.Objects.requireNonNull(storage);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onTaken(AnvilTakeResultEvent event) {
var sheets = new CharacterSheets();
var result = event.getResult();
var id = sheets.identify(result);
if (id.isEmpty()) return;
var input = event.getInventory().getItem(0);
if (!id.equals(sheets.identify(input))) return;
String name = name(result);
if (name.equals(name(input))) return;
var player = event.getPlayer().getUniqueId();
var character = id.orElseThrow();
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
if (failure != null || renamed.isEmpty()) failures.accept(new Failure(player, character, failure));
});
}
private String name(ItemStack sheet) {
var custom = sheet.getItemMeta().customName();
return custom == null ? items.getI18NDisplayName(sheet)
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(custom);
}
}
@@ -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,175 @@
package games.dmg.fantasystick.crafting;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import org.bukkit.Keyed;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.CraftItemEvent;
/**
* Owns accepted manual craft lifetimes. Native entrypoints/close run on the server thread.
* nextTick must always enqueue onto that thread (never run inline), or throw on rejection.
* Diagnostics must be thread-safe, nonthrowing and independent of native player access.
* A cancellation observed before this HIGHEST handler is preserved; the event's single boolean cannot
* distinguish our containment cancellation from an independent later plugin veto.
*/
public final class ManualCraftingController implements Listener, AutoCloseable {
public record Failure(UUID player, List<UUID> characters, String operation, Throwable cause) {
public Failure { characters = List.copyOf(characters); }
}
private final CharacterStorage storage;
private final Supplier<CharacterRecord> allocate;
private final Consumer<Runnable> nextTick;
private final Function<Player, ManualDropDelivery.DropTarget> drops;
private final Consumer<Failure> failures;
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
private volatile boolean closed;
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
public ManualCraftingController(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Function<Player, ManualDropDelivery.DropTarget> drops, Consumer<Failure> failures) {
this.storage = java.util.Objects.requireNonNull(storage);
this.allocate = java.util.Objects.requireNonNull(allocate);
this.nextTick = java.util.Objects.requireNonNull(nextTick);
this.drops = java.util.Objects.requireNonNull(drops);
this.failures = java.util.Objects.requireNonNull(failures);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
java.util.Optional<ManualCraftPlan> plan;
try {
plan = ManualCraftPlan.capture(event);
} catch (RuntimeException failure) {
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
return;
} finally {
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
event.setCancelled(true);
}
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
try {
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
attempt.characters = records.stream().map(CharacterRecord::id).toList();
attempt.preparation = storage.prepareCraft(records);
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.id, attempt);
abandon(attempt);
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
}
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.id) != attempt) {
abandon(attempt);
return;
}
try {
nextTick.accept(() -> complete(attempt, batch, failure));
} catch (RuntimeException rejected) {
pending.remove(attempt.id, attempt);
abandon(attempt);
// This branch may run on the persistence worker: diagnostics use captured IDs, never native player access.
failures.accept(new Failure(attempt.id, attempt.characters, "schedule", rejected));
}
}
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.id) != attempt) {
abandon(attempt);
return;
}
if (!delivering.add(attempt.id)) return;
try {
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
else if (attempt.player.isOnline()) {
attempt.handoffStarted = true;
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
}
} else {
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
}
}
} catch (RuntimeException unexpected) {
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
} finally {
pending.remove(attempt.id, attempt);
abandon(attempt);
try {
if (attempt.player.isOnline()) attempt.player.updateInventory();
} catch (RuntimeException unexpected) {
failures.accept(new Failure(attempt.id, attempt.characters, "sync", unexpected));
} finally {
delivering.remove(attempt.id);
}
}
}
private static final class Attempt {
final UUID id;
final Player player;
final ManualCraftPlan plan;
List<UUID> characters = List.of();
CharacterStorage.CraftPreparation preparation;
volatile boolean handoffStarted;
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
Attempt(UUID id, Player player, ManualCraftPlan plan) {
this.id = id;
this.player = player;
this.plan = plan;
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onClose(org.bukkit.event.inventory.InventoryCloseEvent event) { invalidate(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onOpen(org.bukkit.event.inventory.InventoryOpenEvent event) {
if (!event.isCancelled()) invalidate(event.getPlayer().getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { invalidate(event.getPlayer().getUniqueId()); }
private void invalidate(UUID player) {
var attempt = pending.remove(player);
if (attempt != null) abandon(attempt);
}
private void abandon(Attempt attempt) {
if (attempt.preparation == null || !attempt.cleanupObserved.compareAndSet(false, true)) return;
attempt.preparation.abandon().thenAccept(cleanup -> {
if (!attempt.handoffStarted && !cleanup.retained().isEmpty()) {
failures.accept(new Failure(attempt.id, List.copyOf(cleanup.retained()), "cleanup-retained", null));
}
});
}
@Override public void close() {
closed = true;
for (var id : pending.keySet()) invalidate(id);
}
}
@@ -0,0 +1,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,71 @@
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 with an existing reference, or after the craft 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");
}
return dropReferences(sheet, current);
}
/**
* Drop an existing stack of references to the same character, not a batch of newly crafted characters.
* COMPLETE confirms this quantity in one entity; the caller must recheck context before source debit.
* No character allocation, inventory-source fallback or retry of uncertain exposure is performed.
*/
public ManualDropDelivery.Status dropReferences(ItemStack sheet, BooleanSupplier current) {
if (sheet == null || sheet.getAmount() <= 0 || sheet.getAmount() > sheet.getMaxStackSize()
|| new CharacterSheets().identify(sheet).isEmpty()) {
throw new IllegalArgumentException("A positive, native-sized stack of identified character references 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();
}
}
}
@@ -22,21 +22,39 @@ public final class EquipmentStorage {
private static final NamespacedKey SESSION = key("storage_session_id");
private static final NamespacedKey SLOTS = key("storage_slots");
private record PackPlan(PackResult result, ItemStack box, int destination) { }
/** Full read-only preflight; no contents are removed and no box is installed. */
public java.util.Optional<PackResult> refusal(PlayerInventory inventory, UUID owner, UUID session) {
var plan = inspect(inventory, owner, session);
return plan.result() == PackResult.PACKED ? java.util.Optional.empty() : java.util.Optional.of(plan.result());
}
public PackResult pack(PlayerInventory inventory, UUID owner, UUID session) {
var plan = inspect(inventory, owner, session);
if (plan.result() != PackResult.PACKED) return plan.result();
// Install the genuine, fully constructed recovery copy before removing any original.
// Setter exceptions may retain duplicates; the caller must report uncertainty, not roll back.
inventory.setItem(plan.destination(), plan.box());
for (int slot : EQUIPMENT_SLOTS) inventory.setItem(slot, null);
return PackResult.PACKED;
}
private PackPlan inspect(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;
if (matches(inventory.getItem(slot), owner, session)) return new PackPlan(PackResult.ALREADY_PACKED, null, -1);
}
for (int slot : EQUIPMENT_SLOTS) {
var item = inventory.getItem(slot);
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return PackResult.INELIGIBLE_EQUIPMENT;
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return new PackPlan(PackResult.INELIGIBLE_EQUIPMENT, null, -1);
}
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;
if (available == -1) return new PackPlan(PackResult.NO_SPACE, null, -1);
var item = new ItemStack(Material.SHULKER_BOX);
var meta = (BlockStateMeta) item.getItemMeta();
var box = (ShulkerBox) meta.getBlockState();
@@ -44,7 +62,7 @@ public final class EquipmentStorage {
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;
return new PackPlan(PackResult.INELIGIBLE_EQUIPMENT, null, -1);
}
contents.setItem(i, equipment);
}
@@ -56,26 +74,48 @@ public final class EquipmentStorage {
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;
return new PackPlan(PackResult.PACKED, item, available);
}
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) {
return restore(inventory, owner, session, item -> false);
}
/** Validates the entire restore before replacing only the current session's owned hotbar controls. */
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
java.util.function.Predicate<ItemStack> ownedControl) {
return restore(inventory, owner, session, ownedControl, null, ignored -> { });
}
/** Cursor-aware restore; the setter is invoked only when the matching box is on the cursor. */
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
ItemStack cursor, java.util.function.Consumer<ItemStack> setCursor) {
return restore(inventory, owner, session, item -> false, cursor, setCursor);
}
/** All candidates and destinations are validated before any write; setter failures are uncertain. */
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
java.util.function.Predicate<ItemStack> ownedControl,
ItemStack cursor, java.util.function.Consumer<ItemStack> setCursor) {
Objects.requireNonNull(inventory);
Objects.requireNonNull(ownedControl);
Objects.requireNonNull(setCursor);
Objects.requireNonNull(owner);
Objects.requireNonNull(session);
int found = -1;
for (int slot = 9; slot < 36; slot++) {
if (matches(inventory.getItem(slot), owner, session)) {
// 9..35 main, 40 offhand, 41 cursor (not an inventory index).
for (int slot = 9; slot <= 41; slot++) {
if (slot >= 36 && slot < 40) continue;
if (matches(slot == 41 ? cursor : 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);
var item = found == 41 ? cursor : 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();
@@ -89,12 +129,15 @@ public final class EquipmentStorage {
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;
if (slot == 39) continue;
var current = inventory.getItem(slot);
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) 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);
if (found == 41) setCursor.accept(displacedHead);
else inventory.setItem(found, displacedHead);
return RestoreResult.RESTORED;
}
@@ -0,0 +1,135 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.util.Map;
import java.util.Objects;
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.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
/**
* Detached, server-thread control presentation/identity, never session or casting authorization.
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
*/
public final class SessionControls {
private static final NamespacedKey VERSION = key("control_version");
private static final NamespacedKey OWNER = key("control_owner_id");
private static final NamespacedKey SESSION = key("control_session_id");
private static final NamespacedKey SLOT = key("control_slot");
private static final NamespacedKey KIND = key("control_kind");
private static final NamespacedKey ABILITY = key("control_ability_id");
/** Presentation catalogue only: no unlock requirements, effects or casting authorization. */
private record Icon(Material material, String name) { }
private static final Map<String, Icon> ABILITIES = Map.ofEntries(
Map.entry("fireball", new Icon(Material.SNOWBALL, "Fireball")),
Map.entry("great_sword", new Icon(Material.WOODEN_SWORD, "Great Sword")),
Map.entry("frost_shard", new Icon(Material.PRISMARINE_SHARD, "Frost Shard")),
Map.entry("longbow", new Icon(Material.BOW, "Longbow")),
Map.entry("quickstep", new Icon(Material.RABBIT_FOOT, "Quickstep")),
Map.entry("second_wind", new Icon(Material.GHAST_TEAR, "Second Wind")),
Map.entry("stone_skin", new Icon(Material.BRICK, "Stone Skin")),
Map.entry("shield_bash", new Icon(Material.SHIELD, "Shield Bash")),
Map.entry("hex", new Icon(Material.SPIDER_EYE, "Hex")),
Map.entry("inspire", new Icon(Material.AMETHYST_SHARD, "Inspire")),
Map.entry("mending_light", new Icon(Material.GHAST_TEAR, "Mending Light")),
Map.entry("purify", new Icon(Material.HONEY_BOTTLE, "Purify")),
Map.entry("guardian_bond", new Icon(Material.IRON_CHESTPLATE, "Guardian Bond")),
Map.entry("rallying_cry", new Icon(Material.GOAT_HORN, "Rallying Cry")),
Map.entry("frost_nova", new Icon(Material.SNOWBALL, "Frost Nova")),
Map.entry("chain_lightning", new Icon(Material.LIGHTNING_ROD, "Chain Lightning")),
Map.entry("sanctuary", new Icon(Material.SEA_LANTERN, "Sanctuary")),
Map.entry("war_banner", new Icon(Material.RED_BANNER, "War Banner")),
Map.entry("meteor", new Icon(Material.FIRE_CHARGE, "Meteor")),
Map.entry("revive", new Icon(Material.ECHO_SHARD, "Revive"))
);
public record Identity(UUID owner, UUID session, int slot) { }
/**
* Builds the entire detached hotbar before a caller may start packing equipment.
* Slot zero is a plain book control, not the pending character-book UI.
*/
public ItemStack[] create(CharacterRecord character, UUID owner, UUID session) {
Objects.requireNonNull(character);
Objects.requireNonNull(owner);
Objects.requireNonNull(session);
var items = new ItemStack[9];
for (int slot = 0; slot < items.length; slot++) {
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
: ABILITIES.getOrDefault(ability, new Icon(Material.PAPER, "Unavailable ability: " + ability));
var item = new ItemStack(icon.material());
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
meta.setMaxStackSize(1);
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(SLOT, PersistentDataType.INTEGER, slot);
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
: ability.isEmpty() ? "empty" : ABILITIES.containsKey(ability) ? "ability" : "unavailable");
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
item.setItemMeta(meta);
items[slot] = item;
}
return items;
}
/** Structural identity only, not proof of an active session or permission to cast. */
public Optional<Identity> identify(ItemStack item) {
if (item == null || item.getAmount() != 1 || !item.hasItemMeta()) return Optional.empty();
var meta = item.getItemMeta();
// The native codec can omit redundant overrides on naturally unstackable icons.
if (item.getMaxStackSize() != 1) return Optional.empty();
var data = meta.getPersistentDataContainer();
if (!data.has(VERSION, PersistentDataType.INTEGER)
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|| !data.has(OWNER, PersistentDataType.STRING)
|| !data.has(SESSION, PersistentDataType.STRING)
|| !data.has(SLOT, PersistentDataType.INTEGER)
|| !data.has(KIND, PersistentDataType.STRING)) return Optional.empty();
int slot = data.get(SLOT, PersistentDataType.INTEGER);
if (slot < 0 || slot > 8 || !validKind(item.getType(), slot, data)) return Optional.empty();
try {
return Optional.of(new Identity(canonicalUuid(data.get(OWNER, PersistentDataType.STRING)),
canonicalUuid(data.get(SESSION, PersistentDataType.STRING)), slot));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
String kind = data.get(KIND, PersistentDataType.STRING);
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
String ability = data.get(ABILITY, PersistentDataType.STRING);
if (ability.isBlank()) return false;
Icon icon = ABILITIES.get(ability);
return kind.equals("ability") ? icon != null && icon.material() == material
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
}
private static UUID canonicalUuid(String value) {
UUID parsed = UUID.fromString(value);
if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Noncanonical UUID");
return parsed;
}
private static NamespacedKey key(String name) { return new NamespacedKey("fantasystick", name); }
public boolean owned(ItemStack item, UUID owner, UUID session) {
return identify(item).filter(identity -> identity.owner().equals(owner)
&& identity.session().equals(session)).isPresent();
}
}
@@ -5,6 +5,10 @@ import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -33,22 +37,205 @@ public final class CharacterStorage implements AutoCloseable {
});
}
/**
* Prepare already allocated fresh characters off-thread. The caller validates configured
* starting points and retains the returned handle through success, cancellation and disable.
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
*/
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
var snapshot = List.copyOf(records);
var ids = new HashSet<UUID>();
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
for (var record : snapshot) {
if (!ids.add(record.id()) || record.revision().signum() != 0 || record.name().isBlank()
|| record.dead() || record.deathId() != null || record.level() != 1
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
}
return new CraftPreparation(snapshot);
}
public final class CraftPreparation {
private final List<CharacterRecord> requested;
private final CompletableFuture<CraftBatch> completion;
private volatile boolean abandoned;
private CompletableFuture<CraftCleanup> cleanup;
private CraftPreparation(List<CharacterRecord> records) {
requested = records;
completion = writeCraftBatch(records, this);
}
/** Observation only; cancelling/timing out this copy does not cancel the owned operation. */
public CompletableFuture<CraftBatch> ready() { return completion.copy(); }
/** Retain this handle through completion; invalidate the attempt, not an observation future. */
public synchronized CompletableFuture<CraftCleanup> abandon() {
abandoned = true;
if (cleanup == null) {
cleanup = completion.handle((batch, failure) -> {
if (batch != null) return batch.abandon();
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
var outcome = cause instanceof CraftPreparationException preparation ? preparation.cleanup()
: retainAll(requested);
return CompletableFuture.completedFuture(outcome);
}).thenCompose(result -> result);
}
return cleanup.copy();
}
}
private CompletableFuture<CraftBatch> writeCraftBatch(List<CharacterRecord> snapshot, CraftPreparation owner) {
return submit(() -> {
var created = new ArrayList<YamlCharacterRepository.Creation>();
UUID attempted = null;
try {
for (var record : snapshot) {
if (owner.abandoned) break;
attempted = record.id();
created.add(repository.createTracked(record));
attempted = null;
}
return new CraftBatch(created, owner);
} catch (IOException | RuntimeException failure) {
var cleanup = discardUnissued(created);
var retained = new HashSet<>(cleanup.retained());
if (attempted != null) retained.add(attempted);
throw new CraftPreparationException(failure, new CraftCleanup(cleanup.removed(), retained));
}
});
}
public static final class CraftPreparationException extends IOException {
@java.io.Serial private static final long serialVersionUID = 1L;
private final transient CraftCleanup cleanup;
private CraftPreparationException(Throwable cause, CraftCleanup cleanup) {
super("Character craft preparation failed; no batch was issued", cause);
this.cleanup = cleanup;
}
public CraftCleanup cleanup() { return cleanup; }
}
/** Removed includes already-absent files; retained includes failed writes whose outcome is unknown. */
public record CraftCleanup(Set<UUID> removed, Set<UUID> retained) {
public CraftCleanup {
removed = Set.copyOf(removed);
retained = Set.copyOf(retained);
}
}
public final class CraftBatch {
private final List<CharacterRecord> records;
private final List<YamlCharacterRepository.Creation> created;
private final CraftPreparation owner;
private boolean resolved;
private CompletableFuture<CraftCleanup> cleanup;
private CraftBatch(List<YamlCharacterRepository.Creation> created, CraftPreparation owner) {
this.owner = owner;
this.created = List.copyOf(created);
this.records = created.stream().map(creation -> creation.record).toList();
}
/** 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));
}
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(name);
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
false, old.deathId(), old.diedAt());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
}
/**
* One accepted defeat against the latest live record on this worker, including queued names.
* The repository's locked revision compare-and-replace prevents overwriting a concurrent
* external writer; a conflict fails rather than retrying an uncertain death or reviving it.
* Callers must retain their one-use session authority until the result resolves.
*/
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId) {
return death(characterId, null);
}
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
return death(characterId, Objects.requireNonNull(expectedRevision));
}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Instant diedAt = clock.instant();
UUID deathId = UUID.randomUUID();
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();
if (old.dead() || expectedRevision != null && !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);
old.loadout(), old.cooldowns(), true, deathId, diedAt);
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -0,0 +1,354 @@
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.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* Player/session receipts, never an external copy of equipment or proof of restoration.
* All disk work is deferred. Coordination assumes cooperating writers, not adversarial
* replacement of directories. Forced atomic file replacement is not inventory/save atomicity
* or a guarantee of directory durability after power loss. Ambiguous receipts are retained.
*/
public final class EquipmentSessionStore implements AutoCloseable {
private static final int MAX_FILE_BYTES = 16_384;
private final Path directory;
private final Clock clock;
private final Replacement replacement;
@FunctionalInterface
interface Replacement { void move(Path source, Path target) throws IOException; }
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-equipment-session-persistence"));
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt) {
public Receipt {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(characterId);
timestamp(preparedAt);
}
}
public record Exit(UUID sessionId, UUID characterId, Instant exitedAt, Instant deadline) {
public Exit {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(characterId);
timestamp(exitedAt);
timestamp(deadline);
if (deadline.isBefore(exitedAt)) throw new IllegalArgumentException("Deadline precedes exit");
}
}
public record State(UUID playerId, BigInteger revision, Receipt pending, Exit lastExit) {
public State {
Objects.requireNonNull(playerId);
Objects.requireNonNull(revision);
if (revision.signum() < 0) throw new IllegalArgumentException("Negative session revision");
if (pending != null && lastExit != null && pending.sessionId().equals(lastExit.sessionId())
&& !pending.characterId().equals(lastExit.characterId())) {
throw new IllegalArgumentException("Inconsistent session character");
}
}
/** Only a delay check; this does not authorize a sheet, equipment, or native state changes. */
public boolean canEnter(UUID character, Duration configuredDelay, Instant now) {
Objects.requireNonNull(character);
Objects.requireNonNull(configuredDelay);
Objects.requireNonNull(now);
if (configuredDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
return configuredDelay.isZero() || lastExit == null || character.equals(lastExit.characterId())
|| !now.isBefore(lastExit.deadline());
}
}
/** No filesystem access, directory creation or recovery occurs in the constructor. */
public EquipmentSessionStore(Path pluginDataDirectory, Clock clock) {
this(pluginDataDirectory, clock, (source, target) -> Files.move(source, target,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
// Narrow disk-boundary seam for deterministic acknowledgment/failure tests.
EquipmentSessionStore(Path pluginDataDirectory, Clock clock, Replacement replacement) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("sessions").toAbsolutePath();
this.clock = Objects.requireNonNull(clock);
this.replacement = Objects.requireNonNull(replacement);
}
public CompletionStage<Optional<State>> find(UUID player) {
Objects.requireNonNull(player);
return submit(() -> withLock(() -> load(player)));
}
/** A receipt acknowledgment is a prerequisite for packing, not evidence that packing happened. */
public CompletionStage<State> prepare(UUID player, UUID session, UUID character) {
Objects.requireNonNull(player);
var receipt = new Receipt(session, character, clock.instant());
return submit(() -> withLock(() -> {
var found = load(player);
if (found.isPresent()) {
var old = found.orElseThrow();
if (old.pending() != null) {
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())) {
return old;
}
throw new IOException("Conflicting pending equipment session");
}
if (old.lastExit() != null && session.equals(old.lastExit().sessionId())) {
throw new IOException("Exited session cannot be prepared again");
}
return write(new State(player, old.revision().add(BigInteger.ONE), receipt, old.lastExit()));
}
return write(new State(player, BigInteger.ZERO, receipt, null));
}));
}
/** Capture the actual exit request time, never queue completion time. The receipt remains pending. */
public CompletionStage<State> recordExit(UUID player, UUID session, Duration delay) {
Objects.requireNonNull(player);
Objects.requireNonNull(session);
Objects.requireNonNull(delay);
Instant exitedAt = clock.instant();
return submit(() -> {
if (delay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
Instant deadline = exitedAt.plus(delay);
timestamp(exitedAt);
timestamp(deadline);
return withLock(() -> {
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
if (old.lastExit() != null && session.equals(old.lastExit().sessionId())) return old;
if (old.pending() == null || !session.equals(old.pending().sessionId())) {
throw new IOException("Exit does not identify the prepared session");
}
var exit = new Exit(session, old.pending().characterId(), exitedAt, deadline);
return write(new State(player, old.revision().add(BigInteger.ONE), old.pending(), exit));
});
});
}
/** Atomically record a proven native restoration and clear only its matching receipt. */
public CompletionStage<State> completeExit(UUID player, UUID session, Duration delay) {
Objects.requireNonNull(player);
Objects.requireNonNull(session);
Objects.requireNonNull(delay);
Instant exitedAt = clock.instant();
return submit(() -> {
if (delay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
Instant deadline = exitedAt.plus(delay);
timestamp(exitedAt);
timestamp(deadline);
return withLock(() -> {
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
boolean recorded = old.lastExit() != null && session.equals(old.lastExit().sessionId());
boolean matching = old.pending() != null && session.equals(old.pending().sessionId());
if (recorded && !matching) return old; // Never clear a newer pending receipt.
if (!matching) throw new IOException("Exit does not identify the prepared session");
var exit = recorded ? old.lastExit()
: new Exit(session, old.pending().characterId(), exitedAt, deadline);
return write(new State(player, old.revision().add(BigInteger.ONE), null, exit));
});
});
}
/** Caller must prove never-packed or actually restored; absent items are not such proof. */
public CompletionStage<State> clearReceipt(UUID player, UUID session) {
Objects.requireNonNull(player);
Objects.requireNonNull(session);
return submit(() -> withLock(() -> {
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
if (old.pending() == null) return old;
if (!session.equals(old.pending().sessionId())) throw new IOException("Receipt session mismatch");
return write(new State(player, old.revision().add(BigInteger.ONE), null, old.lastExit()));
}));
}
@FunctionalInterface
private interface DiskOperation<T> { T run() throws IOException; }
private <T> CompletionStage<T> submit(DiskOperation<T> operation) {
try {
return CompletableFuture.supplyAsync(() -> {
try {
return operation.run();
} catch (IOException exception) {
throw new CompletionException(exception);
}
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException exception) {
return CompletableFuture.failedStage(exception);
}
}
private <T> T withLock(DiskOperation<T> operation) throws IOException {
try {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe session directory");
}
Path lockPath = directory.resolve(".sessions.lock");
if (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe session coordination file");
}
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Equipment session busy; retry the operation");
return operation.run();
}
} catch (OverlappingFileLockException exception) {
throw new IOException("Equipment session busy; retry the operation");
} catch (IOException exception) {
// Filesystem paths and parser data must not escape through diagnostics/cause chains.
throw new IOException("Equipment session IO refused or failed; original state must be inspected");
}
}
private Optional<State> load(UUID player) throws IOException {
Path path = file(player);
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe session record");
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
byte[] bytes = input.readNBytes(MAX_FILE_BYTES + 1);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Session record exceeds size limit");
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
State state = decode(text);
if (!state.playerId().equals(player)) throw new IllegalArgumentException("Session player mismatch");
return Optional.of(state);
} catch (RuntimeException exception) {
throw new IOException("Invalid equipment session YAML");
}
}
private State write(State state) throws IOException {
byte[] bytes = encode(state).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Session record exceeds size limit");
Path temporary = Files.createTempFile(directory, ".session-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
// Do not fall back to truncating or non-atomic replacement of an existing record.
replacement.move(temporary, file(state.playerId()));
return state;
} finally {
Files.deleteIfExists(temporary);
}
}
private Path file(UUID player) { return directory.resolve(player + ".yml"); }
private static Yaml yaml() {
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0);
options.setNestingDepthLimit(8);
options.setCodePointLimit(MAX_FILE_BYTES);
return new Yaml(new SafeConstructor(options));
}
private static String encode(State state) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("schema-version", 1);
root.put("player-id", state.playerId().toString());
root.put("revision", state.revision().toString());
if (state.pending() != null) {
var receipt = state.pending();
root.put("pending", Map.of("session-id", receipt.sessionId().toString(),
"character-id", receipt.characterId().toString(), "prepared-at", receipt.preparedAt().toString()));
}
if (state.lastExit() != null) {
var exit = state.lastExit();
root.put("last-exit", Map.of("session-id", exit.sessionId().toString(),
"character-id", exit.characterId().toString(), "exited-at", exit.exitedAt().toString(),
"deadline", exit.deadline().toString()));
}
return yaml().dump(root);
}
private static State decode(String input) {
Map<?, ?> root = mapping(yaml().load(input), Set.of("schema-version", "player-id", "revision"),
Set.of("schema-version", "player-id", "revision", "pending", "last-exit"));
if (!Integer.valueOf(1).equals(root.get("schema-version"))) {
throw new IllegalArgumentException("Unsupported session schema");
}
Receipt pending = null;
Exit exit = null;
if (root.containsKey("pending")) {
var keys = Set.of("session-id", "character-id", "prepared-at");
var row = mapping(root.get("pending"), keys, keys);
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")));
}
if (root.containsKey("last-exit")) {
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
var row = mapping(root.get("last-exit"), keys, keys);
exit = new Exit(id(row.get("session-id")), id(row.get("character-id")),
instant(row.get("exited-at")), instant(row.get("deadline")));
}
String revision = text(root.get("revision"));
if (!revision.matches("0|[1-9][0-9]*")) throw new IllegalArgumentException("Invalid revision");
return new State(id(root.get("player-id")), new BigInteger(revision), pending, exit);
}
private static Map<?, ?> mapping(Object value, Set<String> required, Set<String> allowed) {
if (value instanceof Map<?, ?> map && map.keySet().containsAll(required) && allowed.containsAll(map.keySet())) {
return map;
}
throw new IllegalArgumentException("Invalid session mapping");
}
private static String text(Object value) {
if (value instanceof String string) return string;
throw new IllegalArgumentException("Expected session string");
}
private static UUID id(Object value) {
String string = text(value);
UUID id = UUID.fromString(string);
if (!id.toString().equals(string)) throw new IllegalArgumentException("Noncanonical session UUID");
return id;
}
private static Instant instant(Object value) {
String string = text(value);
if (!string.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{1,9})?Z")) {
throw new IllegalArgumentException("Expected UTC session timestamp");
}
return Instant.parse(string);
}
private static void timestamp(Instant value) {
instant(Objects.requireNonNull(value).toString());
}
/** Nonblocking shutdown: accepted work drains; no recovery sweep, deletion or replay. */
@Override public void close() { worker.shutdown(); }
}
@@ -11,6 +11,8 @@ import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -37,26 +39,70 @@ public final class YamlCharacterRepository {
}
public void create(CharacterRecord character) throws IOException {
createTracked(character);
}
/** Process-local proof; never reconstructed from a revision-zero file after restart. */
static final class Creation {
final CharacterRecord record;
private final BasicFileAttributes attributes;
private final byte[] bytes;
private Creation(CharacterRecord record, BasicFileAttributes attributes, byte[] bytes) {
this.record = record;
this.attributes = attributes;
this.bytes = bytes;
}
}
Creation createTracked(CharacterRecord character) throws IOException {
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
withLock(character.id(), () -> {
Creation[] created = new Creation[1];
withLock(() -> {
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Character already exists: " + character.id());
}
write(character);
created[0] = write(character);
});
return created[0];
}
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
throw new IOException("Character update must advance exactly one revision");
}
withLock(character.id(), () -> {
withLock(() -> {
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
write(character);
});
}
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
boolean discardUnissued(Creation created) throws IOException {
boolean[] removed = {false};
withLock(() -> {
var path = file(created.record.id());
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
removed[0] = true;
return;
}
var current = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
var expected = created.attributes;
if (!current.isRegularFile() || expected.fileKey() == null
|| !expected.fileKey().equals(current.fileKey())
|| !expected.creationTime().equals(current.creationTime())
|| !expected.lastModifiedTime().equals(current.lastModifiedTime())
|| current.size() != created.bytes.length) return;
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
if (!Arrays.equals(created.bytes, input.readNBytes(created.bytes.length + 1))) return;
}
Files.delete(path);
removed[0] = true;
});
return removed[0];
}
public Optional<CharacterRecord> load(UUID id) throws IOException {
Path file = file(id);
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
@@ -73,7 +119,7 @@ public final class YamlCharacterRepository {
}
}
private void write(CharacterRecord character) throws IOException {
private Creation write(CharacterRecord character) throws IOException {
byte[] bytes = CharacterYaml.encode(character).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
Path temporary = Files.createTempFile(directory, ".character-", ".tmp");
@@ -83,16 +129,19 @@ public final class YamlCharacterRepository {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
// Never fall back to an in-place, partially written character record.
replacement.move(temporary, file(character.id()));
return new Creation(character, attributes, bytes);
} finally {
Files.deleteIfExists(temporary);
}
}
private void withLock(UUID id, Operation operation) throws IOException {
/** Bounded coordination for all mutations; never mix with older per-ID writers in this directory. */
private void withLock(Operation operation) throws IOException {
Files.createDirectories(directory);
Path lockPath = directory.resolve(id + ".lock");
Path lockPath = directory.resolve(".records.lock");
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
@@ -19,6 +19,7 @@ public final class CharacterProgress {
private final UUID characterId;
private final ProgressionRules rules;
private final BigDecimal nativeHealthLimit;
private final EnumMap<Stat, BigInteger> stats = new EnumMap<>(Stat.class);
private int level = 1;
private BigInteger experience = BigInteger.ZERO;
@@ -26,11 +27,19 @@ public final class CharacterProgress {
private BigInteger revision = BigInteger.ZERO;
private BigDecimal health = BigDecimal.TEN;
/** Uses the pinned runtime's default ceiling. Runtime adapters must supply their actual limit. */
public CharacterProgress(UUID characterId, ProgressionRules rules) {
this(characterId, rules, new BigDecimal("1024"));
}
public CharacterProgress(UUID characterId, ProgressionRules rules, BigDecimal nativeHealthLimit) {
this.characterId = Objects.requireNonNull(characterId);
this.rules = Objects.requireNonNull(rules);
this.nativeHealthLimit = Objects.requireNonNull(nativeHealthLimit);
if (nativeHealthLimit.signum() <= 0) throw new IllegalArgumentException("Native health limit must be positive");
this.points = rules.startingPoints();
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
this.health = maximumHealth();
}
public synchronized Snapshot snapshot() {
@@ -47,9 +56,10 @@ public final class CharacterProgress {
if (!revision.equals(expectedRevision)) return Purchase.STALE_REVISION;
if (health.signum() == 0) return Purchase.NOT_ALIVE;
if (points.signum() == 0) return Purchase.NO_POINTS;
BigDecimal previousMaximum = maximumHealth();
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
points = points.subtract(BigInteger.ONE);
if (stat == Stat.CON) health = health.add(BigDecimal.ONE);
if (stat == Stat.CON) health = health.add(maximumHealth().subtract(previousMaximum));
revision = revision.add(BigInteger.ONE);
return Purchase.APPLIED;
}
@@ -80,6 +90,6 @@ public final class CharacterProgress {
}
private BigDecimal maximumHealth() {
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(nativeHealthLimit);
}
}
@@ -0,0 +1,109 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
/** Server-thread command entry; persistence callbacks never access senders or players. */
public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public static final String PERMISSION = "fantasystick.admin";
public record Failure(UUID player, String operation, Throwable cause) { }
private final EquipmentSessionStore receipts;
private final EquipmentSessions sessions;
private final Server server;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
this.receipts = Objects.requireNonNull(receipts);
this.sessions = Objects.requireNonNull(sessions);
this.server = Objects.requireNonNull(server);
this.nextTick = Objects.requireNonNull(nextTick);
this.diagnostics = Objects.requireNonNull(diagnostics);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You need fantasystick.admin to inspect or recover equipment sessions.");
return true;
}
UUID owner;
try {
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
throw new IllegalArgumentException("Invalid command shape");
}
owner = UUID.fromString(args[1]);
if (!owner.toString().equalsIgnoreCase(args[1])) throw new IllegalArgumentException("Noncanonical UUID shape");
} catch (IllegalArgumentException invalid) {
sender.sendMessage("Usage: /fantasystick <inspect|recover> <player-uuid> (full UUID required)");
return true;
}
if (args[0].equals("recover")) {
var player = server.getPlayer(owner);
if (player == null || !player.isOnline()) {
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
return true;
}
sessions.recover(player);
sender.sendMessage("Validated equipment recovery requested for " + owner
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
return true;
}
receipts.find(owner).whenComplete((state, failure) -> {
if (failure != null) report(owner, "inspect", failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)) return;
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
if (failure != null) {
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
return;
}
sender.sendMessage("Equipment receipt player=" + owner + " (file and native observations are not atomic)");
if (state.isEmpty()) sender.sendMessage("Saved receipt: none; pending=none; last exit=none.");
else {
var saved = state.orElseThrow();
var pending = saved.pending();
sender.sendMessage(pending == null ? "pending=none" : "pending session=" + pending.sessionId()
+ " character=" + pending.characterId() + " prepared=" + pending.preparedAt());
var exit = saved.lastExit();
sender.sendMessage(exit == null ? "last exit=none; deadline=none" : "last exit session=" + exit.sessionId()
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
}
var player = server.getPlayer(owner);
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
else {
var marker = EquipmentSessionMarker.read(player);
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
sender.sendMessage("Online active=" + sessions.active(owner).map(active -> "session=" + active.session()
+ " character=" + active.character()).orElse("none"));
}
});
} catch (RuntimeException rejected) { report(owner, "inspect-response", rejected); }
});
return true;
}
private void report(UUID owner, String operation, Throwable cause) {
try { diagnostics.accept(new Failure(owner, operation, cause)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
}
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
return List.of();
}
@Override public void close() { closed = true; }
}
@@ -0,0 +1,742 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.crafting.ManualDropDelivery;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.SessionControls;
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.NamespacedKey;
import org.bukkit.attribute.Attribute;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCreativeEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.persistence.PersistentDataType;
/** Server-thread equipment bridge. The scheduler must defer, never execute inline. */
public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private final SessionControls controls = new SessionControls();
private static final class Intent { boolean started; }
private final Map<UUID, Intent> swaps = new HashMap<>();
private final Map<UUID, UUID> observed = new HashMap<>();
private final Map<UUID, Object> queued = new HashMap<>();
private final Set<UUID> writing = new HashSet<>();
private final Map<UUID, BoxMove> moves = new HashMap<>();
private static final class BoxMove {
final Player player;
final InventoryView view;
final EquipmentSessions.Active active;
final ItemStack box;
final ItemStack control;
boolean issued;
boolean placing;
BoxMove(Player player, InventoryView view, EquipmentSessions.Active active, ItemStack box) {
this.player = player; this.view = view; this.active = active; this.box = box.clone();
control = new ItemStack(org.bukkit.Material.GRAY_DYE);
var meta = control.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("Move Fantasy Storage Box"));
meta.setMaxStackSize(1);
var data = meta.getPersistentDataContainer();
data.set(new NamespacedKey("fantasystick", "control_move_version"), PersistentDataType.INTEGER, 1);
data.set(new NamespacedKey("fantasystick", "control_move_owner"), PersistentDataType.STRING, active.player().toString());
data.set(new NamespacedKey("fantasystick", "control_move_session"), PersistentDataType.STRING, active.session().toString());
data.set(new NamespacedKey("fantasystick", "control_move_token"), PersistentDataType.STRING, UUID.randomUUID().toString());
control.setItemMeta(meta);
}
}
private boolean closed;
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick) {
this(sessions, nextTick, failure -> System.getLogger(EquipmentSessionEvents.class.getName())
.log(System.Logger.Level.WARNING, "Equipment event failure for " + failure.player(), failure.cause()));
}
/** Diagnostics report contained scheduler failures and possibly exposed drop outcomes; never throw. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics) {
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.diagnostics = Objects.requireNonNull(diagnostics);
sessions.attachCursorCustody(this::cancelMove);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void death(org.bukkit.event.entity.PlayerDeathEvent event) {
if (closed) return;
UUID owner = event.getEntity().getUniqueId();
queued.remove(owner);
swaps.remove(owner);
sessions.cancelEntry(owner);
boolean reserved = writing.add(owner);
try { sessions.defeat(event); }
finally { if (reserved) writing.remove(owner); }
}
// Only actual ordinary death invalidates context; preserve a prior cancellation entirely.
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void ordinaryDeath(org.bukkit.event.entity.PlayerDeathEvent event) {
if (closed) return;
invalidate(event.getEntity());
}
@EventHandler(priority = EventPriority.MONITOR)
public void respawn(org.bukkit.event.player.PlayerRespawnEvent event) {
if (!closed) invalidate(event.getPlayer());
}
private void invalidate(Player player) {
UUID owner = player.getUniqueId();
queued.remove(owner);
swaps.remove(owner);
observed.put(owner, sheets.identify(player.getInventory().getItemInOffHand()).orElse(null));
sessions.invalidateContext(owner);
}
@EventHandler(priority = EventPriority.MONITOR)
public void changed(PlayerInventorySlotChangeEvent event) { reconcile(event.getPlayer()); }
@EventHandler(priority = EventPriority.MONITOR)
public void join(org.bukkit.event.player.PlayerJoinEvent event) {
if (closed) return;
var player = event.getPlayer();
UUID owner = player.getUniqueId();
queued.remove(owner);
observed.put(owner, sheets.identify(player.getInventory().getItemInOffHand()).orElse(null));
sessions.arrive(player);
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var player = event.getPlayer();
UUID owner = player.getUniqueId();
queued.remove(owner);
swaps.remove(owner);
boolean reserved = writing.add(owner);
try { sessions.invalidateContext(owner); sessions.exit(player); }
finally { if (reserved) writing.remove(owner); observed.remove(owner); }
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void swap(org.bukkit.event.player.PlayerSwapHandItemsEvent event) {
Player player = event.getPlayer();
int slot = player.getInventory().getHeldItemSlot();
if (reservedExchange(player, slot)) {
event.setCancelled(true);
// Purpur 2618 snapshots the proposed hand stacks BEFORE dispatch. Never let its
// continuation write the old fantasy control over the restored ordinary stack.
if (Objects.equals(event.getMainHandItem(), player.getInventory().getItemInOffHand())
&& Objects.equals(event.getOffHandItem(), player.getInventory().getItem(slot))) {
exchange(player, slot, null);
}
} else if (special(event.getMainHandItem()) || special(event.getOffHandItem())
|| special(player.getInventory().getItemInMainHand()) || special(player.getInventory().getItemInOffHand())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void click(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
if (ownInventory(player, event.getClickedInventory()) && event.getSlot() == 40
&& (event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP)
&& (event.getAction() == InventoryAction.DROP_ONE_SLOT || event.getAction() == InventoryAction.DROP_ALL_SLOT)
&& sheets.identify(event.getCurrentItem()).isPresent()) {
// LivingEntity.drop's cancelled-source fallback adds to inventory, not the offhand;
// at full capacity it discards leftovers. Keep custody until detached insertion succeeds.
event.setCancelled(true);
dropSheet(player, event.getView(), event.getClick() == ClickType.CONTROL_DROP);
return;
}
int slot = -1;
if (event.getClickedInventory() instanceof PlayerInventory) {
if (event.getSlot() == 40 && event.getClick() == ClickType.NUMBER_KEY) slot = event.getHotbarButton();
else if (event.getClick() == ClickType.SWAP_OFFHAND) slot = event.getSlot();
}
if (event.getAction() == InventoryAction.HOTBAR_SWAP && reservedExchange(player, slot)) {
event.setCancelled(true);
exchange(player, slot, event.getView());
return;
}
if (pickupClaim(event.getCursor(), "control_move_") && !cursorIndependent(event)) {
event.setCancelled(true);
if (!(event instanceof InventoryCreativeEvent)
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) {
if (event.getRawSlot() == -999) cancelMove(player.getUniqueId());
else if (ownInventory(player, event.getClickedInventory()) && event.getSlot() >= 9 && event.getSlot() < 36
&& (event.getAction() == InventoryAction.PLACE_ALL || event.getAction() == InventoryAction.PLACE_ONE
|| event.getAction() == InventoryAction.SWAP_WITH_CURSOR)) {
placeMove(player, event.getView(), event.getSlot(), event.getCursor());
}
}
return;
}
if (box(event.getCurrentItem()) != null && empty(event.getCursor())
&& ownInventory(player, event.getClickedInventory()) && event.getSlot() >= 9 && event.getSlot() < 36
&& !(event instanceof InventoryCreativeEvent)
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)
&& (event.getAction() == InventoryAction.PICKUP_ALL || event.getAction() == InventoryAction.PICKUP_HALF)) {
event.setCancelled(true);
beginMove(player, event.getView(), event.getCurrentItem());
return;
}
if (blockedClick(player, event)) event.setCancelled(true);
}
private static boolean cursorIndependent(InventoryClickEvent event) {
return switch (event.getAction()) {
case DROP_ONE_SLOT, DROP_ALL_SLOT, MOVE_TO_OTHER_INVENTORY, HOTBAR_SWAP -> true;
default -> false;
};
}
private boolean blockedClick(Player player, InventoryClickEvent event) {
var current = event.getCurrentItem();
var cursor = pickupClaim(event.getCursor(), "control_move_") && cursorIndependent(event) ? null : event.getCursor();
var inventory = event.getClickedInventory();
int slot = event.getSlot();
if (event.getSlotType() == InventoryType.SlotType.RESULT
&& Arrays.stream(event.getView().getTopInventory().getContents()).anyMatch(this::special)) return true;
if (control(current) || control(cursor)) return true;
if (event instanceof InventoryCreativeEvent && (special(current) || special(cursor))) return true;
// Native pickup swaps the active sheet onto the cursor and its own box into offhand.
// Reconciliation then restores into that freed offhand, without needing main capacity.
if (ownInventory(player, inventory) && slot == 40
&& event.getAction() == InventoryAction.SWAP_WITH_CURSOR
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) {
var active = sessions.active(player.getUniqueId()).orElse(null);
var heldBox = box(cursor);
if (active != null && heldBox != null && heldBox.owner().equals(active.player())
&& heldBox.session().equals(active.session())
&& sheets.identify(current).filter(active.character()::equals).isPresent()) return false;
}
if (event.getClick() == ClickType.NUMBER_KEY || event.getClick() == ClickType.SWAP_OFFHAND) {
int source = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
if (source < 0) return false;
var hidden = player.getInventory().getItem(source);
return special(current) || special(hidden)
|| destinationBlocked(player, inventory, slot, hidden)
|| destinationBlocked(player, player.getInventory(), source, current);
}
// Normal pickup is exclusively the bound move-control path above. No unsupported
// action may fall back to vanilla taking the genuine box onto the cursor.
if (box(current) != null) return true;
if (box(cursor) != null && destinationBlocked(player, inventory, slot, cursor)) return true;
if (event.getAction() == InventoryAction.COLLECT_TO_CURSOR && !empty(cursor)) {
for (var item : event.getView().getTopInventory().getContents()) if (special(item) && item.isSimilar(cursor)) return true;
for (var item : player.getInventory().getContents()) if (special(item) && item.isSimilar(cursor)) return true;
}
if (!active(player)) return false;
if (ownInventory(player, inventory) && slot >= 0 && slot < 9) return true;
if (event.isShiftClick() && !empty(current)) {
if (ownInventory(player, inventory) && event.getView().getType() == InventoryType.CRAFTING && slot >= 9 && slot < 36) {
// Native InventoryMenu tries armor first, otherwise routes main -> hotbar.
return !(cosmetic(current) && empty(player.getInventory().getHelmet()));
}
if (!ownInventory(player, inventory) && mainCapacity(player, current) < current.getAmount()) return true;
}
return destinationBlocked(player, inventory, slot, cursor);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void drag(InventoryDragEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
if (control(event.getOldCursor())) {
event.setCancelled(true);
if (pickupClaim(event.getOldCursor(), "control_move_") && event.getNewItems().size() == 1) {
int raw = event.getNewItems().keySet().iterator().next();
int slot = event.getView().convertSlot(raw);
if (ownInventory(player, event.getView().getInventory(raw)) && slot >= 9 && slot < 36) {
// QUICK_CRAFT temporarily debits carried before event dispatch; DENY
// restores it afterwards. Confirm that live cursor only in the deferred task.
placeMove(player, event.getView(), slot, event.getOldCursor());
}
}
return;
}
for (var entry : event.getNewItems().entrySet()) {
int raw = entry.getKey();
if (special(event.getView().getItem(raw)) || destinationBlocked(player,
event.getView().getInventory(raw), event.getView().convertSlot(raw), entry.getValue())) {
event.setCancelled(true); return;
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void drop(org.bukkit.event.player.PlayerDropItemEvent event) {
if (special(event.getItemDrop().getItemStack())) event.setCancelled(true);
}
// isCancelled() is only the block decision on this event. Denying item use must
// also run for RIGHT_CLICK_AIR / an existing block veto, without changing that veto.
@EventHandler(priority = EventPriority.HIGHEST)
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
if (special(event.getPlayer().getInventory().getItem(event.getHand()))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void armorStand(org.bukkit.event.player.PlayerArmorStandManipulateEvent event) {
if (special(event.getPlayerItem()) || special(event.getArmorStandItem())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void consume(org.bukkit.event.player.PlayerItemConsumeEvent event) {
if (special(event.getItem())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void place(org.bukkit.event.block.BlockPlaceEvent event) {
if (special(event.getItemInHand())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void transfer(org.bukkit.event.inventory.InventoryMoveItemEvent event) {
if (special(event.getItem())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void attemptPickup(org.bukkit.event.player.PlayerAttemptPickupItemEvent event) {
if (pickupBlocked(event.getPlayer(), event.getItem().getItemStack(), event.getRemaining())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void pickup(org.bukkit.event.entity.EntityPickupItemEvent event) {
if (pickupBlocked(event.getEntity(), event.getItem().getItemStack(), event.getRemaining())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void inventoryPickup(org.bukkit.event.inventory.InventoryPickupItemEvent event) {
var item = event.getItem().getItemStack();
if (pickupClaim(item, "control_") || pickupClaim(item, "storage_")) event.setCancelled(true);
}
private boolean pickupBlocked(org.bukkit.entity.LivingEntity entity, ItemStack item, int remaining) {
if (pickupClaim(item, "control_")) return true;
if (!pickupClaim(item, "storage_")) return false;
var box = box(item);
// ItemEntity.playerTouch temporarily narrows the stack to canHold before entity
// pickup dispatch, then restores count + remaining before Inventory.add. Never
// mistake a partial/oversized claim for one genuine nonstackable box.
if (box == null || item.getAmount() != 1 || item.getMaxStackSize() != 1 || remaining != 0
|| !item.getPersistentDataContainer().has(BOX_SLOTS, PersistentDataType.INTEGER_ARRAY)
|| !Arrays.equals(BOX_SLOT_MAP, item.getPersistentDataContainer().get(BOX_SLOTS, PersistentDataType.INTEGER_ARRAY))
|| !(entity instanceof Player player) || !box.owner().equals(player.getUniqueId())
|| player.getInventory().getMaxStackSize() < 1) return true;
// Purpur 2618 Inventory.add(-1, stack): factory nonstackable boxes cannot merge;
// getFreeSlot scans 0..35, NOT the selected slot or only main storage. Read only:
// leave native insertion to the caller, and never invent a recovery destination.
for (int slot = 0; slot < 36; slot++) {
if (empty(player.getInventory().getItem(slot))) return slot < 9;
}
return true;
}
// Pickup-only fail-closed claims: malformed/future metadata must not turn protected
// remnants into ordinary loot. This grants no session/recovery authority and does not
// change the existing cursor/click policy or EquipmentStorage's payload validation.
private static boolean pickupClaim(ItemStack item, String prefix) {
if (empty(item)) return false;
return item.getPersistentDataContainer().getKeys().stream()
.anyMatch(key -> key.getNamespace().equals("fantasystick") && key.getKey().startsWith(prefix));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void launch(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event) {
if (special(event.getItemStack())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void shoot(org.bukkit.event.entity.EntityShootBowEvent event) {
if (special(event.getBow()) || special(event.getConsumable())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void dispense(org.bukkit.event.block.BlockDispenseArmorEvent event) {
if (special(event.getItem()) || event.getTargetEntity() instanceof Player player
&& active(player) && (!cosmetic(event.getItem()) || forbiddenEquip(event.getItem()))) event.setCancelled(true);
}
private static boolean forbiddenEquip(ItemStack item) {
if (empty(item)) return false;
var equippable = item.getData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE);
if (equippable == null) return false;
return switch (equippable.slot()) {
case HEAD -> !cosmetic(item);
case CHEST, LEGS, FEET -> true;
default -> false;
};
}
private boolean destinationBlocked(Player player, Inventory inventory, int slot, ItemStack item) {
if (control(item)) return true;
var box = box(item);
if (box != null) return !ownerMain(player, inventory, slot, box.owner());
if (!active(player) || !ownInventory(player, inventory)) return false;
if (slot >= 0 && slot < 9) return true;
return !empty(item) && slot >= 36 && slot <= 39 && (slot != 39 || !cosmetic(item));
}
private boolean active(Player player) { return sessions.active(player.getUniqueId()).isPresent(); }
private boolean control(ItemStack item) { return pickupClaim(item, "control_move_") || controls.identify(item).isPresent(); }
private boolean special(ItemStack item) { return control(item) || box(item) != null; }
private static boolean ownInventory(Player player, Inventory inventory) {
return inventory instanceof PlayerInventory && inventory.equals(player.getInventory());
}
private static boolean ownerMain(Player player, Inventory inventory, int slot, UUID owner) {
return owner.equals(player.getUniqueId()) && ownInventory(player, inventory) && slot >= 9 && slot < 36;
}
private static int mainCapacity(Player player, ItemStack item) {
int capacity = 0;
for (int slot = 9; slot < 36; slot++) {
var current = player.getInventory().getItem(slot);
if (empty(current)) capacity += item.getMaxStackSize();
else if (current.isSimilar(item)) capacity += Math.max(0, item.getMaxStackSize() - current.getAmount());
}
return capacity;
}
// Identity only, not restoration validation. The slot map and actual contents remain
// exclusively EquipmentStorage's responsibility. Names never confer protected identity.
private record Box(UUID owner, UUID session) { }
private static final NamespacedKey BOX_VERSION = new NamespacedKey("fantasystick", "storage_version");
private static final NamespacedKey BOX_OWNER = new NamespacedKey("fantasystick", "storage_owner_id");
private static final NamespacedKey BOX_SESSION = new NamespacedKey("fantasystick", "storage_session_id");
private static final NamespacedKey BOX_SLOTS = new NamespacedKey("fantasystick", "storage_slots");
private static final int[] BOX_SLOT_MAP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
private static Box box(ItemStack item) {
if (empty(item) || !item.getType().name().endsWith("SHULKER_BOX")) return null;
var data = item.getPersistentDataContainer();
if (!data.has(BOX_VERSION, PersistentDataType.INTEGER) || !data.has(BOX_OWNER, PersistentDataType.STRING)
|| !data.has(BOX_SESSION, PersistentDataType.STRING)
|| !Integer.valueOf(1).equals(data.get(BOX_VERSION, PersistentDataType.INTEGER))) return null;
String owner = data.get(BOX_OWNER, PersistentDataType.STRING);
String session = data.get(BOX_SESSION, PersistentDataType.STRING);
if (owner == null || session == null) return null;
try {
UUID ownerId = UUID.fromString(owner), sessionId = UUID.fromString(session);
return ownerId.toString().equals(owner) && sessionId.toString().equals(session) ? new Box(ownerId, sessionId) : null;
} catch (IllegalArgumentException invalid) { return null; }
}
private static boolean cosmetic(ItemStack item) {
if (empty(item)) return false;
boolean head = switch (item.getType()) {
case PLAYER_HEAD, ZOMBIE_HEAD, CREEPER_HEAD, DRAGON_HEAD, PIGLIN_HEAD,
SKELETON_SKULL, WITHER_SKELETON_SKULL, CARVED_PUMPKIN -> true;
default -> false;
};
if (!head) return false;
var modifiers = item.getAttributeModifiers(EquipmentSlot.HEAD);
if (modifiers != null && (modifiers.containsKey(Attribute.ARMOR) || modifiers.containsKey(Attribute.ARMOR_TOUGHNESS))) return false;
return !item.containsEnchantment(Enchantment.PROTECTION) && !item.containsEnchantment(Enchantment.FIRE_PROTECTION)
&& !item.containsEnchantment(Enchantment.BLAST_PROTECTION) && !item.containsEnchantment(Enchantment.PROJECTILE_PROTECTION)
&& !item.containsEnchantment(Enchantment.FEATHER_FALLING);
}
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
private void beginMove(Player player, InventoryView view, ItemStack box) {
UUID owner = player.getUniqueId();
var active = sessions.active(owner).orElse(null);
if (closed || active == null || writing.contains(owner) || moves.containsKey(owner) || swaps.containsKey(owner)
|| box.getAmount() != 1 || box.getMaxStackSize() != 1
|| !new Box(owner, active.session()).equals(box(box))) return;
var move = new BoxMove(player, view, active, box);
moves.put(owner, move);
try {
nextTick.accept(() -> {
if (moves.get(owner) != move || move.issued) return;
writing.add(owner);
try {
if (!moveCurrent(move) || !empty(player.getItemOnCursor()) || source(move) < 0) {
cancelMove(move); return;
}
move.issued = true;
player.setItemOnCursor(move.control.clone());
if (!issuedCurrent(move)) { cancelMove(move); return; }
player.updateInventory();
if (!issuedCurrent(move)) cancelMove(move);
} catch (RuntimeException failure) { cancelMove(move); report(owner, active.session(), failure); }
finally { writing.remove(owner); }
});
} catch (RuntimeException failure) { cancelMove(move); report(owner, active.session(), failure); }
}
private void placeMove(Player player, InventoryView view, int destination, ItemStack presented) {
UUID owner = player.getUniqueId();
var move = moves.get(owner);
if (move == null || move.player != player || move.view != view || !move.issued || move.placing
|| writing.contains(owner) || !moveCurrent(move) || !move.control.equals(presented)) return;
var displaced = copy(player.getInventory().getItem(destination));
if (special(displaced) && !move.box.equals(displaced)) return;
move.placing = true;
try {
nextTick.accept(() -> {
if (moves.get(owner) != move || !move.placing) {
// A cancelled native drag can restore its old harmless carried token AFTER
// a reentrant close. Revoke/clean only this issued token, never a replacement.
cancelMove(move); return;
}
move.placing = false; // One-use callback, including native exceptions.
writing.add(owner);
try {
int source = source(move);
if (!moveCurrent(move) || source < 0 || !move.control.equals(player.getItemOnCursor())
|| !Objects.equals(displaced, copy(player.getInventory().getItem(destination)))) return;
// Cursor setters can synchronously reenter close/exit or change either slot.
// Remove the harmless control FIRST, then revalidate before touching gear.
player.setItemOnCursor(null);
if (!moveCurrent(move) || !empty(player.getItemOnCursor()) || source(move) != source
|| !Objects.equals(displaced, copy(player.getInventory().getItem(destination)))) return;
if (source != destination) {
NativeMainInventorySwap.swap(player.getInventory(), source, destination);
}
moves.remove(owner, move);
player.updateInventory(); // No planned writes after this reentrant boundary.
} catch (RuntimeException failure) { report(owner, move.active.session(), failure); }
finally { cancelMove(move); writing.remove(owner); reconcile(player); }
});
} catch (RuntimeException failure) { cancelMove(move); report(owner, move.active.session(), failure); }
}
private boolean issuedCurrent(BoxMove move) {
return moveCurrent(move) && move.control.equals(move.player.getItemOnCursor()) && source(move) >= 0;
}
private boolean moveCurrent(BoxMove move) {
return !closed && moves.get(move.active.player()) == move && move.player.isOnline()
&& move.player.getOpenInventory() == move.view
&& sessions.active(move.active.player()).filter(move.active::equals).isPresent();
}
/** Follow the unique current box, not its index before a sorting operation. */
private int source(BoxMove move) {
int found = -1;
var expected = new Box(move.active.player(), move.active.session());
var inventory = move.player.getInventory();
for (int slot = 0; slot < inventory.getSize(); slot++) {
var item = inventory.getItem(slot);
if (!expected.equals(box(item))) continue;
if (found != -1 || slot < 9 || slot >= 36 || !move.box.equals(item)) return -1;
found = slot;
}
return expected.equals(box(move.player.getItemOnCursor())) ? -1 : found;
}
private void cancelMove(UUID owner) {
var move = moves.get(owner);
if (move != null) cancelMove(move);
}
private void cancelMove(BoxMove move) {
UUID owner = move.active.player();
moves.remove(owner, move); // Invalidate before a cursor setter can reenter; never revoke a replacement.
if (!move.issued) return;
boolean reserved = writing.add(owner);
try {
if (move.control.equals(move.player.getItemOnCursor())) move.player.setItemOnCursor(null);
} catch (RuntimeException failure) { report(owner, move.active.session(), failure); }
finally { if (reserved) writing.remove(owner); }
}
@EventHandler(priority = EventPriority.MONITOR)
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
UUID owner = event.getPlayer().getUniqueId();
cancelMove(owner);
swaps.remove(owner);
sessions.invalidateTail(owner);
}
private boolean reservedExchange(Player player, int slot) {
if (slot < 0 || slot > 8) return false;
var active = sessions.active(player.getUniqueId()).orElse(null);
return active != null && sheets.identify(player.getInventory().getItemInOffHand()).filter(active.character()::equals).isPresent()
&& controls.owned(player.getInventory().getItem(slot), active.player(), active.session());
}
private void exchange(Player player, int slot, InventoryView view) {
UUID owner = player.getUniqueId();
if (closed || writing.contains(owner) || swaps.containsKey(owner)) return;
var active = sessions.active(owner).orElseThrow();
var before = snapshot(player);
var cursor = copy(player.getItemOnCursor());
var heldBox = box(cursor);
// A successful restore consumes exactly this own cursor box, replacing it with
// the pre-restore head (or empty). Do not also accept the old box afterwards.
var move = moves.get(owner);
var restoredCursor = move != null && move.issued && move.control.equals(cursor) ? null
: heldBox != null && heldBox.owner().equals(owner) && heldBox.session().equals(active.session())
? copy(before[39]) : cursor;
var token = new Intent();
submit(owner, active.session(), token, () -> {
if (closed || !player.isOnline()
|| !sessions.active(owner).filter(active::equals).isPresent()
|| !Arrays.equals(before, snapshot(player))
|| !Objects.equals(cursor, copy(player.getItemOnCursor()))
|| (view != null ? player.getOpenInventory() != view : player.getInventory().getHeldItemSlot() != slot)) return;
queued.remove(owner);
writing.add(owner);
try {
if (sessions.exit(player) != EquipmentStorage.RestoreResult.RESTORED) return;
// Only this still-current, contained hotbar/offhand request is replayed.
// Ordinary cursor/slot moves are already complete and never enter this path.
if (closed || swaps.get(owner) != token || !Objects.equals(before[40], copy(player.getInventory().getItemInOffHand()))
|| !Objects.equals(restoredCursor, copy(player.getItemOnCursor()))
|| view != null && player.getOpenInventory() != view) return;
var ordinary = copy(player.getInventory().getItem(slot));
var sheet = copy(player.getInventory().getItemInOffHand());
player.getInventory().setItem(slot, sheet);
player.getInventory().setItemInOffHand(ordinary);
// An actual exit happened even if the restored ordinary stack is a sheet
// for the same character. The coordinator must check the next entry.
observed.put(owner, null);
player.updateInventory();
} finally { writing.remove(owner); }
reconcile(player);
});
}
private void dropSheet(Player player, InventoryView view, boolean all) {
UUID owner = player.getUniqueId();
if (closed || writing.contains(owner) || swaps.containsKey(owner)) return;
var active = sessions.active(owner);
var marker = EquipmentSessionMarker.read(player);
var before = snapshot(player);
var cursor = copy(player.getItemOnCursor());
var token = new Intent();
java.util.function.BooleanSupplier current = () -> !closed && swaps.get(owner) == token && player.isOnline()
&& EquipmentSessionMarker.read(player).equals(marker) && sessions.active(owner).equals(active) && player.getOpenInventory() == view
&& Arrays.equals(before, snapshot(player)) && Objects.equals(cursor, copy(player.getItemOnCursor()));
UUID session = active.map(EquipmentSessions.Active::session).orElse(null);
submit(owner, session, token, () -> {
if (!current.getAsBoolean()) return;
writing.add(owner);
try {
if (empty(before[40]) || before[40].getAmount() > before[40].getMaxStackSize()) {
throw new IllegalArgumentException("Offhand sheet references exceed the native stack limit");
}
var selected = before[40].clone();
selected.setAmount(all ? selected.getAmount() : 1);
var result = new PlayerSheetDrops(player, event -> player.getServer().getPluginManager().callEvent(event))
.dropReferences(selected, current);
if (result == ManualDropDelivery.Status.COMPLETE && current.getAsBoolean()) {
int remaining = before[40].getAmount() - selected.getAmount();
if (remaining > 0) {
var retained = before[40].clone();
retained.setAmount(remaining);
player.getInventory().setItemInOffHand(retained);
} else {
queued.remove(owner);
sessions.cancelEntry(owner);
player.getInventory().setItemInOffHand(null);
observed.put(owner, null);
sessions.exit(player);
}
player.updateInventory();
} else if (result == ManualDropDelivery.Status.UNCONFIRMED || result == ManualDropDelivery.Status.COMPLETE) {
report(owner, session, new IllegalStateException(
"Sheet drop outcome is uncertain; retained current inventory, never replayed or charged stale contents"));
}
} finally { writing.remove(owner); reconcile(player); }
});
}
private void submit(UUID owner, UUID session, Intent token, Runnable action) {
swaps.put(owner, token);
try {
nextTick.accept(() -> {
if (swaps.get(owner) != token || token.started) return;
token.started = true;
try { action.run(); }
catch (RuntimeException failure) { report(owner, session, failure); }
finally { swaps.remove(owner, token); }
});
} catch (RuntimeException failure) { swaps.remove(owner, token); report(owner, session, failure); }
}
private void report(UUID owner, UUID session, Throwable failure) {
try { diagnostics.accept(new EquipmentSessions.Failure(owner, session, failure)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize retries or abort custody cleanup. */ }
}
private static ItemStack copy(ItemStack item) { return item == null || item.isEmpty() ? null : item.clone(); }
private static ItemStack[] snapshot(Player player) {
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessionEvents::copy).toArray(ItemStack[]::new);
}
private void reconcile(Player player) {
UUID owner = player.getUniqueId();
if (closed || writing.contains(owner) || !player.isOnline()) return;
var marker = EquipmentSessionMarker.read(player).orElse(null);
if (marker != null && (marker.phase() == EquipmentSessionMarker.Phase.PACKING
|| marker.phase() == EquipmentSessionMarker.Phase.RESTORING)) return;
UUID character = sheets.identify(player.getInventory().getItemInOffHand()).orElse(null);
var active = sessions.active(owner).orElse(null);
boolean initial = !observed.containsKey(owner);
boolean changed = initial || !Objects.equals(observed.get(owner), character);
observed.put(owner, character);
// A terminal recovery/client-sync observation is not a fresh offhand move.
if (initial && active == null && marker != null) return;
if (active != null && !active.character().equals(character)) {
queued.remove(owner);
writing.add(owner);
try { sessions.exit(player); }
finally { writing.remove(owner); }
changed = true;
}
if (!changed) return;
queued.remove(owner);
sessions.cancelEntry(owner);
if (character == null || sessions.active(owner).isPresent()) return;
Object token = new Object();
var entryMarker = EquipmentSessionMarker.read(player);
queued.put(owner, token);
try {
nextTick.accept(() -> {
if (!queued.remove(owner, token) || closed || !player.isOnline()
|| !EquipmentSessionMarker.read(player).equals(entryMarker)
|| sessions.active(owner).isPresent()
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(character::equals).isPresent()) return;
sessions.enter(player);
});
} catch (RuntimeException failure) { queued.remove(owner, token); throw failure; }
}
/** Invalidate deferred native actions before closing the coordinator/stores on disable. */
@Override public void close() {
closed = true;
for (var owner : java.util.List.copyOf(moves.keySet())) cancelMove(owner);
queued.clear();
swaps.clear();
observed.clear();
}
}
@@ -0,0 +1,56 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.persistence.PersistentDataType;
/**
* Small versioned proof carried with native player state; owner is implicitly that player.
* Server-thread only. One PDC value avoids partially written fields, not cross-save/crash
* atomicity. No equipment copy, forced player save, or external recovery journal lives here.
*/
final class EquipmentSessionMarker {
enum Phase { PACKING, PACKED, RESTORING, RESTORED }
record Token(UUID session, UUID character, Phase phase) {
Token {
Objects.requireNonNull(session);
Objects.requireNonNull(character);
Objects.requireNonNull(phase);
}
boolean matches(UUID sessionId, UUID characterId) {
return session.equals(sessionId) && character.equals(characterId);
}
}
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "equipment_session");
static boolean present(Player player) { return player.getPersistentDataContainer().has(KEY); }
static Optional<Token> read(Player player) {
var data = player.getPersistentDataContainer();
if (!data.has(KEY, PersistentDataType.STRING)) return Optional.empty();
String value = data.get(KEY, PersistentDataType.STRING);
if (value == null || value.length() > 100) return Optional.empty();
String[] fields = value.split("\\|", -1);
if (fields.length != 4 || !fields[0].equals("1")) return Optional.empty();
try {
return Optional.of(new Token(uuid(fields[1]), uuid(fields[2]), Phase.valueOf(fields[3])));
} catch (IllegalArgumentException invalid) { return Optional.empty(); }
}
static void write(Player player, UUID session, UUID character, Phase phase) {
var token = new Token(session, character, phase);
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
"1|" + token.session() + "|" + token.character() + "|" + token.phase());
}
static void clear(Player player) { player.getPersistentDataContainer().remove(KEY); }
private static UUID uuid(String value) {
UUID parsed = UUID.fromString(value);
if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Noncanonical UUID");
return parsed;
}
}
@@ -0,0 +1,679 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
/**
* Server-thread equipment custody and minimal defeat handling. Fantasy health attributes,
* nutrition, display and rewards are not applied here. No inventory/file transaction,
* automatic retry or snapshot rollback is promised. The scheduler must defer onto
* the server thread; diagnostics must be thread-safe and must not use Player APIs.
*/
public final class EquipmentSessions implements AutoCloseable {
public record Notice(UUID player, String code, String message) { }
public record Failure(UUID player, UUID session, Throwable cause) { }
public record Active(UUID player, UUID session, UUID character) { }
private final CharacterStorage characters;
private final EquipmentSessionStore receipts;
private final Duration switchDelay;
private final Consumer<Runnable> nextTick;
private final Consumer<Notice> notices;
private final Consumer<Failure> diagnostics;
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private final SessionControls controls = new SessionControls();
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
private final Map<UUID, Attempt> active = new HashMap<>();
// Covers reentrant inventory setters and client sync, not just asynchronous work.
private final Map<UUID, Attempt> transitioning = new HashMap<>();
private Consumer<UUID> cursorCustody = ignored -> { };
private boolean cursorCustodyAttached;
private boolean closed;
private boolean closeSweepFinished;
private int persistenceOperations;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
private static final class Attempt {
final Player player;
final UUID owner;
UUID character;
SessionClaims.Claim claim;
UUID session;
ItemStack[] snapshot;
ItemStack cursor;
EquipmentSessionMarker.Token entryMarker;
boolean markerValidated;
boolean acknowledged;
boolean saving;
boolean effectsStarted;
boolean cleaning;
boolean cleanupAttempted;
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
boolean arrival;
org.bukkit.Location entryLocation;
boolean defeated;
boolean deathResolved;
boolean gearAcknowledged;
boolean bookAttempted;
boolean bookDelivered;
boolean deliveryInvalidated;
boolean conversionReported;
ItemStack deathReference;
org.bukkit.inventory.InventoryView deathView;
Attempt(Player player, UUID owner, UUID character) {
this.player = player; this.owner = owner; this.character = character;
}
}
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay,
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
this.characters = Objects.requireNonNull(characters);
this.receipts = Objects.requireNonNull(receipts);
this.switchDelay = Objects.requireNonNull(switchDelay);
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
this.nextTick = Objects.requireNonNull(nextTick);
this.notices = Objects.requireNonNull(notices);
this.diagnostics = Objects.requireNonNull(diagnostics);
}
/** One server-thread custody bridge per coordinator; never called by persistence workers. */
synchronized void attachCursorCustody(Consumer<UUID> invalidate) {
if (cursorCustodyAttached) throw new IllegalStateException("Cursor custody already attached");
cursorCustody = Objects.requireNonNull(invalidate);
cursorCustodyAttached = true;
}
private void invalidateCursor(UUID owner) {
try { cursorCustody.accept(owner); }
catch (RuntimeException failure) { diagnostic(new Failure(owner, null, failure)); }
}
/**
* Completes after close's native sweep and all owned receipt/death mutations finish,
* including failed or ambiguous outcomes. Retained receipts/conversions are not outstanding operations.
* Observers cannot complete/cancel the lifetime authority. Does not await queued server tasks.
*/
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
// All accounting, submissions and completion handlers share the lifecycle monitor. A handler
// may submit its follow-up before its own reservation is released, including after close.
private <T> void persist(Supplier<CompletionStage<T>> operation, BiConsumer<T, Throwable> completion) {
persistenceOperations++;
CompletionStage<T> work;
try { work = operation.get(); }
catch (RuntimeException failure) { work = CompletableFuture.failedStage(failure); }
work.whenComplete((value, failure) -> {
synchronized (EquipmentSessions.this) {
try { completion.accept(value, failure); }
finally {
persistenceOperations--;
finishDrain();
}
}
});
}
private void finishDrain() {
if (closeSweepFinished && persistenceOperations == 0 && transitioning.isEmpty()) drained.complete(null);
}
public synchronized Optional<Active> active(UUID player) {
var attempt = active.get(player);
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
}
public synchronized void enter(Player player) {
UUID owner = player.getUniqueId();
if (closed) return;
if (transitioning.containsKey(owner)) {
notice(owner, "BUSY", "An equipment transition is still completing; retry afterwards."); return;
}
if (active.containsKey(owner)) return;
cancelEntry(owner);
if (owned.containsKey(owner)) {
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
return;
}
var character = sheets.identify(player.getInventory().getItemInOffHand());
if (!player.isOnline() || character.isEmpty()) {
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
return;
}
var attempt = new Attempt(player, owner, character.orElseThrow());
attempt.snapshot = snapshot(player);
attempt.cursor = copy(player.getItemOnCursor());
pending.put(owner, attempt);
characters.find(attempt.character).thenCombine(receipts.find(owner), Loaded::new)
.whenComplete((loaded, failure) -> schedule(attempt, () -> loaded(attempt, loaded, failure)));
}
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
if (hasRemnants(attempt.player)) return false;
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
if (EquipmentSessionMarker.present(attempt.player)) {
// A terminal marker is resolved only by its matching completed file acknowledgment.
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
var exit = state == null ? null : state.lastExit();
if (marker == null || marker.phase() != EquipmentSessionMarker.Phase.RESTORED
|| state == null || state.pending() != null || exit == null
|| !marker.matches(exit.sessionId(), exit.characterId())) return false;
}
attempt.entryMarker = marker;
attempt.markerValidated = true;
return true;
}
private static boolean hasRemnants(Player player) {
return Arrays.stream(player.getInventory().getContents()).anyMatch(EquipmentSessions::remnant)
|| remnant(player.getItemOnCursor());
}
private static boolean remnant(ItemStack item) {
if (item == null || item.isEmpty() || !item.hasItemMeta()) return false;
// Even malformed/foreign remnants are unsafe to pack as ordinary equipment.
return item.getItemMeta().getPersistentDataContainer().getKeys().stream().anyMatch(key ->
key.getNamespace().equals("fantasystick")
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_")));
}
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
if (!entryCurrent(attempt) || attempt.claim != null) return;
if (failure != null) { fail(attempt, failure); return; }
var record = loaded.character().orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is missing, dead or has no health."); return;
}
if (loaded.state().isPresent()) {
var state = loaded.state().orElseThrow();
if (state.pending() != null) {
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
}
if (!state.canEnter(attempt.character, switchDelay, Instant.now())) {
refuse(attempt, "COOLDOWN", "Wait until " + state.lastExit().deadline()
+ " to switch characters; the same character may be resumed now."); return;
}
}
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
refuse(attempt, "NATIVE_RECOVERY_REQUIRED", "Native equipment marker or control/box remnants require inspection; entry did not change items.");
return;
}
var refusal = equipment.refusal(attempt.player.getInventory(), attempt.owner, UUID.randomUUID());
if (refusal.isPresent()) { refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return; }
// Restart receipts can belong to dead/missing characters, so they cannot acquire a
// live SessionClaims token. Their unresolved equipment outcome still excludes entry.
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
refuse(attempt, "CHARACTER_BUSY", "This character has an unresolved equipment session."); return;
}
attempt.claim = claims.tryClaim(attempt.owner, record).orElse(null);
if (attempt.claim == null) { refuse(attempt, "CHARACTER_BUSY", "This character or player already has a session."); return; }
attempt.session = attempt.claim.sessionId();
owned.put(attempt.owner, attempt);
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
attempt.saving = true;
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character), (saved, error) -> {
attempt.saving = false;
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
attempt.acknowledged = true;
if (!current(attempt)) { abandon(attempt); return; }
// Queue the cross-worker read under lifecycle ownership: close cannot finish
// its drain boundary and then have this callback submit fresh character IO.
characters.find(attempt.character).whenComplete((fresh, readError) ->
schedule(attempt, () -> complete(attempt, fresh, readError)));
});
}
private synchronized void complete(Attempt attempt, Optional<CharacterRecord> fresh, Throwable failure) {
if (!entryCurrent(attempt)) return;
if (failure != null) { fail(attempt, failure); return; }
var record = fresh.orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is no longer live."); return;
}
transitioning.put(attempt.owner, attempt);
boolean packed = false;
try {
var items = controls.create(record, attempt.owner, attempt.session);
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
attempt.effectsStarted = true;
mark(attempt, EquipmentSessionMarker.Phase.PACKING);
var result = equipment.pack(attempt.player.getInventory(), attempt.owner, attempt.session);
if (result != EquipmentStorage.PackResult.PACKED) {
EquipmentSessionMarker.clear(attempt.player); // Adapter refusal proves no inventory writes.
attempt.effectsStarted = false;
refuse(attempt, result.name(), "Equipment entry refused: " + result); return;
}
packed = true;
if (interrupted(attempt)) return;
for (int slot = 0; slot < 9; slot++) {
attempt.player.getInventory().setItem(slot, items[slot]);
if (interrupted(attempt)) return;
}
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
pending.remove(attempt.owner, attempt);
if (!attempt.exitRequested) active.put(attempt.owner, attempt);
attempt.player.updateInventory();
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
} finally {
transitioning.remove(attempt.owner, attempt);
// The deferred exit must reserve persistence before drain can be signalled.
try {
if (packed && !attempt.nativeUncertain && !attempt.defeated && attempt.exitRequested) restoreExit(attempt);
} finally { finishDrain(); }
}
}
private boolean interrupted(Attempt attempt) {
if (current(attempt) || attempt.exitRequested) return false;
notice(attempt.owner, "ENTRY_INTERRUPTED", "Entry was cancelled during packing; the genuine box and receipt were retained for recovery.");
return true;
}
/**
* Ends mode without awaiting IO. Reentrant calls during an owned native transition request
* teardown at that transition's boundary and return NOT_FOUND (no separate restore ran).
* A native exception returns INVALID_BOX with NATIVE_UNCERTAIN notice/diagnostic; never retry it.
*/
public synchronized EquipmentStorage.RestoreResult exit(Player player) {
UUID owner = player.getUniqueId();
var transition = transitioning.get(owner);
if (transition != null) {
active.remove(owner, transition);
transition.exitRequested = true;
invalidateCursor(owner);
return EquipmentStorage.RestoreResult.NOT_FOUND;
}
cancelEntry(owner);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
return restoreExit(attempt);
}
/**
* Paper dispatches death before inventory clearing/drop spawning. Cancellation returns from
* ServerPlayer.die; keepInventory is neither needed nor sufficient for this transition.
* Only an active session owns defeat. Later ordinary deaths and prior vetoes stay ordinary.
*/
public synchronized void defeat(org.bukkit.event.entity.PlayerDeathEvent event) {
if (closed || event.isCancelled()) return;
UUID owner = event.getEntity().getUniqueId();
var attempt = active.remove(owner);
if (attempt == null) {
var transition = transitioning.get(owner);
if (transition != null && transition.defeated) {
// Contain only reentrance inside this native defeat, not a later ordinary
// lethal outcome. This is not a grace period or post-defeat invulnerability.
event.setCancelled(true);
event.setReviveHealth(Objects.requireNonNull(event.getEntity()
.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
}
return;
}
attempt.defeated = true;
event.setCancelled(true);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
// closes CharacterStorage. No dependent character work is submitted after this point.
persist(() -> characters.recordDeath(attempt.character), (dead, failure) -> {
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
attempt.deathResolved = true;
if (closed) { conversionUncertain(attempt, "Shutdown before Tail conversion"); return; }
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
});
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
});
try {
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
attempt.deathView = attempt.player.getOpenInventory();
restoreExit(attempt);
// Equipment-only sessions have not changed attributes. Read the current ordinary
// maximum, including unrelated modifiers; never set a base value or modifiers.
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
event.setReviveHealth(maximum);
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
try {
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
}
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
}
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Native defeat transition failed; inspect retained equipment and death state");
} finally {
if (reserved) transitioning.remove(attempt.owner, attempt);
finishDrain();
}
}
/** No automatic delivery after quit/respawn, including a reconnect of the same Player ID. */
public synchronized void invalidateContext(UUID owner) {
invalidateCursor(owner);
cancelEntry(owner);
invalidateTail(owner);
}
/** Invalidates deferred conversion only; does not move a cursor, box, sheet or controls. */
public synchronized void invalidateTail(UUID owner) {
var attempt = owned.get(owner);
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
}
private void deliverTail(Attempt attempt, CharacterRecord dead) {
if (attempt.bookAttempted) return;
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
try {
if (!tailContext(attempt)
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|| attempt.deathReference.getAmount() < 1
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
}
// An exact captured stack of copied references represents ONE character, not N
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
// move items, overwrite a changed stack, or grant into an available slot.
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
attempt.player.getInventory().setItemInOffHand(book);
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
}
attempt.bookDelivered = true;
release(attempt);
sync(attempt);
} catch (RuntimeException failure) {
diagnostic(attempt, failure);
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
}
}
private boolean tailContext(Attempt attempt) {
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
return marker != null && marker.matches(attempt.session, attempt.character)
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
}
private void conversionUncertain(Attempt attempt, String reason) {
if (attempt.conversionReported) return;
attempt.conversionReported = true;
attempt.deliveryInvalidated = true;
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
+ ". Retain current items and authoritative state; no automatic reissue or revival."));
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
active.remove(attempt.owner, attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
try {
EquipmentStorage.RestoreResult result;
try {
result = restoreMarked(attempt);
} catch (RuntimeException failure) {
attempt.nativeUncertain = true;
persistExit(attempt, false);
diagnostic(attempt, failure);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
return EquipmentStorage.RestoreResult.INVALID_BOX;
}
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED);
if (result != EquipmentStorage.RestoreResult.RESTORED) {
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
} else sync(attempt);
return result;
} finally {
if (reserved) transitioning.remove(attempt.owner, attempt);
finishDrain();
}
}
private void mark(Attempt attempt, EquipmentSessionMarker.Phase phase) {
EquipmentSessionMarker.write(attempt.player, attempt.session, attempt.character, phase);
}
private EquipmentStorage.RestoreResult restoreMarked(Attempt attempt) {
invalidateCursor(attempt.owner);
var marker = EquipmentSessionMarker.read(attempt.player);
if (marker.filter(token -> token.matches(attempt.session, attempt.character)
&& token.phase() == EquipmentSessionMarker.Phase.PACKED).isEmpty()) {
notice(attempt.owner, "NATIVE_MARKER", "Missing, malformed, mismatched or ambiguous native equipment marker; "
+ "expected session " + attempt.session + ", character " + attempt.character + ", observed "
+ marker.map(Object::toString).orElse("missing/malformed")
+ ". Items and receipt retained. RESTORED receipts require manual reconciliation too.");
return EquipmentStorage.RestoreResult.INVALID_BOX;
}
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
item -> controls.owned(item, attempt.owner, attempt.session),
attempt.player.getItemOnCursor(), attempt.player::setItemOnCursor);
// Returned refusals are read-only. Exceptions must retain the ambiguous RESTORING phase.
mark(attempt, result == EquipmentStorage.RestoreResult.RESTORED
? EquipmentSessionMarker.Phase.RESTORED : EquipmentSessionMarker.Phase.PACKED);
return result;
}
private void sync(Attempt attempt) {
try { attempt.player.updateInventory(); }
catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private void persistExit(Attempt attempt, boolean restored) {
attempt.saving = true;
persist(() -> restored
? receipts.completeExit(attempt.owner, attempt.session, switchDelay)
: receipts.recordExit(attempt.owner, attempt.session, switchDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (restored) { attempt.gearAcknowledged = true; release(attempt); }
});
}
public synchronized void cancelEntry(UUID player) {
var attempt = pending.remove(player);
if (attempt != null) abandon(attempt);
}
private void abandon(Attempt attempt) {
if (attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
if (attempt.acknowledged) {
attempt.cleaning = true;
attempt.cleanupAttempted = true;
persist(() -> receipts.clearReceipt(attempt.owner, attempt.session), (state, failure) -> {
attempt.cleaning = false;
if (failure == null) release(attempt); else diagnostic(attempt, failure);
});
} else if (!attempt.saving) release(attempt);
}
private void release(Attempt attempt) {
if (attempt.defeated && (!attempt.deathResolved || !attempt.gearAcknowledged || !attempt.bookDelivered)) return;
owned.remove(attempt.owner, attempt);
if (attempt.claim != null) attempt.claim.close();
}
private boolean current(Attempt attempt) { return !closed && pending.get(attempt.owner) == attempt; }
private boolean entryCurrent(Attempt attempt) {
if (!current(attempt)) { abandon(attempt); return false; }
if (!attempt.player.isOnline()
|| attempt.markerValidated && (EquipmentSessionMarker.present(attempt.player) != (attempt.entryMarker != null)
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|| !Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|| !sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
refuse(attempt, "STALE_ENTRY", "Entry changed before preparation completed; retry with your current equipment.");
return false;
}
return true;
}
private void refuse(Attempt attempt, String code, String message) {
pending.remove(attempt.owner, attempt);
abandon(attempt);
notice(attempt.owner, code, message);
}
private void fail(Attempt attempt, Throwable failure) {
if (attempt.effectsStarted && owned.get(attempt.owner) == attempt) attempt.nativeUncertain = true;
pending.remove(attempt.owner, attempt);
active.remove(attempt.owner, attempt);
abandon(attempt);
diagnostic(attempt, failure);
}
private void diagnostic(Attempt attempt, Throwable failure) {
diagnostic(new Failure(attempt.owner, attempt.session, failure));
}
private void diagnostic(Failure failure) {
try { diagnostics.accept(failure); }
catch (RuntimeException ignored) { /* Observers cannot abort owned cleanup or shutdown. */ }
}
private void notice(UUID owner, String code, String message) {
try { notices.accept(new Notice(owner, code, message)); }
catch (RuntimeException failure) { diagnostic(new Failure(owner, null, failure)); }
}
private synchronized void schedule(Attempt attempt, Runnable callback) {
// close already invalidates pending work and owns its never-packed cleanup. Accepted
// reads may still finish, but must not require another task on a disabled scheduler.
if (closed) return;
try {
nextTick.accept(() -> {
synchronized (EquipmentSessions.this) {
try { callback.run(); }
catch (RuntimeException failure) { fail(attempt, failure); }
}
});
} catch (RuntimeException failure) { fail(attempt, failure); }
}
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
private static ItemStack[] snapshot(Player player) {
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
}
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
public synchronized void recover(Player player) { recover(player, false); }
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
public synchronized void arrive(Player player) { recover(player, true); }
private void recover(Player player, boolean arrival) {
UUID owner = player.getUniqueId();
invalidateCursor(owner);
if (closed || !player.isOnline()) return;
var previous = owned.get(owner);
if (previous != null && previous.defeated && (arrival || previous.gearAcknowledged
|| previous.bookDelivered || previous.player != player)) {
notice(owner, "DEFEAT_PENDING", "Death or Tail conversion is pending/uncertain; retained state requires inspection, not automatic replay."); return;
}
if (previous != null && previous.nativeUncertain) {
notice(owner, "NATIVE_UNCERTAIN", "A native equipment write had an uncertain outcome; automatic restoration is unsafe. Retain items for inspection."); return;
}
if (transitioning.containsKey(owner) || active.containsKey(owner)
|| previous != null && (previous.saving || previous.cleaning)) {
notice(owner, "BUSY", "A session transition is still pending; recovery did not change items."); return;
}
cancelEntry(owner);
var attempt = new Attempt(player, owner, null);
// A recovered receipt has no process-local never-packed proof.
attempt.effectsStarted = true;
attempt.arrival = arrival;
pending.put(owner, attempt);
receipts.find(owner).whenComplete((state, failure) ->
schedule(attempt, () -> recovered(attempt, state, failure)));
}
private synchronized void recovered(Attempt attempt, Optional<EquipmentSessionStore.State> state, Throwable failure) {
if (!current(attempt)) return;
pending.remove(attempt.owner, attempt);
if (!attempt.player.isOnline()) return;
if (failure != null) { diagnostic(attempt, failure); return; }
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
if (receipt == null) {
if (attempt.arrival) {
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player);
else if (EquipmentSessionMarker.present(attempt.player) || hasRemnants(attempt.player)) {
notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection.");
}
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
}
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
}
attempt.character = receipt.characterId();
attempt.session = receipt.sessionId();
var previous = owned.get(attempt.owner);
if (previous != null && !attempt.session.equals(previous.session)) {
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
}
if (previous != null && previous.defeated) {
// Explicit same-lifetime equipment recovery still uses matching receipt/PACKED
// proof. Keep the original death authority and its pending worker completion;
// do not replace it with a recovery Attempt that could release its claim early.
previous.deliveryInvalidated = true;
restoreExit(previous);
return;
}
if (previous != null) attempt.claim = previous.claim;
owned.put(attempt.owner, attempt);
transitioning.put(attempt.owner, attempt);
try {
var result = restoreMarked(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED) {
persistExit(attempt, true);
sync(attempt);
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
} catch (RuntimeException error) {
fail(attempt, error);
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
} finally {
transitioning.remove(attempt.owner, attempt);
finishDrain();
}
}
@Override public synchronized void close() {
if (closed) return;
closed = true;
try {
for (var attempt : transitioning.values()) attempt.exitRequested = true;
// Accepted main-thread tasks may be discarded on disable. Report now as well as
// from worker completion; neither path needs a scheduler on a disabled plugin.
for (var attempt : java.util.List.copyOf(owned.values())) {
if (attempt.defeated && !attempt.bookDelivered) conversionUncertain(attempt, "Shutdown before confirmed Tail conversion");
}
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
claims.close();
} finally {
closeSweepFinished = true;
finishDrain();
}
}
}
@@ -0,0 +1,29 @@
package games.dmg.fantasystick.session;
import java.math.BigDecimal;
import org.bukkit.attribute.Attribute;
/**
* Read-only access to the pinned runtime's health range; never changes server settings or attributes.
* Call on the server thread after registry initialization. The API exposes no range getter, so use the
* supported runtime's public Craft/native accessors and fail explicitly if they are incompatible.
* This reads the current range on each call, not a guessed default or cached configuration value.
* Native session/health application is separate and must revalidate its context before mutation.
*/
public final class NativeHealthLimit {
private NativeHealthLimit() { }
public static BigDecimal read() {
try {
Object range = Attribute.MAX_HEALTH.getClass().getMethod("getHandle").invoke(Attribute.MAX_HEALTH);
double maximum = ((Number) range.getClass().getMethod("getMaxValue").invoke(range)).doubleValue();
if (!Double.isFinite(maximum) || maximum <= 0
|| !Float.isFinite((float) maximum) || (float) maximum <= 0) {
throw new IllegalStateException("Native maximum health must be positive and representable as Minecraft health");
}
return BigDecimal.valueOf(maximum);
} catch (ReflectiveOperationException | ClassCastException failure) {
throw new IllegalStateException("Cannot read the supported native maximum-health range", failure);
}
}
}
@@ -0,0 +1,43 @@
package games.dmg.fantasystick.session;
import java.util.List;
import java.util.Objects;
import org.bukkit.inventory.PlayerInventory;
/**
* Purpur 2618 main-storage-only exchange, called on the server thread after revalidation.
* Public Craft/native accessors (as with NativeHealthLimit), no private-field access.
* Native NonNullList.set only replaces a list reference: no item copying, packet emission,
* equip notification, menu recomputation or plugin callback between the two writes.
* Caller syncs AFTER completing/invalidation of its one-use move. Fail closed on other APIs.
* Not a crash-atomic operation or a portable guarantee for arbitrary Inventory adapters.
*/
final class NativeMainInventorySwap {
private NativeMainInventorySwap() { }
static void swap(PlayerInventory inventory, int source, int destination) {
if (source < 9 || source >= 36 || destination < 9 || destination >= 36) {
throw new IllegalArgumentException("Only main storage may be exchanged");
}
try {
var craft = Class.forName("org.bukkit.craftbukkit.inventory.CraftInventoryPlayer");
var nativeInventory = Class.forName("net.minecraft.world.entity.player.Inventory");
Object handle = craft.getMethod("getInventory").invoke(craft.cast(inventory));
Object contents = nativeInventory.getMethod("getNonEquipmentItems").invoke(handle);
if (contents.getClass() != Class.forName("net.minecraft.core.NonNullList")
|| !(contents instanceof List<?> items) || items.size() != 36) {
throw new IllegalStateException("Unsupported native main storage");
}
exchange(items, source, destination);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Native main-storage exchange unavailable", failure);
}
}
private static <T> void exchange(List<T> items, int source, int destination) {
T box = Objects.requireNonNull(items.get(source));
T displaced = Objects.requireNonNull(items.get(destination));
items.set(destination, box);
items.set(source, displaced);
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.util.Optional;
import java.util.UUID;
/**
* Process-local pending/active session exclusion, not persisted character ownership or native activation.
* A player can own any number of sheets, but must finish one session before entering another.
* The caller retains the claim across asynchronous entry/active callbacks and releases it on failure/exit.
* Share one registry across entry routes in this plugin process; separate instances/processes do not coordinate.
* No cooldown, equipment, saved-state reconciliation or native health policy is supplied here.
*/
public final class SessionClaims implements AutoCloseable {
private final java.util.Map<UUID, Claim> players = new java.util.HashMap<>();
private final java.util.Map<UUID, Claim> characters = new java.util.HashMap<>();
private boolean closed;
/**
* The caller must supply current authoritative saved data, not data copied from an item, and validate
* sheet possession, lifecycle, switching deadline and native preflight. This method does no file I/O.
*/
public synchronized Optional<Claim> tryClaim(UUID player, CharacterRecord character) {
java.util.Objects.requireNonNull(player);
java.util.Objects.requireNonNull(character);
if (closed || character.dead() || character.health().signum() == 0
|| players.containsKey(player) || characters.containsKey(character.id())) return Optional.empty();
var claim = new Claim(player, character.id());
players.put(player, claim);
characters.put(character.id(), claim);
return Optional.of(claim);
}
public final class Claim implements AutoCloseable {
private final UUID player;
private final UUID character;
private final UUID session = UUID.randomUUID();
private Claim(UUID player, UUID character) { this.player = player; this.character = character; }
public UUID playerId() { return player; }
public UUID characterId() { return character; }
public UUID sessionId() { return session; }
/** Lifetime/identity check only: does not re-read saved liveness or authorize native effects. */
public boolean isCurrent() {
synchronized (SessionClaims.this) {
return players.get(player) == this && characters.get(character) == this;
}
}
@Override public void close() {
synchronized (SessionClaims.this) {
players.remove(player, this);
characters.remove(character, this);
}
}
}
@Override public synchronized void close() {
closed = true;
players.clear();
characters.clear();
}
}
+10 -1
View File
@@ -2,5 +2,14 @@ name: FantasyStick
version: ${version}
main: games.dmg.fantasystick.FantasyStickPlugin
api-version: "26.2"
description: Transferable RPG characters for SoMC; bootstrap only, gameplay not implemented.
description: Transferable character sheets and recoverable equipment sessions; other RPG gameplay is not activated.
author: dmg.games
commands:
fantasystick:
description: Inspect equipment receipts or request validated online recovery.
usage: /fantasystick <inspect|recover> <player-uuid>
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Inspect equipment sessions and request proof-based recovery.
default: op
@@ -0,0 +1,231 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
import games.dmg.fantasystick.crafting.CharacterFactory;
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.concurrent.CopyOnWriteArrayList;
import net.kyori.adventure.text.Component;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.inventory.ResultContainer;
import org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView;
import org.bukkit.craftbukkit.inventory.CraftInventoryAnvil;
import org.bukkit.entity.HumanEntity;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MenuType;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent;
class CharacterAnvilNamesNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void anAcceptedNativeResultSavesItsNewNameWithoutTouchingInventoryOrPlayerXp() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
fixture.grid.setItem(0, sheet);
var renamed = sheet.clone();
var meta = renamed.getItemMeta();
meta.customName(Component.text("Mira of Dawn — 雪"));
renamed.setItemMeta(meta);
fixture.grid.setItem(2, renamed);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, renamed));
var saved = storage.find(original.id()).join().orElseThrow();
assertEquals("Mira of Dawn — 雪", saved.name());
assertEquals(original.id(), saved.id());
assertEquals(original.stats(), saved.stats());
assertEquals(original.health(), saved.health());
assertEquals(original.unspentPoints(), saved.unspentPoints());
assertEquals(sheet, fixture.grid.getItem(0));
assertEquals(renamed, fixture.grid.getItem(2));
var dead = storage.recordDeath(saved.id(), saved.revision()).join().orElseThrow();
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
var book = (org.bukkit.inventory.meta.BookMeta) ItemStack.deserializeBytes(tail.serializeAsBytes()).getItemMeta();
assertEquals("Tail of Mira of Dawn — 雪", book.getTitle());
var text = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
assertTrue(text.serialize(book.customName()).contains("Mira of Dawn — 雪"));
assertTrue(book.pages().stream().map(text::serialize).anyMatch(page -> page.contains("Mira of Dawn — 雪")));
assertTrue(problems.isEmpty());
// Actor rejects all XP/cursor/inventory mutation calls and all worker-side player access.
}
}
@Test
void clearingTheCustomNameUsesTheNativePaperNameAndLaterSheetsAndTailBooksUseTheSavedName() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
fixture.grid.setItem(0, sheet);
var cleared = named(sheet, null);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, cleared));
var saved = storage.find(original.id()).join().orElseThrow();
assertEquals("Paper", saved.name());
var rendered = new CharacterSheets().create(saved);
assertEquals(Component.text("Paper"), rendered.getItemMeta().customName());
assertEquals(original.id(), new CharacterSheets().identify(ItemStack.deserializeBytes(rendered.serializeAsBytes())).orElseThrow());
var dead = storage.recordDeath(saved.id(), saved.revision()).join().orElseThrow();
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
assertEquals("Tail of Paper", ((org.bukkit.inventory.meta.BookMeta) book.getItemMeta()).getTitle());
assertTrue(problems.isEmpty());
}
}
@Test
void ignoresOrdinaryItemsIdentitySubstitutionAndNonRenameTakesWithoutOverwritingANewerSavedName() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var other = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
storage.create(other).join();
var latest = storage.renameCharacter(original.id(), "Saved elsewhere").join().orElseThrow();
var listener = listener(storage, problems);
fixture.grid.setItem(0, sheet);
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, sheet.clone()));
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, new ItemStack(org.bukkit.Material.PAPER)));
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view,
named(new CharacterSheets().create(other), Component.text("Different identity"))));
fixture.grid.setItem(0, new ItemStack(org.bukkit.Material.PAPER));
listener.onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, named(sheet, Component.text("Counterfeit input"))));
assertEquals(latest, storage.find(original.id()).join().orElseThrow());
assertEquals(other, storage.find(other.id()).join().orElseThrow());
assertTrue(problems.isEmpty());
}
}
@Test
void missingAndDeadSheetReferencesAreReportedWithoutCreatingOrRevivingACharacter() throws Exception {
for (boolean dead : new boolean[] {false, true}) {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
fixture.grid.setItem(0, sheet);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
if (dead) { storage.create(original).join(); storage.recordDeath(original.id(), original.revision()).join(); }
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view,
named(sheet, Component.text("Cannot create or restore"))));
var saved = storage.find(original.id()).join();
assertEquals(dead, saved.isPresent());
if (dead) { assertTrue(saved.orElseThrow().dead()); assertEquals(original.name(), saved.orElseThrow().name()); }
assertEquals(1, problems.size());
assertEquals(original.id(), problems.getFirst().character());
assertEquals(fixture.actor.id, problems.getFirst().player());
assertEquals(sheet, fixture.grid.getItem(0));
}
}
}
@Test
void saveFailureReportsCapturedIdsAndPreservesSavedProgressWithoutReissuingOrMutatingItems() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
var output = named(sheet, Component.text("Taken but not yet saved"));
fixture.grid.setItem(0, sheet);
fixture.grid.setItem(2, output);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
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());
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
assertEquals(original, storage.find(original.id()).join().orElseThrow());
assertEquals(1, problems.size());
assertNotNull(problems.getFirst().cause());
}
assertEquals(sheet, fixture.grid.getItem(0));
assertEquals(output, fixture.grid.getItem(2));
assertEquals(0, fixture.actor.syncs);
}
}
@Test
void capturesTheTakenNameBeforeWorkerWorkAndRepeatsDoNotRewriteTheSameSavedName() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var sheet = new CharacterSheets().create(original);
fixture.grid.setItem(0, sheet);
var output = named(sheet, Component.text("Captured name"));
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
var listener = listener(storage, problems);
var event = new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output);
listener.onTaken(event); listener.onTaken(event);
var meta = output.getItemMeta(); meta.customName(Component.text("Later mutable output")); output.setItemMeta(meta);
var saved = storage.find(original.id()).join().orElseThrow();
assertEquals("Captured name", saved.name());
assertEquals(java.math.BigInteger.ONE, saved.revision());
assertTrue(problems.isEmpty());
}
}
@Test
void clearingACustomNameRespectsAnExplicitNativeItemNameComponent() throws Exception {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var fixture = new AnvilFixture();
var input = new CharacterSheets().create(original);
var meta = input.getItemMeta(); meta.itemName(Component.text("Arcane Paper")); input.setItemMeta(meta);
fixture.grid.setItem(0, input);
var output = named(input, null);
String nativeName = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(output).getHoverName().getString();
assertEquals("Arcane Paper", nativeName);
var problems = new CopyOnWriteArrayList<CharacterAnvilNames.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
storage.create(original).join();
listener(storage, problems).onTaken(new AnvilTakeResultEvent(fixture.actor.player, fixture.view, output));
assertEquals(nativeName, storage.find(original.id()).join().orElseThrow().name());
assertTrue(problems.isEmpty());
}
}
private static CharacterAnvilNames listener(CharacterStorage storage, CopyOnWriteArrayList<CharacterAnvilNames.Failure> problems) {
return new CharacterAnvilNames(storage, problems::add, org.bukkit.craftbukkit.inventory.CraftItemFactory.instance());
}
static ItemStack named(ItemStack source, Component name) {
var result = source.clone();
var meta = result.getItemMeta(); meta.customName(name); result.setItemMeta(meta);
return result;
}
static final class AnvilFixture {
final ManualCraftingControllerNativeTest.Actor actor = new ManualCraftingControllerNativeTest.Actor(2, 1);
final CraftInventoryAnvil grid = new CraftInventoryAnvil(null, new SimpleContainer(2), new ResultContainer());
final InventoryView view = new CraftAbstractInventoryView() {
@Override public CraftInventoryAnvil getTopInventory() { return grid; }
@Override public PlayerInventory getBottomInventory() { return actor.fixture.inventory; }
@Override public HumanEntity getPlayer() { return actor.player; }
@Override public InventoryType getType() { return InventoryType.ANVIL; }
@Override public String getTitle() { return "Anvil 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; }
@Override public ItemStack getCursor() { throw new AssertionError("Unexpected cursor access"); }
@Override public void setCursor(ItemStack item) { throw new AssertionError("Unexpected cursor write"); }
};
}
}
@@ -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,152 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual packaged PluginCommand permission/dispatch; only the absent console is a port. */
class EquipmentAdminCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void permissionAndStrictShapeRejectBeforePersistenceOrNativeLookup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
assertNotNull(f.command, "Packaged descriptor must install the actual command");
var sender = new Sender();
sender.allowed = false;
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"inspect", UUID.randomUUID().toString()}));
// Executor must also defend direct dispatch, not rely solely on PluginCommand.
assertTrue(f.command.getExecutor().onCommand(sender.port, f.command, "fantasystick",
new String[]{"recover", UUID.randomUUID().toString()}));
sender.allowed = true;
for (String[] args : List.of(new String[]{}, new String[]{"inspect"},
new String[]{"reset", UUID.randomUUID().toString()}, new String[]{"inspect", "1-1-1-1-1"},
new String[]{"recover", "not-a-uuid"}, new String[]{"inspect", UUID.randomUUID().toString(), "extra"})) {
assertTrue(f.command.execute(sender.port, "fantasystick", args));
}
assertFalse(Files.exists(data.resolve("sessions")));
assertTrue(f.tasks.isEmpty());
assertEquals(0, f.playerLookups, "Rejected commands must not inspect native players");
assertFalse(sender.messages.isEmpty());
}
assertFalse(Files.exists(data.resolve("sessions")), "No queued IO after worker drain either");
}
@Test void inspectIsDeferredSanitizedAndReturnsToMainThreadAndCloseDropsResponses() throws Exception {
try (var receipts = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data, java.time.Clock.systemUTC());
var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
UUID owner = UUID.randomUUID(), session = UUID.randomUUID(), character = UUID.randomUUID();
receipts.prepare(owner, session, character).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
receipts.recordExit(owner, session, java.time.Duration.ofSeconds(900)).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
f.plugin.setEnabled(true);
var sender = new Sender();
assertNotNull(f.command);
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"inspect", owner.toString()}));
assertTrue(sender.messages.isEmpty(), "No response until the server callback");
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
response.run();
String text = String.join("\n", sender.messages);
assertTrue(text.contains(owner.toString()), text);
assertTrue(text.contains(session.toString()), text);
assertTrue(text.contains(character.toString()), text);
assertTrue(text.contains("pending") && text.contains("deadline") && text.contains("exit"), text);
assertFalse(text.contains("schema-version") || text.contains(data.toString()), text);
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"inspect", owner.toString()});
var stale = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(stale);
f.plugin.setEnabled(false);
f.plugin.setEnabled(true);
stale.run();
assertTrue(sender.messages.isEmpty(), "Previous lifetime must not reply after re-enable");
}
}
@Test void onlineRecoveryUsesNativeProofAndReportsOnlyARequestAndOfflineNeverQueuesIO() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new Sender(); UUID offline = UUID.randomUUID();
f.command.execute(sender.port, "fantasystick", new String[]{"recover", offline.toString()});
assertTrue(sender.messages.getLast().contains("must be online"));
assertFalse(Files.exists(data.resolve("sessions"))); assertTrue(f.tasks.isEmpty());
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player);
var original = a.core.snapshot(); UUID session = UUID.randomUUID();
f.receipts().prepare(a.core.id, session, a.core.record.id()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.PackResult.PACKED,
new games.dmg.fantasystick.items.EquipmentStorage().pack(a.core.inventory, a.core.id, session));
a.core.marker(session, "PACKED"); var packed = a.core.snapshot();
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"recover", a.core.id.toString()});
assertEquals(1, sender.messages.size());
assertTrue(sender.messages.getFirst().contains("requested") && sender.messages.getFirst().contains("not confirmation"));
assertArrayEquals(packed, a.core.snapshot(), "Request is not premature success");
f.tick();
assertArrayEquals(original, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
f.command.execute(sender.port, "fantasystick", new String[]{"recover", a.core.id.toString()}); f.tick();
assertArrayEquals(original, a.core.snapshot());
}
}
@Test void ambiguousRecoveryRetainsEveryItemAndReceiptAndInspectionNeverPrintsRawMarker() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player);
UUID session = UUID.randomUUID();
var saved = f.receipts().prepare(a.core.id, session, a.core.record.id()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
new games.dmg.fantasystick.items.EquipmentStorage().pack(a.core.inventory, a.core.id, session);
a.core.pdc.set(new org.bukkit.NamespacedKey("fantasystick", "equipment_session"),
org.bukkit.persistence.PersistentDataType.STRING, "secret-not-a-valid-marker");
var before = a.core.snapshot(); var marker = a.core.pdc.serializeToBytes();
var sender = new Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"recover", a.core.id.toString()}); f.tick();
assertArrayEquals(before, a.core.snapshot()); assertArrayEquals(marker, a.core.pdc.serializeToBytes());
assertEquals(saved, f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow());
f.command.execute(sender.port, "fantasystick", new String[]{"inspect", a.core.id.toString()}); f.tick();
var text = String.join("\n", sender.messages);
assertTrue(text.contains("malformed (retained)")); assertTrue(text.contains("active=none"));
assertFalse(text.contains("secret-not-a-valid-marker"));
}
}
@Test void inspectObservesCurrentOnlineActiveStateAndPermissionRevocationDropsDeferredResponse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var sender = new Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"inspect", a.core.id.toString()}); f.tick();
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("active=session=")));
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("phase=PACKED")));
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"inspect", a.core.id.toString()});
var pending = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(pending);
sender.allowed = false; pending.run(); assertTrue(sender.messages.isEmpty());
sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"inspect", a.core.id.toString()});
pending = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(pending);
((AutoCloseable) f.command.getExecutor()).close(); pending.run(); assertTrue(sender.messages.isEmpty());
}
}
static final class Sender {
final Thread main = Thread.currentThread();
final List<String> messages = new ArrayList<>();
boolean allowed = true;
final CommandSender port = FantasyStickLifecycleNativeTest.port(CommandSender.class, (name, args) -> {
assertSame(main, Thread.currentThread(), "Worker accessed command sender: " + name);
return switch (name) {
case "hasPermission" -> { assertEquals("fantasystick.admin", args[0]); yield allowed; }
case "sendMessage" -> { messages.add(String.valueOf(args[0])); yield null; }
default -> throw new AssertionError("Unexpected sender call " + name);
};
});
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -38,6 +38,114 @@ class EquipmentStorageNativeTest {
});
}
@Test
void laterClearFailureLeavesEveryOriginalInTheActualNativeBoxWithoutRollback() {
var writes = new java.util.concurrent.atomic.AtomicInteger();
var armed = new java.util.concurrent.atomic.AtomicBoolean();
var inventory = new CraftInventoryPlayer(new Inventory(null, new EntityEquipment()) {
@Override public InventoryHolder getOwner() { return null; }
}) {
@Override public void setItem(int slot, ItemStack item) {
super.setItem(slot, item);
if (armed.get() && writes.incrementAndGet() == 7) throw new IllegalStateException("Later clear failed");
}
};
int[] slots = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
for (int i = 0; i < slots.length; i++) inventory.setItem(slots[i], new ItemStack(Material.DIAMOND, i + 1));
var original = snapshot(inventory);
armed.set(true);
assertThrows(IllegalStateException.class, () -> new EquipmentStorage().pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
var actualBox = inventory.getItem(9);
assertNotNull(actualBox, "A real recovery box must exist before any original is cleared");
var nativeContents = CraftItemStack.asNMSCopy(actualBox).get(DataComponents.CONTAINER);
assertNotNull(nativeContents);
var packed = NonNullList.withSize(27, net.minecraft.world.item.ItemStack.EMPTY);
nativeContents.copyInto(packed);
for (int i = 0; i < slots.length; i++) assertEquals(original[slots[i]], CraftItemStack.asBukkitCopy(packed.get(i)));
assertNull(inventory.getItem(0), "No silent rollback of cleared originals");
assertEquals(original[8], inventory.getItem(8), "Uncertain duplicates remain in their original slots");
}
@Test
void validatesTheWholeRestoreBeforeReplacingOnlyOwnedControlsAndKeepsOffhandAndHead() {
var inventory = inventory();
var owner = UUID.randomUUID();
var session = UUID.randomUUID();
var storage = new EquipmentStorage();
var sword = new ItemStack(Material.DIAMOND_SWORD);
inventory.setItem(0, sword);
var sheet = new ItemStack(Material.PAPER);
inventory.setItemInOffHand(sheet);
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
var control = new ItemStack(Material.STICK);
var meta = control.getItemMeta();
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "test_session"), PersistentDataType.STRING, session.toString());
control.setItemMeta(meta);
for (int i = 0; i < 9; i++) inventory.setItem(i, control.clone());
inventory.setItem(8, new ItemStack(Material.EMERALD));
var before = snapshot(inventory);
assertEquals(EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
storage.restore(inventory, owner, session, control::equals));
assertArrayEquals(before, inventory.getContents(), "Refusal must not strip even owned controls first");
inventory.setItem(8, control.clone());
for (int i = 10; i < 36; i++) inventory.setItem(i, new ItemStack(Material.STONE, 64));
var head = new ItemStack(Material.CARVED_PUMPKIN);
inventory.setHelmet(head);
assertEquals(EquipmentStorage.RestoreResult.RESTORED,
storage.restore(inventory, owner, session, control::equals));
assertEquals(sword, inventory.getItem(0));
for (int i = 1; i < 9; i++) assertTrue(inventory.getItem(i) == null || inventory.getItem(i).isEmpty());
assertEquals(sheet, inventory.getItemInOffHand());
assertEquals(head, inventory.getItem(9));
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND,
storage.restore(inventory, owner, session, control::equals));
}
@Test
void everyBoxLocationRestoresAllThirteenSlotsAndOnlyItsFreedLocationReceivesTheHead() {
for (int location : new int[]{35, 40, 41}) for (boolean head : new boolean[]{false, true}) {
var inventory = inventory(); var owner = UUID.randomUUID(); var session = UUID.randomUUID();
var storage = new EquipmentStorage();
int[] slots = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
for (int i = 0; i < slots.length; i++) inventory.setItem(slots[i], new ItemStack(Material.ARROW, i + 1));
var original = snapshot(inventory);
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, owner, session));
var box = inventory.getItem(9).clone();
for (int slot = 9; slot < 36; slot++) inventory.setItem(slot, new ItemStack(Material.STONE, 64));
var cursor = new java.util.concurrent.atomic.AtomicReference<>(new ItemStack(Material.DIAMOND, 7));
var unrelatedCursor = cursor.get().clone();
var offhand = new ItemStack(Material.EMERALD, 3); inventory.setItemInOffHand(offhand);
if (location == 41) cursor.set(box); else inventory.setItem(location, box);
var displaced = head ? new ItemStack(Material.CARVED_PUMPKIN) : null;
inventory.setHelmet(displaced);
assertEquals(EquipmentStorage.RestoreResult.RESTORED, storage.restore(inventory, owner, session, cursor.get(), cursor::set));
for (int slot : slots) assertEquals(original[slot], inventory.getItem(slot), "Original slot " + slot);
for (int slot = 9; slot < 36; slot++) assertEquals(slot == location ? displaced : new ItemStack(Material.STONE, 64), inventory.getItem(slot));
if (location == 40 && !head) assertTrue(inventory.getItemInOffHand().isEmpty());
else assertEquals(location == 40 ? displaced : offhand, inventory.getItemInOffHand());
assertEquals(location == 41 ? displaced : unrelatedCursor, cursor.get());
var restored = snapshot(inventory); var restoredCursor = cursor.get();
assertEquals(EquipmentStorage.RestoreResult.NOT_FOUND, storage.restore(inventory, owner, session, cursor.get(), cursor::set));
assertArrayEquals(restored, inventory.getContents()); assertEquals(restoredCursor, cursor.get());
}
}
@Test
void duplicatesAcrossMainOffhandAndCursorAreAmbiguousBeforeAnyWrite() {
for (int[] pair : new int[][]{{9, 35}, {9, 40}, {9, 41}, {40, 41}}) {
var inventory = inventory(); var owner = UUID.randomUUID(); var session = UUID.randomUUID();
var storage = new EquipmentStorage(); inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
storage.pack(inventory, owner, session);
var box = inventory.getItem(9).clone(); inventory.setItem(9, null);
var cursor = new java.util.concurrent.atomic.AtomicReference<>(new ItemStack(Material.EMERALD));
for (int location : pair) { if (location == 41) cursor.set(box.clone()); else inventory.setItem(location, box.clone()); }
var before = snapshot(inventory); var beforeCursor = cursor.get().clone();
assertEquals(EquipmentStorage.RestoreResult.AMBIGUOUS,
storage.restore(inventory, owner, session, cursor.get(), ignored -> fail("No cursor writes on refusal")));
assertArrayEquals(before, inventory.getContents()); assertEquals(beforeCursor, cursor.get());
}
}
@Test
void malformedAmbiguousAndOccupiedRestorationPathsLeaveAllItemsUntouched() {
for (int variant = 0; variant < 6; variant++) {
@@ -84,6 +192,33 @@ class EquipmentStorageNativeTest {
}
}
@Test
void cursorAndOffhandRefusalsValidateTheEntireBoxAndDestinationsBeforeMutation() {
for (int location : new int[]{40, 41}) for (int invalid = 0; invalid < 4; invalid++) {
var inventory = inventory(); var owner = UUID.randomUUID(); var session = UUID.randomUUID();
var storage = new EquipmentStorage(); inventory.setItem(0, new ItemStack(Material.DIAMOND_SWORD));
storage.pack(inventory, owner, session);
var item = inventory.getItem(9).clone(); inventory.setItem(9, null);
if (invalid == 0) item.setAmount(2);
if (invalid == 1) {
var meta = item.getItemMeta();
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "storage_slots"), PersistentDataType.INTEGER_ARRAY, new int[]{0});
item.setItemMeta(meta);
}
if (invalid == 2) {
var meta = (BlockStateMeta) item.getItemMeta(); var box = (ShulkerBox) meta.getBlockState();
box.getInventory().setItem(26, new ItemStack(Material.EMERALD)); meta.setBlockState(box); item.setItemMeta(meta);
}
if (invalid == 3) inventory.setItem(8, new ItemStack(Material.EMERALD));
var cursor = location == 41 ? item : new ItemStack(Material.DIAMOND);
if (location == 40) inventory.setItemInOffHand(item);
var before = snapshot(inventory); var beforeCursor = cursor.clone();
assertEquals(invalid == 3 ? EquipmentStorage.RestoreResult.OCCUPIED_SLOT : EquipmentStorage.RestoreResult.INVALID_BOX,
storage.restore(inventory, owner, session, cursor, ignored -> fail("Refusal cannot write cursor")));
assertArrayEquals(before, inventory.getContents()); assertEquals(beforeCursor, cursor);
}
}
@Test
void eligibilityMatchesTheActualVanillaShulkerSlotForEveryNativeItemMaterial() {
var nativeSlot = new net.minecraft.world.inventory.ShulkerBoxSlot(new net.minecraft.world.SimpleContainer(27), 0, 0, 0);
@@ -118,9 +253,14 @@ class EquipmentStorageNativeTest {
inventory.setItemInOffHand(new ItemStack(Material.PAPER));
var before = snapshot(inventory);
var storage = new EquipmentStorage();
assertEquals(EquipmentStorage.PackResult.NO_SPACE, storage.refusal(inventory, UUID.randomUUID(), UUID.randomUUID()).orElseThrow());
assertArrayEquals(before, inventory.getContents());
assertEquals(EquipmentStorage.PackResult.NO_SPACE, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
assertArrayEquals(before, inventory.getContents());
inventory.setItem(35, null);
var eligible = snapshot(inventory);
assertTrue(storage.refusal(inventory, UUID.randomUUID(), UUID.randomUUID()).isEmpty());
assertArrayEquals(eligible, inventory.getContents());
assertEquals(EquipmentStorage.PackResult.PACKED, storage.pack(inventory, UUID.randomUUID(), UUID.randomUUID()));
assertEquals(Material.SHULKER_BOX, inventory.getItem(35).getType());
}
@@ -0,0 +1,859 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import io.papermc.paper.plugin.configuration.PluginMeta;
import io.papermc.paper.plugin.manager.NativeEventDispatcher;
import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader;
import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.jar.JarFile;
import java.util.logging.Logger;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Loads the actual packaged entrypoint with native JavaPlugin lifecycle/dispatcher and explicit server ports. */
class FantasyStickLifecycleNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void enableRegistersCraftControllerAndCrafterGuardBeforeExposingTheRecipe() throws Exception {
try (var fixture = new Fixture(data)) {
fixture.plugin.setEnabled(true);
assertTrue(fixture.recipes.containsKey(CharacterSheets.RECIPE));
int recipe = fixture.order.indexOf("recipe");
assertTrue(fixture.order.indexOf("ManualCraftingController") >= 0);
assertTrue(fixture.order.indexOf("ManualCraftingController") < recipe);
assertTrue(fixture.order.indexOf("ManualCraftingGuard") >= 0);
assertTrue(fixture.order.indexOf("ManualCraftingGuard") < recipe);
assertTrue(fixture.order.indexOf("EquipmentSessionEvents") >= 0);
assertTrue(fixture.order.indexOf("EquipmentSessionEvents") < recipe);
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
}
}
@Test
void disableRemovesOnlyItsRecipeAndListenersAndIsSafeToRepeat() throws Exception {
try (var fixture = new Fixture(data)) {
var otherKey = new NamespacedKey("unrelated", "character_sheet");
var other = new org.bukkit.inventory.ShapelessRecipe(otherKey, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND))
.addIngredient(org.bukkit.Material.STICK);
fixture.recipes.put(otherKey, other);
fixture.plugin.setEnabled(true);
assertTrue(java.util.Arrays.stream(org.bukkit.event.inventory.CraftItemEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
fixture.plugin.setEnabled(false);
assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
assertSame(other, fixture.recipes.get(otherKey));
assertFalse(java.util.Arrays.stream(org.bukkit.event.inventory.CraftItemEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
assertFalse(java.util.Arrays.stream(org.bukkit.event.block.CrafterCraftEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
fixture.plugin.onDisable();
assertSame(other, fixture.recipes.get(otherKey));
}
}
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND)).addIngredient(org.bukkit.Material.STICK);
if (collision) fixture.recipes.put(CharacterSheets.RECIPE, existing);
else fixture.failRegistrationAt = stage;
assertThrows(IllegalStateException.class, () -> fixture.plugin.setEnabled(true));
if (collision) assertSame(existing, fixture.recipes.get(CharacterSheets.RECIPE));
else assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
assertFalse(java.util.Arrays.stream(org.bukkit.event.inventory.CraftItemEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
assertFalse(java.util.Arrays.stream(org.bukkit.event.block.CrafterCraftEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
assertFalse(java.util.Arrays.stream(org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
}
}
}
@Test
void registeredCraftDispatchUsesConfiguredPointsAndOnlySavesAcceptedCompletedAttempts() throws Exception {
for (String points : List.of("40", "0", "922337203685477580812345")) {
var directory = data.resolve(points);
java.nio.file.Files.createDirectories(directory);
if (!points.equals("40")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "starting-points: '" + points + "'\n");
try (var fixture = new Fixture(directory)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertTrue(new CharacterSheets().identify(fixture.recipes.get(CharacterSheets.RECIPE).getResult()).isEmpty());
var cancelled = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
cancelled.setCancelled(true);
fixture.dispatcher.accept(cancelled);
assertTrue(fixture.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
var event = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory).load(id).orElseThrow();
assertEquals(new java.math.BigInteger(points), saved.unspentPoints());
assertEquals(1, saved.level());
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, actor.syncs);
}
}
}
@Test
void invalidStartingPointValuesFailBeforeInstallingAnyGameplay() throws Exception {
for (String value : List.of("-1", "not-a-number", "1.5")) {
var directory = data.resolve(value);
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), "starting-points: '" + value + "'\n");
try (var fixture = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty());
assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
try (var fixture = new Fixture(data)) {
fixture.plugin.setEnabled(true);
var block = port(org.bukkit.block.Block.class, (name, args) -> { throw new AssertionError("Unexpected block access"); });
for (boolean ours : new boolean[] {false, true}) {
var recipe = ours ? new CharacterSheets().recipe() : new org.bukkit.inventory.ShapelessRecipe(
new NamespacedKey("other", "character_sheet"), new org.bukkit.inventory.ItemStack(org.bukkit.Material.PAPER))
.addIngredient(org.bukkit.Material.STICK);
for (boolean cancelled : new boolean[] {false, true}) {
var event = new org.bukkit.event.block.CrafterCraftEvent(block, recipe, recipe.getResult());
event.setCancelled(cancelled);
fixture.dispatcher.accept(event);
assertEquals(ours || cancelled, event.isCancelled());
assertEquals(recipe.getResult(), event.getResult());
}
}
assertTrue(fixture.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(data.resolve("characters")));
}
}
@Test
void disableInvalidatesAlreadyQueuedCraftCompletionBeforeStorageShutdown() throws Exception {
try (var fixture = new Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
fixture.dispatcher.accept(actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
fixture.plugin.setEnabled(false);
complete.run(); complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(0, actor.syncs);
assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
awaitUnissuedRecordCleanup(data);
}
}
@Test
void installedManualRoutesDeliverSavedDistinctSheetsIncludingQAndCtrlQ() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("route-" + route);
try (var fixture = new Fixture(directory)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(3, 2);
var world = new PlayerSheetDropsNativeTest.WorldFixture();
actor.eyeLocation = new org.bukkit.Location(world.world, 10, 66, 10);
var dropped = new ArrayList<org.bukkit.inventory.ItemStack>();
world.onSpawn = event -> {
fixture.dispatcher.accept(event);
if (!event.isCancelled()) dropped.add(event.getEntity().getItemStack().clone());
};
var click = switch (route) {
case 0 -> org.bukkit.event.inventory.ClickType.RIGHT;
case 1 -> org.bukkit.event.inventory.ClickType.SHIFT_LEFT;
case 2 -> org.bukkit.event.inventory.ClickType.NUMBER_KEY;
case 3 -> org.bukkit.event.inventory.ClickType.SWAP_OFFHAND;
case 4 -> org.bukkit.event.inventory.ClickType.DROP;
default -> org.bukkit.event.inventory.ClickType.CONTROL_DROP;
};
var action = switch (route) {
case 0 -> org.bukkit.event.inventory.InventoryAction.PICKUP_HALF;
case 1 -> org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY;
case 2, 3 -> org.bukkit.event.inventory.InventoryAction.HOTBAR_SWAP;
case 4 -> org.bukkit.event.inventory.InventoryAction.DROP_ONE_SLOT;
default -> org.bukkit.event.inventory.InventoryAction.DROP_ALL_SLOT;
};
var event = new org.bukkit.event.inventory.CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE),
actor.fixture.view, org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, click, action, route == 2 ? 4 : -1);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete); complete.run();
var outputs = new ArrayList<org.bukkit.inventory.ItemStack>(dropped);
for (var item : actor.fixture.inventory.getContents()) if (new CharacterSheets().identify(item).isPresent()) outputs.add(item);
if (new CharacterSheets().identify(actor.fixture.view.getCursor()).isPresent()) outputs.add(actor.fixture.view.getCursor());
int quantity = route == 1 || route == 5 ? 2 : 1;
assertEquals(quantity, outputs.size());
var ids = outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList();
assertEquals(quantity, ids.stream().distinct().count());
for (var id : ids) assertTrue(new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory).load(id).isPresent());
var first = actor.fixture.grid.getMatrix()[0];
assertEquals(2 - quantity, first == null ? 0 : first.getAmount());
assertEquals(1, actor.syncs);
if (route >= 4) assertEquals(quantity, world.spawnCalls);
complete.run();
assertEquals(1, actor.syncs);
}
}
}
@Test
void recipeRemovalFailureDoesNotPreventAttemptInvalidationOrListenerCleanup() throws Exception {
try (var fixture = new Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
fixture.dispatcher.accept(actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
fixture.failRemoval = true;
assertDoesNotThrow(() -> fixture.plugin.setEnabled(false));
complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertFalse(java.util.Arrays.stream(org.bukkit.event.inventory.CraftItemEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
fixture.failRemoval = false;
fixture.plugin.onDisable();
assertFalse(fixture.recipes.containsKey(CharacterSheets.RECIPE));
awaitUnissuedRecordCleanup(data);
}
}
@Test
void installedAnvilObserverPersistsTakenNamesButNeverPreviewsAndUnregistersOnDisable() throws Exception {
try (var fixture = new Fixture(data)) {
fixture.plugin.setEnabled(true);
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
repository.create(original);
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var sheet = new CharacterSheets().create(original);
var output = CharacterAnvilNamesNativeTest.named(sheet, net.kyori.adventure.text.Component.text("Registered anvil name"));
anvil.grid.setItem(0, sheet);
anvil.grid.setItem(2, output);
var previewView = port(org.bukkit.inventory.view.AnvilView.class, (name, args) -> switch (name) {
case "getTopInventory" -> anvil.grid;
default -> throw new AssertionError("Unexpected preview call " + name);
});
fixture.dispatcher.accept(new org.bukkit.event.inventory.PrepareAnvilEvent(previewView, output));
assertEquals(original, repository.load(original.id()).orElseThrow());
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output));
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
while (!repository.load(original.id()).orElseThrow().name().equals("Registered anvil name") && System.nanoTime() < deadline) {
Thread.sleep(5);
}
assertEquals("Registered anvil name", repository.load(original.id()).orElseThrow().name());
assertEquals(original.stats(), repository.load(original.id()).orElseThrow().stats());
assertEquals(sheet, anvil.grid.getItem(0));
assertEquals(output, anvil.grid.getItem(2));
fixture.plugin.setEnabled(false);
assertFalse(java.util.Arrays.stream(org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == fixture.plugin));
}
}
@Test void packagedDeathRegistrationAndReentrantDisableKeepAlreadyOwnedCharacterSave() throws Exception {
for (boolean disable : new boolean[]{false, true}) {
var directory = data.resolve("death-" + disable);
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true);
var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> {
entered.countDown();
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException e) { throw new AssertionError(e); }
});
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sheet = a.core.inventory.getItemInOffHand().clone();
if (disable) a.core.mutation = () -> { a.core.mutation = () -> {}; f.plugin.setEnabled(false); };
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.OUT_OF_WORLD)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 7,
net.kyori.adventure.text.Component.text("ordinary death"), true);
try {
f.dispatcher.accept(death);
assertTrue(death.isCancelled()); assertFalse(death.getKeepInventory());
assertEquals(a.core.normalMaximum, a.core.health);
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(sheet, a.core.inventory.getItemInOffHand());
if (disable) assertFalse(sessions.drained().toCompletableFuture().isDone());
} finally { release.countDown(); }
gate.get(5, java.util.concurrent.TimeUnit.SECONDS);
if (!disable) {
f.tick(); assertEquals(org.bukkit.Material.WRITTEN_BOOK, a.core.inventory.getItemInOffHand().getType());
assertTrue(a.messages.stream().anyMatch(message -> message.startsWith("§c")), "Red defeat feedback port");
f.plugin.setEnabled(false);
}
f.awaitWorkers(); sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(new YamlCharacterRepository(directory).load(a.core.record.id()).orElseThrow().dead());
assertEquals(1, a.core.teleports);
if (disable) assertEquals(sheet, a.core.inventory.getItemInOffHand(), "No replacement on shutdown");
assertFalse(java.util.Arrays.stream(org.bukkit.event.entity.PlayerDeathEvent.getHandlerList().getRegisteredListeners())
.anyMatch(listener -> listener.getPlugin() == f.plugin));
}
}
}
@Test void packagedOffhandTransitionAcknowledgesReceiptAndRevalidatesBeforePacking() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
var a = new Actor(data); f.players.put(a.core.id, a.player);
var before = a.core.snapshot();
f.changed(a); assertArrayEquals(before, a.core.snapshot());
f.tick(); assertArrayEquals(before, a.core.snapshot());
f.tick();
var completion = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(completion);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNotNull(state.pending()); assertNull(a.core.marker()); assertArrayEquals(before, a.core.snapshot());
a.core.inventory.setItem(3, new org.bukkit.inventory.ItemStack(org.bukkit.Material.EMERALD));
completion.run();
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(org.bukkit.Material.EMERALD, a.core.inventory.getItem(3).getType());
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
a.core.inventory.setItemInOffHand(null); f.changed(a);
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
f.activate(a);
assertEquals(org.bukkit.Material.SHULKER_BOX, a.core.inventory.getItem(9).getType());
assertEquals(org.bukkit.Material.BOOK, a.core.inventory.getItem(0).getType());
}
}
@Test void packagedRemovalSwapQuitAndDisableRestoreExactlyOnce() throws Exception {
for (String route : List.of("removal", "swap", "quit", "disable")) {
try (var f = new Fixture(data.resolve(route))) {
f.plugin.setEnabled(true);
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player); f.activate(a);
var receipts = f.receipts();
if (route.equals("removal")) {
a.core.cursor = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
} else if (route.equals("swap")) {
var event = new org.bukkit.event.player.PlayerSwapHandItemsEvent(a.player,
a.core.inventory.getItemInOffHand(), a.core.inventory.getItem(0));
f.dispatcher.accept(event); assertTrue(event.isCancelled()); f.tick();
} else if (route.equals("quit")) {
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
assertTrue(a.core.marker().endsWith("|RESTORED"));
assertEquals(org.bukkit.Material.DIAMOND_HELMET, a.core.inventory.getHelmet().getType());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, route.equals("swap")
? a.core.inventory.getItemInOffHand().getType() : a.core.inventory.getItem(0).getType());
if (!route.equals("disable")) assertNull(receipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
var restored = a.core.snapshot();
f.plugin.setEnabled(false); f.awaitWorkers(); f.plugin.onDisable();
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(restored, a.core.snapshot());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data.resolve(route), java.time.Clock.systemUTC())) {
assertNull(reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
}
}
}
}
@Test void packagedRefusalsExplainSpaceAndIneligibleEquipmentWithoutMutation() throws Exception {
for (boolean full : new boolean[]{false, true}) try (var f = new Fixture(data.resolve("refusal-" + full))) {
f.plugin.setEnabled(true);
var a = new Actor(data.resolve("refusal-" + full)); f.players.put(a.core.id, a.player);
if (full) for (int slot = 9; slot < 36; slot++) a.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE, 64));
else a.core.inventory.setItem(4, new org.bukkit.inventory.ItemStack(org.bukkit.Material.SHULKER_BOX));
var before = a.core.snapshot(); f.changed(a); f.tick(); f.tick();
assertArrayEquals(before, a.core.snapshot()); assertNull(a.core.marker());
assertEquals(1, a.messages.size());
assertTrue(a.messages.getFirst().contains(full ? "free one slot" : "vanilla shulker"), a.messages::toString);
assertEquals(1, a.particles.size()); assertTrue(a.particles.getFirst() > 0 && a.particles.getFirst() <= 16);
assertTrue(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
}
}
@Test void startupArrivalIsSilentForOrdinaryPlayersAndReceiptFirstForSheetHolders() throws Exception {
try (var f = new Fixture(data)) {
var ordinary = new Actor(data); ordinary.core.inventory.setItemInOffHand(null);
f.players.put(ordinary.core.id, ordinary.player);
var sheet = new Actor(data); f.players.put(sheet.core.id, sheet.player);
f.plugin.setEnabled(true);
// Two receipt lookups, then the sheet's load and acknowledged preparation callbacks.
f.tick(); f.tick(); f.tick(); f.tick();
assertTrue(ordinary.messages.isEmpty()); assertTrue(ordinary.particles.isEmpty());
assertNull(ordinary.core.marker()); assertTrue(sheet.core.marker().endsWith("|PACKED"));
}
}
@Test void startupArrivalRecoversMatchingReceiptBeforeEntryAndRetainsAmbiguity() throws Exception {
for (boolean ambiguous : new boolean[]{false, true}) {
var directory = data.resolve("arrival-" + ambiguous);
try (var f = new Fixture(directory)) {
var a = new Actor(directory); f.players.put(a.core.id, a.player);
var original = a.core.snapshot(); var session = java.util.UUID.randomUUID();
try (var store = new games.dmg.fantasystick.persistence.EquipmentSessionStore(directory, java.time.Clock.systemUTC())) {
store.prepare(a.core.id, session, a.core.record.id()).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
new games.dmg.fantasystick.items.EquipmentStorage().pack(a.core.inventory, a.core.id, session);
a.core.marker(session, ambiguous ? "RESTORING" : "PACKED");
var packed = a.core.snapshot();
f.plugin.setEnabled(true); assertArrayEquals(packed, a.core.snapshot()); f.tick();
assertArrayEquals(ambiguous ? packed : original, a.core.snapshot());
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
if (ambiguous) { assertNotNull(state.pending()); assertFalse(a.messages.isEmpty()); }
else { assertNull(state.pending()); assertTrue(a.core.marker().endsWith("|RESTORED")); }
assertTrue(f.tasks.isEmpty(), "Recovery is not a fresh entry request");
}
}
}
@Test void shutdownDrainsAnAcceptedPreparationAndItsNeverPackedCleanup() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true); var a = new Actor(data); f.players.put(a.core.id, a.player);
var original = a.core.snapshot(); f.changed(a); f.tick();
var loaded = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(loaded);
var worker = (java.util.concurrent.ExecutorService) Fixture.field(f.receipts(), "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
});
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
try {
loaded.run(); // Receipt preparation accepted, but the worker has not acknowledged it.
f.plugin.setEnabled(false);
} finally { release.countDown(); }
f.awaitWorkers();
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(original, a.core.snapshot()); assertNull(a.core.marker());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data, java.time.Clock.systemUTC())) {
assertNull(reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
}
}
}
@Test void reentrantDisableDuringTheCloseSweepCannotCloseReceiptsBeforeExitSubmission() throws Exception {
for (boolean sync : new boolean[]{false, true}) {
var directory = data.resolve("reentrant-" + sync);
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true);
var first = new Actor(directory); var second = new Actor(directory);
var firstOriginal = first.core.snapshot(); var secondOriginal = second.core.snapshot();
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player);
f.activate(first); f.activate(second);
for (var actor : List.of(first, second)) {
Runnable disable = () -> {
actor.core.mutation = () -> {}; actor.core.sync = () -> {};
f.plugin.onDisable();
};
if (sync) actor.core.sync = disable; else actor.core.mutation = disable;
}
f.plugin.setEnabled(false); f.awaitWorkers();
assertArrayEquals(firstOriginal, first.core.snapshot());
assertArrayEquals(secondOriginal, second.core.snapshot());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(directory, java.time.Clock.systemUTC())) {
for (var actor : List.of(first, second)) {
var saved = reopened.find(actor.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(saved.pending()); assertNotNull(saved.lastExit());
}
}
}
}
}
@Test void drainObserversCannotForceStoreClosureOrCloseAReenabledLifetime() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true); var a = new Actor(data); f.players.put(a.core.id, a.player);
var original = a.core.snapshot(); f.changed(a); f.tick();
var loaded = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(loaded);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
var oldReceipts = f.receipts();
var worker = (java.util.concurrent.ExecutorService) Fixture.field(oldReceipts, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
loaded.run();
var readOnly = sessions.drained();
assertThrows(UnsupportedOperationException.class, () ->
((java.util.concurrent.CompletableFuture<Void>) readOnly).complete(null));
assertThrows(UnsupportedOperationException.class, () ->
((java.util.concurrent.CompletableFuture<Void>) readOnly).cancel(false));
readOnly.toCompletableFuture().complete(null);
readOnly.toCompletableFuture().cancel(false);
readOnly.toCompletableFuture().obtrudeException(new IllegalStateException("Forged observer"));
assertFalse(sessions.drained().toCompletableFuture().isDone());
f.plugin.setEnabled(false);
assertFalse(worker.isShutdown(), "Preparation and its cleanup still own the old store");
assertFalse(java.util.Arrays.stream(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent
.getHandlerList().getRegisteredListeners()).anyMatch(listener -> listener.getPlugin() == f.plugin));
f.players.clear(); f.plugin.setEnabled(true);
var newReceipts = f.receipts(); assertNotSame(oldReceipts, newReceipts);
release.countDown();
assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(sessions.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertNull(newReceipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Old completion closes only its captured store, not the new instance");
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(original, a.core.snapshot()); assertNull(a.core.marker());
var rejected = assertThrows(java.util.concurrent.ExecutionException.class, () ->
oldReceipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertInstanceOf(java.util.concurrent.RejectedExecutionException.class, rejected.getCause());
} finally { release.countDown(); }
}
}
@Test void acceptedCharacterReadFinishingAfterDisableCannotScheduleOrPack() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true); var a = new Actor(data); f.players.put(a.core.id, a.player);
var original = a.core.snapshot(); f.changed(a); f.tick();
var loaded = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(loaded);
var worker = (java.util.concurrent.ExecutorService) Fixture.field(Fixture.field(f.plugin, "characters"), "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
loaded.run();
// Preparation's handler queues its fresh character read before this receipt barrier.
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
int scheduled = f.schedulerCalls.get();
f.plugin.setEnabled(false);
release.countDown(); f.awaitWorkers();
assertEquals(scheduled, f.schedulerCalls.get(), "A late read must not submit to the disabled scheduler");
while (!f.tasks.isEmpty()) f.tasks.remove().run();
assertArrayEquals(original, a.core.snapshot()); assertNull(a.core.marker());
try (var reopened = new games.dmg.fantasystick.persistence.EquipmentSessionStore(data, java.time.Clock.systemUTC())) {
assertNull(reopened.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
}
} finally { release.countDown(); }
}
}
@Test void deferredPackCannotMutateAcrossDisableAndReenable() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);
var a = new Actor(data); f.players.put(a.core.id, a.player);
var before = a.core.snapshot(); f.changed(a); f.tick(); f.tick();
var late = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(late);
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true);
late.run(); late.run();
assertArrayEquals(before, a.core.snapshot()); assertNull(a.core.marker()); assertTrue(a.messages.isEmpty());
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending());
}
}
@Test void configuredSwitchDelaysReachRealPersistedExits() throws Exception {
for (String value : List.of("default", "0", "37")) {
var directory = data.resolve(value); java.nio.file.Files.createDirectories(directory);
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
}
}
}
@Test void invalidSwitchDurationsFailBeforeAnyNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String value : List.of("-1", "1.5", "true", "[]", "'abc'", "9223372036854775808", "9223372036854775807", "253402300800")) {
var directory = data.resolve("invalid-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = "character-swap-cooldown-seconds: " + value + "\n";
java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), value);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("sessions")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
private static void awaitUnissuedRecordCleanup(Path directory) throws Exception {
// The batch was ready before disable, so its cleanup was queued before shutdown.
// Production disable remains nonblocking; only the test waits before deleting its fixture directory.
var deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
while (true) {
try (var paths = java.nio.file.Files.list(directory.resolve("characters"))) {
if (paths.noneMatch(path -> path.getFileName().toString().endsWith(".yml"))) return;
}
if (System.nanoTime() >= deadline) fail("Unissued fixture records were not cleaned up before temporary-directory disposal");
Thread.sleep(5);
}
}
static final class Fixture implements AutoCloseable {
final List<String> order = new ArrayList<>();
final Map<NamespacedKey, Recipe> recipes = new HashMap<>();
final LinkedBlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
final java.util.concurrent.atomic.AtomicInteger schedulerCalls = new java.util.concurrent.atomic.AtomicInteger();
final List<Listener> listeners = new ArrayList<>();
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
final Thread main = Thread.currentThread();
final Map<java.util.UUID, org.bukkit.entity.Player> players = new HashMap<>();
org.bukkit.command.PluginCommand command;
int playerLookups;
String failRegistrationAt;
boolean failRemoval;
final NativeEventDispatcher dispatcher;
final Server server;
final Loader loader;
final JavaPlugin plugin;
Fixture(Path data) throws Exception {
var manager = port(PluginManager.class, (name, args) -> switch (name) {
case "registerEvents" -> {
var listener = (Listener) args[0];
order.add(listener.getClass().getSimpleName());
listeners.add(listener);
captureWorkers((JavaPlugin) args[1]);
dispatcher().register(listener, (org.bukkit.plugin.Plugin) args[1]);
if (listener.getClass().getSimpleName().equals(failRegistrationAt)) throw new IllegalStateException("registration failed");
yield null;
}
case "callEvent" -> { dispatcher().accept((org.bukkit.event.Event) args[0]); yield null; }
default -> throw new AssertionError("Unexpected manager call " + name);
});
var scheduler = port(BukkitScheduler.class, (name, args) -> switch (name) {
case "runTask" -> {
schedulerCalls.incrementAndGet();
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
tasks.add((Runnable) args[1]);
yield port(BukkitTask.class, (method, values) -> { throw new AssertionError("Unexpected task call " + method); });
}
default -> throw new AssertionError("Unexpected scheduler call " + name);
});
server = port(Server.class, (name, args) -> switch (name) {
case "getPluginManager" -> manager;
case "getItemFactory" -> org.bukkit.craftbukkit.inventory.CraftItemFactory.instance();
case "getScheduler" -> scheduler;
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
case "isPrimaryThread" -> Thread.currentThread() == main;
case "getOnlinePlayers" -> List.copyOf(players.values());
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> command;
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
var recipe = (Recipe) args[0];
yield recipes.putIfAbsent(((Keyed) recipe).getKey(), recipe) == null;
}
case "getRecipe" -> recipes.get((NamespacedKey) args[0]);
case "removeRecipe" -> {
if (failRemoval) throw new IllegalStateException("Simulated registry removal failure");
yield recipes.remove((NamespacedKey) args[0]) != null;
}
default -> throw new AssertionError("Unexpected server call " + name);
});
dispatcher = new NativeEventDispatcher(server);
loader = new Loader(server, data);
plugin = (JavaPlugin) loader.loadClass("games.dmg.fantasystick.FantasyStickPlugin").getConstructor().newInstance();
var definition = plugin.getDescription().getCommands().get("fantasystick");
if (definition != null) {
// Bukkit normally constructs this from the packaged descriptor during plugin loading.
var constructor = org.bukkit.command.PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class);
constructor.setAccessible(true);
command = constructor.newInstance("fantasystick", plugin);
command.setPermission((String) definition.get("permission"));
// Explicit server-message port: native permission dispatch otherwise reads global Bukkit.server.
command.permissionMessage(net.kyori.adventure.text.Component.text("Permission denied"));
command.setUsage((String) definition.get("usage"));
}
}
private NativeEventDispatcher dispatcher() { return dispatcher; }
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
}
void tick() throws Exception {
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
}
void changed(Actor actor) {
dispatcher.accept(new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player, 40,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.AIR), actor.core.inventory.getItemInOffHand()));
}
void activate(Actor actor) throws Exception {
changed(actor); tick(); tick(); tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
if (!workers.contains(worker)) workers.add(worker);
}
}
}
private static Object field(Object object, String name) {
try {
var field = object.getClass().getDeclaredField(name); field.setAccessible(true); return field.get(object);
} catch (ReflectiveOperationException error) { throw new AssertionError(error); }
}
void awaitWorkers() throws IOException {
try {
for (var worker : workers) assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS),
"Accepted persistence must drain before fixture disposal; production shutdown is nonblocking");
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
}
@Override public void close() throws IOException {
try { plugin.setEnabled(false); awaitWorkers(); }
finally { HandlerList.unregisterAll(plugin); loader.close(); }
}
}
/** Real native inventory/PDC from the coordinator fixture; message/particle/network ports only. */
static final class Actor {
final EquipmentSessionsNativeTest.Actor core;
final List<String> messages = new ArrayList<>();
final List<Integer> particles = new ArrayList<>();
final org.bukkit.entity.Player player;
Actor(Path data) throws Exception {
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);
core = new EquipmentSessionsNativeTest.Actor(record);
player = (org.bukkit.entity.Player) Proxy.newProxyInstance(org.bukkit.entity.Player.class.getClassLoader(),
new Class<?>[]{org.bukkit.entity.Player.class}, (proxy, method, args) -> {
assertSame(core.main, Thread.currentThread(), "Worker accessed Player: " + method.getName());
return switch (method.getName()) {
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
var grid = new ManualCraftPlanNativeTest.Fixture(2, 1).grid;
core.view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
@Override public org.bukkit.inventory.Inventory getTopInventory() { return grid; }
@Override public org.bukkit.inventory.PlayerInventory getBottomInventory() { return core.inventory; }
@Override public org.bukkit.entity.HumanEntity getPlayer() { return player; }
@Override public org.bukkit.event.inventory.InventoryType getType() { return org.bukkit.event.inventory.InventoryType.CRAFTING; }
@Override public org.bukkit.inventory.ItemStack getCursor() { return core.cursor; }
@Override public void setCursor(org.bukkit.inventory.ItemStack item) { core.cursor = item; }
@Override public String getTitle() { return "Packaged equipment fixture"; }
@Override public String getOriginalTitle() { return getTitle(); }
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title mutation"); }
@Override public org.bukkit.inventory.MenuType getMenuType() { return null; }
};
}
}
static final class Loader extends URLClassLoader implements ConfiguredPluginClassLoader {
private static final String ENTRY = "games.dmg.fantasystick.FantasyStickPlugin";
private final Server server;
private final Path data;
private final Path jar = Path.of(System.getProperty("distribution.jar"));
private final PluginDescriptionFile description;
private JavaPlugin plugin;
Loader(Server server, Path data) throws Exception {
super(new URL[] {Path.of(System.getProperty("distribution.jar")).toUri().toURL()}, FantasyStickLifecycleNativeTest.class.getClassLoader());
this.server = server;
this.data = data;
try (var archive = new JarFile(jar.toFile()); var input = archive.getInputStream(archive.getJarEntry("plugin.yml"))) {
description = new PluginDescriptionFile(input);
}
}
@Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
if (!name.equals(ENTRY)) return super.loadClass(name, resolve);
var loaded = findLoadedClass(name);
if (loaded == null) loaded = findClass(name);
if (resolve) resolveClass(loaded);
return loaded;
}
}
@Override public Class<?> loadClass(String name, boolean resolve, boolean global, boolean libraries) throws ClassNotFoundException {
return loadClass(name, resolve);
}
@Override public PluginMeta getConfiguration() { return description; }
@Override public JavaPlugin getPlugin() { return plugin; }
@Override public PluginClassLoaderGroup getGroup() { return null; }
@Override public void init(JavaPlugin created) {
plugin = created;
created.init(server, description, data.toFile(), jar.toFile(), this, description, Logger.getLogger("FantasyStick lifecycle fixture"));
}
}
@FunctionalInterface interface Call { Object invoke(String name, Object[] args); }
static <T> T port(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" -> "Lifecycle port " + type.getSimpleName();
default -> throw new AssertionError(method.getName());
};
return call.invoke(method.getName(), args);
}));
}
}
@@ -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,361 @@
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;
HumanEntity human;
boolean failMatrixRead;
Fixture(int width, int amount) {
matrix = new TransientCraftingContainer(menu, width, width);
var result = new ResultContainer();
matrix.resultInventory = result;
grid = new CraftInventoryCrafting(matrix, result) {
@Override public ItemStack[] getMatrix() {
if (failMatrixRead) throw new IllegalStateException("Simulated native matrix read failure");
return super.getMatrix();
}
};
view = new CraftAbstractInventoryView() {
@Override public CraftInventoryCrafting getTopInventory() { return grid; }
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
@Override public HumanEntity getPlayer() {
if (human == null) throw new AssertionError("Unexpected player/network access");
return human;
}
@Override public InventoryType getType() { return grid.getType(); }
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
@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,450 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.crafting.ManualCraftingController;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.UUID;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.CopyOnWriteArrayList;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.inventory.InventoryView;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ManualCraftingControllerNativeTest {
@TempDir Path data;
private final CopyOnWriteArrayList<ManualCraftingController.Failure> unexpected = new CopyOnWriteArrayList<>();
@org.junit.jupiter.api.AfterEach void assertNoUnexpectedDiagnostics() {
assertTrue(unexpected.isEmpty(), () -> "Unexpected diagnostics: " + unexpected);
}
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void acceptedCraftIsContainedSavedAndDeliveredOnlyOnItsDeferredServerCallbackOnce() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var allocated = new ArrayList<CharacterRecord>();
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults());
allocated.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop craft"); }, failures::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
controller.onCraft(event);
assertTrue(event.isCancelled(), "Vanilla must not consume the metadata-empty preview");
assertEquals(1, allocated.size());
var repeat = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
controller.onCraft(repeat);
assertTrue(repeat.isCancelled());
assertEquals(1, allocated.size(), "The pending attempt must not allocate a second batch");
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
assertEquals(allocated.getFirst(), storage.find(allocated.getFirst().id()).join().orElseThrow());
complete.run();
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(allocated.getFirst().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertEquals(1, actor.syncs);
complete.run();
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, actor.syncs);
assertTrue(failures.isEmpty());
}
}
@Test
@SuppressWarnings("try") // Exercise disable during an attempt and idempotent final resource cleanup.
void closingDisconnectingDisablingOrChangingContextBeforeCompletionNeverConsumesOrDelivers() throws Exception {
for (int scenario = 0; scenario < 5; scenario++) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
if (scenario == 0) controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
if (scenario == 1) {
actor.online = false;
controller.onQuit(new org.bukkit.event.player.PlayerQuitEvent(actor.player, net.kyori.adventure.text.Component.empty()));
}
if (scenario == 2) controller.close();
if (scenario == 3) actor.fixture.grid.getMatrix()[0].setAmount(7);
if (scenario == 4) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
complete.run();
assertEquals(scenario == 3 ? 7 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertTrue(actor.current.getCursor().isEmpty());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
}
}
}
@Test
void oldCallbacksCannotDeliverIntoOrRemoveANewAttemptOnTheSameReopenedMenu() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var old = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(old);
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var fresh = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(fresh);
old.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
assertEquals(2, records.size(), "Old completion must not clear the fresh reservation");
fresh.run();
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
assertTrue(storage.find(records.get(1).id()).join().isPresent());
}
}
@Test
void qAndCtrlQUseTheSavedDropPathEvenWithFullPlayerStorage() throws Exception {
for (boolean bulk : new boolean[] {false, true}) {
var actor = new Actor(3, 3);
for (int slot = 0; slot < 36; slot++) actor.fixture.inventory.setItem(slot,
new org.bukkit.inventory.ItemStack(org.bukkit.Material.COBBLESTONE, 64));
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var outputs = new ArrayList<org.bukkit.inventory.ItemStack>();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
assertSame(actor.player, player);
assertTrue(current.getAsBoolean());
outputs.add(sheet.clone());
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, unexpected::add)) {
var event = actor.fixture.event(bulk ? ClickType.CONTROL_DROP : ClickType.DROP,
bulk ? InventoryAction.DROP_ALL_SLOT : InventoryAction.DROP_ONE_SLOT);
controller.onCraft(event);
assertTrue(event.isCancelled());
assertEquals(bulk ? 3 : 1, records.size());
assertTrue(outputs.isEmpty());
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(bulk ? 3 : 1, outputs.size());
assertEquals(records.stream().map(CharacterRecord::id).toList(),
outputs.stream().map(item -> new CharacterSheets().identify(item).orElseThrow()).toList());
var first = actor.fixture.grid.getMatrix()[0];
assertEquals(bulk ? 0 : 2, first == null ? 0 : first.getAmount());
assertEquals(64, actor.fixture.inventory.getItem(8).getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(1, actor.syncs);
for (var record : records) assertTrue(storage.find(record.id()).join().isPresent());
}
}
}
@Test
void deliveryAndClientSyncKeepAReservationEvenAcrossCloseReopenAndReentrantCompletion() throws Exception {
for (int scenario = 0; scenario < 3; scenario++) {
var actor = new Actor(2, 3);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var failures = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
var listener = new java.util.concurrent.atomic.AtomicReference<ManualCraftingController>();
var completion = new java.util.concurrent.atomic.AtomicReference<Runnable>();
int mode = scenario;
Runnable reopenAndCraft = () -> {
listener.get().onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
var again = actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
listener.get().onCraft(again);
assertTrue(again.isCancelled());
};
if (mode == 2) actor.onSync = reopenAndCraft;
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
if (mode == 0) completion.get().run();
if (mode == 1) reopenAndCraft.run();
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, failures::add)) {
listener.set(controller);
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
completion.set(tasks.poll(5, TimeUnit.SECONDS)); assertNotNull(completion.get());
completion.get().run();
assertEquals(1, records.size(), "Reentrant close must not open a second allocation window during delivery/sync");
assertEquals(mode == 1 ? 3 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(1, actor.syncs);
assertEquals(mode == 1 ? java.util.List.of("drop-unconfirmed") : java.util.List.of(),
failures.stream().map(ManualCraftingController.Failure::operation).toList());
assertTrue(tasks.isEmpty());
}
}
}
@Test
void rejectedSchedulingAbandonsUnissuedFilesWithoutWorkerPlayerAccessAndPermitsRetry() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var reject = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, task -> {
if (reject.getAndSet(false)) throw new java.util.concurrent.RejectedExecutionException("scheduler unavailable");
tasks.add(task);
}, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var problem = problems.poll(5, TimeUnit.SECONDS);
assertNotNull(problem, "Scheduling rejection must not silently orphan the pending attempt");
assertEquals("schedule", problem.operation());
assertEquals(actor.id, problem.player());
assertEquals(java.util.List.of(records.getFirst().id()), problem.characters());
assertTrue(storage.find(records.getFirst().id()).join().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(0, actor.syncs);
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(2, records.size());
assertEquals(records.get(1).id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
}
}
@Test
void allocationAndSaveFailuresLeaveInventoryUnchangedAndDoNotBlockTheNextCraft() throws Exception {
for (boolean failAllocation : new boolean[] {true, false}) {
var directory = data.resolve(failAllocation ? "allocation" : "save");
java.nio.file.Files.createDirectories(directory);
if (!failAllocation) java.nio.file.Files.writeString(directory.resolve("characters"), "blocked directory fixture");
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var firstAllocation = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var storage = new CharacterStorage(new YamlCharacterRepository(directory), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
if (firstAllocation.getAndSet(false) && failAllocation) throw new IllegalStateException("allocation unavailable");
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
assertDoesNotThrow(() -> controller.onCraft(event));
assertTrue(event.isCancelled());
if (!failAllocation) { var failed = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run(); }
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
assertEquals(failAllocation ? "prepare" : "save", problem.operation());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
if (!failAllocation) java.nio.file.Files.delete(directory.resolve("characters"));
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var retry = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(retry); retry.run();
assertEquals(records.getLast().id(), new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow());
assertEquals(1, actor.fixture.grid.getMatrix()[0].getAmount());
}
}
}
@Test
void deliveryAndSyncExceptionsAreReportedWithoutReplayDeletionOrLeakingTheReservation() throws Exception {
for (boolean syncFailure : new boolean[] {false, true}) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var records = new ArrayList<CharacterRecord>();
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
if (syncFailure) actor.onSync = () -> { throw new IllegalStateException("sync failed"); };
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
var record = new CharacterFactory().create(ProgressionRules.defaults()); records.add(record); return record;
}, tasks::add, player -> (sheet, current) -> {
if (!syncFailure) throw new IllegalStateException("world callback failed");
return games.dmg.fantasystick.crafting.ManualDropDelivery.Status.COMPLETE;
}, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
assertDoesNotThrow(complete::run);
assertEquals(java.util.List.of(syncFailure ? "sync" : "delivery"),
problems.stream().map(ManualCraftingController.Failure::operation).toList());
assertTrue(storage.find(records.getFirst().id()).join().isPresent());
assertEquals(syncFailure ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
complete.run();
assertEquals(1, problems.size());
actor.onSync = () -> { };
controller.onCraft(actor.fixture.event(ClickType.DROP, InventoryAction.DROP_ONE_SLOT));
assertEquals(2, records.size(), "An exception must release the reservation for a separate new intent");
// close() intentionally does not wait for disk work. Drain this new attempt while the
// store is open, before the next scenario reuses its directory or JUnit removes it.
var retry = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(retry);
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
assertTrue(storage.find(records.getLast().id()).join().isEmpty());
retry.run();
assertEquals(1, problems.size());
}
}
}
@Test
@SuppressWarnings("try")
void preservesOtherRecipesAndCancellationsAndAllocatesNothingForRejectedOrStaleIntents() throws Exception {
var annotation = ManualCraftingController.class.getMethod("onCraft", org.bukkit.event.inventory.CraftItemEvent.class)
.getAnnotation(org.bukkit.event.EventHandler.class);
assertEquals(org.bukkit.event.EventPriority.HIGHEST, annotation.priority());
assertTrue(annotation.ignoreCancelled());
for (int scenario = 0; scenario < 7; scenario++) {
var actor = new Actor(2, 2);
var allocations = new java.util.concurrent.atomic.AtomicInteger();
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
if (scenario == 0) event.setCancelled(true);
if (scenario == 1) event = new org.bukkit.event.inventory.CraftItemEvent(new org.bukkit.inventory.ShapelessRecipe(
new org.bukkit.NamespacedKey("unrelated", "character_sheet"), new org.bukkit.inventory.ItemStack(org.bukkit.Material.PAPER))
.addIngredient(org.bukkit.Material.STICK), actor.fixture.view,
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
if (scenario == 2) actor.fixture.grid.setResult(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
if (scenario == 3) actor.fixture.view.setCursor(new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
if (scenario == 4) actor.online = false;
if (scenario == 5) actor.current = new ManualCraftPlanNativeTest.Fixture(2, 2).view;
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
}, task -> { }, player -> { throw new AssertionError("Not a drop"); }, problem -> { })) {
assertEquals(0, allocations.get());
if (scenario == 6) controller.close();
controller.onCraft(event);
assertEquals(scenario != 1, event.isCancelled());
assertEquals(0, allocations.get(), "Rejected intent " + scenario + " must not mint identities");
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
}
}
}
@Test
void acceptedOpenInvalidatesTheLifetimeButACancelledOpenDoesNotInventATransition() throws Exception {
for (boolean cancelled : new boolean[] {false, true}) {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
player -> { throw new AssertionError("Not a drop"); }, unexpected::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
var open = new org.bukkit.event.inventory.InventoryOpenEvent(actor.fixture.view);
open.setCancelled(cancelled);
controller.onOpen(open);
complete.run();
assertEquals(cancelled ? 1 : 2, actor.fixture.grid.getMatrix()[0].getAmount());
assertEquals(cancelled, storage.find(record.id()).join().isPresent());
assertEquals(!cancelled, actor.fixture.view.getCursor().isEmpty());
}
}
}
@Test
void retainedUnissuedFilesAreReportedOnceWithoutDeletingEditsOrTouchingThePlayerFromTheWorker() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var problems = new LinkedBlockingQueue<ManualCraftingController.Failure>();
var record = new CharacterFactory().create(ProgressionRules.defaults());
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> record, tasks::add,
player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
var file = data.resolve("characters").resolve(record.id() + ".yml");
java.nio.file.Files.writeString(file, "\n", java.nio.file.StandardOpenOption.APPEND);
var changed = java.nio.file.Files.readAllBytes(file);
controller.onClose(new org.bukkit.event.inventory.InventoryCloseEvent(actor.fixture.view));
var problem = problems.poll(5, TimeUnit.SECONDS); assertNotNull(problem);
assertEquals("cleanup-retained", problem.operation());
assertEquals(java.util.List.of(record.id()), problem.characters());
complete.run(); complete.run();
assertArrayEquals(changed, java.nio.file.Files.readAllBytes(file));
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(problems.isEmpty());
}
}
@Test
void aNativePlanningExceptionStillContainsVanillaAndReportsWithoutAllocating() throws Exception {
var actor = new Actor(2, 2);
var tasks = new LinkedBlockingQueue<Runnable>();
var problems = new CopyOnWriteArrayList<ManualCraftingController.Failure>();
var allocations = new java.util.concurrent.atomic.AtomicInteger();
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC());
var controller = new ManualCraftingController(storage, () -> {
allocations.incrementAndGet(); return new CharacterFactory().create(ProgressionRules.defaults());
}, tasks::add, player -> { throw new AssertionError("Not a drop"); }, problems::add)) {
var event = actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL);
actor.fixture.failMatrixRead = true;
assertDoesNotThrow(() -> controller.onCraft(event));
assertTrue(event.isCancelled(), "A planner exception must not allow vanilla to consume the plain preview");
assertEquals(0, allocations.get());
assertTrue(tasks.isEmpty());
assertEquals(java.util.List.of("plan"), problems.stream().map(ManualCraftingController.Failure::operation).toList());
actor.fixture.failMatrixRead = false;
assertEquals(2, actor.fixture.grid.getMatrix()[0].getAmount());
controller.onCraft(actor.fixture.event(ClickType.LEFT, InventoryAction.PICKUP_ALL));
var complete = tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
assertEquals(1, allocations.get());
assertTrue(new CharacterSheets().identify(actor.fixture.view.getCursor()).isPresent());
}
}
static final class Actor {
final ManualCraftPlanNativeTest.Fixture fixture;
final UUID id = UUID.randomUUID();
final Player player;
final Thread serverThread = Thread.currentThread();
InventoryView current;
org.bukkit.Location eyeLocation;
boolean online = true;
int syncs;
Runnable onSync = () -> { };
Actor(int width, int amount) {
fixture = new ManualCraftPlanNativeTest.Fixture(width, amount);
current = fixture.view;
player = Player.class.cast(Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[] {Player.class},
(object, method, args) -> {
assertSame(serverThread, Thread.currentThread(), "No native player access on the persistence worker");
return switch (method.getName()) {
case "getUniqueId" -> id;
case "getOpenInventory" -> current;
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "equals" -> object == args[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Crafting actor fixture";
default -> throw new AssertionError("Unexpected player call " + method.getName());
};
}));
fixture.human = player;
}
}
}
@@ -0,0 +1,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,67 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.CharacterProgress;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.NativeHealthLimit;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.UUID;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.ai.attributes.RangedAttribute;
import org.bukkit.attribute.Attribute;
import org.bukkit.craftbukkit.attribute.CraftAttributeInstance;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeHealthLimitTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void invalidNativeRangesFailExplicitlyRatherThanGuessingOrChangingTheRuntime() {
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
double original = range.getMaxValue();
try {
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY,
Double.NEGATIVE_INFINITY, Double.MAX_VALUE, Double.MIN_VALUE}) {
range.maxValue = invalid;
assertThrows(IllegalStateException.class, NativeHealthLimit::read);
assertEquals(invalid, range.getMaxValue());
}
} finally { range.maxValue = original; }
assertEquals(0, BigDecimal.valueOf(original).compareTo(NativeHealthLimit.read()));
}
@Test
void readsTheActualNativeCeilingRatherThanGuessingTheDefaultAndFeedsCappedProgression() {
var range = (RangedAttribute) Attributes.MAX_HEALTH.value();
double original = range.getMaxValue();
try {
// Isolated non-listening test runtime only. Never write an operator's config file.
for (double ceiling : new double[] {10.5, 1024, 2048}) {
range.maxValue = ceiling;
BigDecimal limit = NativeHealthLimit.read();
assertEquals(0, BigDecimal.valueOf(ceiling).compareTo(limit));
assertEquals(ceiling, range.getMaxValue(), "Reading does not modify the native range");
var progress = new CharacterProgress(UUID.randomUUID(),
new ProgressionRules(BigInteger.valueOf(3000), BigInteger.valueOf(100), 30), limit);
progress.recordHealth(new BigDecimal("6.25"));
var handle = new net.minecraft.world.entity.ai.attributes.AttributeInstance(Attributes.MAX_HEALTH, ignored -> { });
var attribute = new CraftAttributeInstance(handle, Attribute.MAX_HEALTH);
for (int index = 0; index < 2100; index++) {
var before = progress.snapshot();
assertEquals(CharacterProgress.Purchase.APPLIED,
progress.buyStat(before.characterId(), before.revision(), Stat.CON));
var after = progress.snapshot();
attribute.setBaseValue(after.maximumHealth().doubleValue());
assertEquals(after.maximumHealth().doubleValue(), attribute.getValue());
assertEquals(0, new BigDecimal("3.75").compareTo(after.maximumHealth().subtract(after.currentHealth())));
}
assertEquals(BigInteger.valueOf(2100), progress.snapshot().stats().get(Stat.CON));
assertEquals(0, limit.compareTo(progress.snapshot().maximumHealth()));
}
} finally { range.maxValue = original; }
}
}
@@ -0,0 +1,344 @@
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);
}
}
}
@Test
void referenceStacksKeepIdentityAndUseOneDetachedEntityAtNativeCapacity() {
for (int amount : new int[]{1, 3, 64, 2}) {
var fixture = new WorldFixture();
var references = sheet();
if (amount == 2) {
var meta = references.getItemMeta(); meta.setMaxStackSize(2); references.setItemMeta(meta);
}
references.setAmount(amount);
var before = references.clone();
var identity = new CharacterSheets().identify(references).orElseThrow();
var events = new AtomicInteger();
var drops = new PlayerSheetDrops(fixture.player, event -> {
events.incrementAndGet();
assertEquals(before, event.getItemDrop().getItemStack());
assertFalse(event.getItemDrop().isInWorld());
assertEquals(0, fixture.spawnCalls);
});
assertEquals(Status.COMPLETE, drops.dropReferences(references, () -> true));
assertEquals(before, references, "Adapter never debits its caller's source");
assertEquals(before, fixture.stack);
assertEquals(identity, new CharacterSheets().identify(fixture.stack).orElseThrow());
assertEquals(1, events.get()); assertEquals(1, fixture.creates);
assertEquals(1, fixture.spawnCalls); assertEquals(1, fixture.spawnEvents);
assertEquals(40, fixture.pickupDelay); assertEquals(fixture.playerId, fixture.thrower);
}
}
@Test
void referenceStackValidationPrecedesAllCallbacksAndCraftContractStaysSingle() {
var oversized = sheet(); oversized.setAmount(oversized.getMaxStackSize() + 1);
var limited = sheet(); var meta = limited.getItemMeta(); meta.setMaxStackSize(2); limited.setItemMeta(meta); limited.setAmount(3);
var empty = sheet(); empty.setAmount(0);
var negative = sheet(); negative.setAmount(-1);
for (var invalid : java.util.Arrays.asList(null, new ItemStack(org.bukkit.Material.PAPER),
new ItemStack(org.bukkit.Material.DIAMOND), empty, negative, oversized, limited)) {
var fixture = new WorldFixture();
var drops = new PlayerSheetDrops(fixture.player, event -> fail("Invalid references reached the public callback"));
assertThrows(IllegalArgumentException.class, () -> drops.dropReferences(invalid, () -> {
fail("Validate before even consulting context"); return true;
}));
assertEquals(0, fixture.creates); assertEquals(0, fixture.spawnCalls);
}
var fixture = new WorldFixture();
games.dmg.fantasystick.crafting.ManualDropDelivery.DropTarget strict = new PlayerSheetDrops(fixture.player,
event -> fail("Crafting DropTarget must remain exactly one sheet"));
var references = sheet(); references.setAmount(3);
assertThrows(IllegalArgumentException.class, () -> strict.drop(references, () -> {
fail("Reject multiple crafting sheets before any callback"); return true;
}));
assertEquals(0, fixture.creates);
}
@Test
void referenceStacksRetainSingleSheetStalenessAndUncertaintySemantics() {
for (int scenario = 0; scenario < 9; scenario++) {
var fixture = new WorldFixture(); var references = sheet(); references.setAmount(3);
var before = references.clone();
var current = new java.util.concurrent.atomic.AtomicBoolean(scenario != 0);
var events = new AtomicInteger();
int change = scenario;
var drops = new PlayerSheetDrops(fixture.player, event -> {
events.incrementAndGet();
if (change == 1) event.setCancelled(true);
if (change == 2) current.set(false);
if (change == 3) fixture.stack.setAmount(1);
if (change == 4) fixture.removed = true;
if (change == 5) { fixture.inWorld = true; event.setCancelled(true); }
});
if (scenario == 6) fixture.onSpawn = event -> event.setCancelled(true);
if (scenario == 7) fixture.onAdded = () -> fixture.stack.setAmount(1);
if (scenario == 8) fixture.onAdded = () -> fixture.removed = true;
assertEquals(scenario == 0 || scenario == 2 ? Status.STALE : scenario == 1 ? Status.CANCELLED : Status.UNCONFIRMED,
drops.dropReferences(references, current::get), "Scenario " + scenario);
assertEquals(before, references);
assertEquals(scenario == 0 ? 0 : 1, events.get());
assertEquals(scenario >= 6 ? 1 : 0, fixture.spawnCalls);
assertEquals(scenario >= 6 ? 1 : 0, fixture.spawnEvents);
if (scenario != 0) assertEquals(new CharacterSheets().identify(before), new CharacterSheets().identify(fixture.stack));
}
}
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,252 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.function.Consumer;
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.persistence.PersistentDataType;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class SessionControlsNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private final SessionControls controls = new SessionControls();
private final UUID owner = UUID.randomUUID();
private final UUID session = UUID.randomUUID();
@Test
void defaultHotbarHasNineDistinctSingleControlsWithNativeCodecIdentity() {
var items = controls.create(character(Collections.nCopies(7, "")), owner, session);
assertEquals(9, items.length);
for (int slot = 0; slot < 9; slot++) {
var item = items[slot];
assertEquals(slot == 0 ? Material.BOOK : slot == 1 ? Material.STICK : Material.GRAY_DYE, item.getType());
assertEquals(Component.text(slot == 0 ? "Character book" : slot == 1 ? "Basic attack" : "No Ability"),
item.getItemMeta().displayName());
assertEquals(1, item.getAmount());
assertEquals(1, item.getMaxStackSize());
var expected = new SessionControls.Identity(owner, session, slot);
assertEquals(expected, controls.identify(item).orElseThrow());
assertTrue(controls.owned(item, owner, session));
var reloaded = roundTrip(item);
assertEquals(item, reloaded);
assertEquals(expected, controls.identify(reloaded).orElseThrow());
for (int other = 0; other < slot; other++) {
assertNotSame(items[other], item);
assertFalse(items[other].isSimilar(item), "Slot identity must distinguish even empty controls");
}
}
}
@Test
void everyApprovedSavedAbilityUsesItsOwnStableIdAndCataloguePresentation() {
var ids = List.of("fireball", "great_sword", "frost_shard", "longbow", "quickstep", "second_wind",
"stone_skin", "shield_bash", "hex", "inspire", "mending_light", "purify", "guardian_bond",
"rallying_cry", "frost_nova", "chain_lightning", "sanctuary", "war_banner", "meteor", "revive");
var names = List.of("Fireball", "Great Sword", "Frost Shard", "Longbow", "Quickstep", "Second Wind",
"Stone Skin", "Shield Bash", "Hex", "Inspire", "Mending Light", "Purify", "Guardian Bond",
"Rallying Cry", "Frost Nova", "Chain Lightning", "Sanctuary", "War Banner", "Meteor", "Revive");
var materials = List.of(Material.SNOWBALL, Material.WOODEN_SWORD, Material.PRISMARINE_SHARD,
Material.BOW, Material.RABBIT_FOOT, Material.GHAST_TEAR, Material.BRICK, Material.SHIELD,
Material.SPIDER_EYE, Material.AMETHYST_SHARD, Material.GHAST_TEAR, Material.HONEY_BOTTLE,
Material.IRON_CHESTPLATE, Material.GOAT_HORN, Material.SNOWBALL, Material.LIGHTNING_ROD,
Material.SEA_LANTERN, Material.RED_BANNER, Material.FIRE_CHARGE, Material.ECHO_SHARD);
for (int index = 0; index < ids.size(); index++) {
var loadout = new ArrayList<>(Collections.nCopies(7, ""));
int abilitySlot = index % 7;
loadout.set(abilitySlot, ids.get(index));
var items = controls.create(character(loadout), owner, session);
var item = roundTrip(items[abilitySlot + 2]);
assertEquals(materials.get(index), item.getType(), ids.get(index));
assertEquals(Component.text(names.get(index)), item.getItemMeta().displayName());
assertEquals(ids.get(index), item.getItemMeta().getPersistentDataContainer()
.get(key("control_ability_id"), PersistentDataType.STRING));
assertEquals(1, item.getAmount());
assertEquals(1, item.getMaxStackSize());
assertEquals(java.util.Optional.of(new SessionControls.Identity(owner, session, abilitySlot + 2)),
controls.identify(item), ids.get(index));
for (int slot = 2; slot < 9; slot++) {
if (slot != abilitySlot + 2) assertEquals(Material.GRAY_DYE, items[slot].getType());
}
}
}
@Test
void unknownSavedAbilitiesRemainUnavailableWithoutChangingTheRecordOrCallerLoadout() {
var loadout = new ArrayList<>(List.of("fireball", "removed_spell", "", "revive", "", "", ""));
var originalLoadout = List.copyOf(loadout);
var record = character(loadout);
var before = new CharacterRecord(record.id(), record.name(), record.revision(), record.stats(), record.level(),
record.experience(), record.unspentPoints(), record.health(), record.learned(), record.loadout(),
record.cooldowns(), record.dead(), record.deathId(), record.diedAt());
var first = controls.create(record, owner, session);
var second = controls.create(record, owner, session);
var unknown = roundTrip(first[3]);
assertEquals(Material.PAPER, unknown.getType());
assertEquals(Component.text("Unavailable ability: removed_spell"), unknown.getItemMeta().displayName());
var data = unknown.getItemMeta().getPersistentDataContainer();
assertEquals("unavailable", data.get(key("control_kind"), PersistentDataType.STRING));
assertEquals("removed_spell", data.get(key("control_ability_id"), PersistentDataType.STRING));
assertEquals(new SessionControls.Identity(owner, session, 3), controls.identify(unknown).orElseThrow());
assertEquals(1, unknown.getMaxStackSize());
assertNotSame(first, second);
for (int slot = 0; slot < 9; slot++) assertNotSame(first[slot], second[slot]);
first[3].setAmount(2);
assertEquals(1, second[3].getAmount());
assertEquals(before, record, "Factory must not grant stats/abilities or rewrite saved state");
assertEquals(originalLoadout, loadout);
assertEquals(originalLoadout, record.loadout());
assertThrows(NullPointerException.class, () -> controls.create(record, null, session));
assertThrows(NullPointerException.class, () -> controls.create(record, owner, null));
assertThrows(NullPointerException.class, () -> controls.create(null, owner, session));
assertEquals(before, record);
}
@Test
void identitySurvivesRenamingButNeverMatchesAnotherOwnerOrSession() {
var record = character(List.of("fireball", "frost_nova", "second_wind", "mending_light", "revive", "removed_spell", ""));
var items = controls.create(record, owner, session);
var otherOwner = UUID.randomUUID();
var otherSession = UUID.randomUUID();
var transferred = controls.create(record, otherOwner, otherSession);
for (int slot = 0; slot < 9; slot++) {
var meta = items[slot].getItemMeta();
meta.displayName(Component.text("Renamed presentation"));
meta.lore(List.of(Component.text("Not identity")));
items[slot].setItemMeta(meta);
assertEquals(new SessionControls.Identity(owner, session, slot), controls.identify(roundTrip(items[slot])).orElseThrow());
assertTrue(controls.owned(items[slot], owner, session));
assertFalse(controls.owned(items[slot], otherOwner, session));
assertFalse(controls.owned(items[slot], owner, otherSession));
assertFalse(controls.owned(items[slot], null, session));
assertFalse(controls.owned(items[slot], owner, null));
assertEquals(new SessionControls.Identity(otherOwner, otherSession, slot), controls.identify(transferred[slot]).orElseThrow());
assertFalse(controls.owned(transferred[slot], owner, session));
}
// The same material in the same slot still needs distinct stable ability IDs.
var fireball = controls.create(character(Collections.nCopies(7, "fireball")), owner, session)[2];
var nova = controls.create(character(Collections.nCopies(7, "frost_nova")), owner, session)[2];
assertFalse(fireball.isSimilar(nova));
}
@Test
void nameOnlyLookalikesWrongMaterialsQuantitiesAndStackLimitsAreRefused() {
assertRejected(null);
assertRejected(new ItemStack(Material.AIR));
var items = controls.create(character(List.of("fireball", "great_sword", "purify", "guardian_bond", "revive", "removed_spell", "")), owner, session);
for (var item : items) {
var fake = new ItemStack(item.getType());
var meta = fake.getItemMeta();
meta.displayName(item.getItemMeta().displayName());
meta.setMaxStackSize(1);
fake.setItemMeta(meta);
assertRejected(fake);
for (int amount : new int[]{0, -1, 2, 64}) {
var wrongAmount = item.clone();
wrongAmount.setAmount(amount);
assertRejected(wrongAmount);
}
var wrongMaterial = new ItemStack(Material.DIRT);
wrongMaterial.setItemMeta(item.getItemMeta());
assertRejected(wrongMaterial);
}
for (Integer limit : new Integer[]{null, 2}) {
var wrongLimit = items[0].clone();
var meta = wrongLimit.getItemMeta();
meta.setMaxStackSize(limit);
wrongLimit.setItemMeta(meta);
assertRejected(wrongLimit);
}
}
@Test
void malformedFutureNoncanonicalAndInconsistentPdcCannotIdentifyAControl() {
var items = controls.create(character(List.of("fireball", "removed_spell", "", "", "", "", "")), owner, session);
var invalid = new LinkedHashMap<String, ItemStack>();
for (String field : List.of("control_version", "control_owner_id", "control_session_id", "control_slot", "control_kind", "control_ability_id")) {
invalid.put("missing " + field, tamper(items[2], data -> data.remove(key(field))));
invalid.put("wrong type " + field, tamper(items[2], data -> data.set(key(field), PersistentDataType.BYTE_ARRAY, new byte[]{1})));
}
for (int version : new int[]{-1, 0, 2, Integer.MAX_VALUE}) {
invalid.put("version " + version, tamper(items[2], data -> data.set(key("control_version"), PersistentDataType.INTEGER, version)));
}
for (String field : List.of("control_owner_id", "control_session_id")) {
for (String value : List.of("", "not-a-uuid", "1-1-1-1-1", " " + owner,
"abcdefab-cdef-abcd-efab-cdefabcdefab".toUpperCase(Locale.ROOT), owner + "\n")) {
invalid.put(field + ": " + value, tamper(items[2], data -> data.set(key(field), PersistentDataType.STRING, value)));
}
}
for (int slot : new int[]{-1, 0, 1, 9, Integer.MAX_VALUE}) {
invalid.put("ability slot " + slot, tamper(items[2], data -> data.set(key("control_slot"), PersistentDataType.INTEGER, slot)));
}
for (String kind : List.of("", "book", "basic_attack", "empty", "unavailable", "future")) {
invalid.put("ability kind " + kind, tamper(items[2], data -> data.set(key("control_kind"), PersistentDataType.STRING, kind)));
}
for (String ability : List.of("", " ", "FIREBALL", "removed_spell", "revive")) {
invalid.put("ability ID " + ability, tamper(items[2], data -> data.set(key("control_ability_id"), PersistentDataType.STRING, ability)));
}
invalid.put("book in basic slot", tamper(items[0], data -> data.set(key("control_slot"), PersistentDataType.INTEGER, 1)));
invalid.put("basic in book slot", tamper(items[1], data -> data.set(key("control_slot"), PersistentDataType.INTEGER, 0)));
invalid.put("book ability tag", tamper(items[0], data -> data.set(key("control_ability_id"), PersistentDataType.STRING, "fireball")));
invalid.put("empty ability tag", tamper(items[4], data -> data.set(key("control_ability_id"), PersistentDataType.STRING, "")));
invalid.put("unavailable known ability", tamper(items[3], data -> data.set(key("control_ability_id"), PersistentDataType.STRING, "fireball")));
invalid.put("unavailable blank ability", tamper(items[3], data -> data.set(key("control_ability_id"), PersistentDataType.STRING, " ")));
assertAll(invalid.entrySet().stream().map(entry -> () -> {
assertTrue(controls.identify(entry.getValue()).isEmpty(), entry.getKey());
assertFalse(controls.owned(entry.getValue(), owner, session), entry.getKey());
}));
}
private void assertRejected(ItemStack item) {
assertTrue(controls.identify(item).isEmpty());
assertFalse(controls.owned(item, owner, session));
}
private static ItemStack tamper(ItemStack original, Consumer<PersistentDataContainer> mutation) {
var copy = original.clone();
var meta = copy.getItemMeta();
mutation.accept(meta.getPersistentDataContainer());
copy.setItemMeta(meta);
return copy;
}
private static NamespacedKey key(String name) { return new NamespacedKey("fantasystick", name); }
private static CharacterRecord character(List<String> loadout) {
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,
loadout.stream().filter(id -> !id.isEmpty()).collect(Collectors.toSet()), loadout,
Map.of(), false, null, null);
}
private static ItemStack roundTrip(ItemStack item) {
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(item)).getOrThrow();
return CraftItemStack.asBukkitCopy(net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow());
}
}
@@ -0,0 +1,18 @@
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); }
public void register(org.bukkit.event.Listener listener, org.bukkit.plugin.Plugin plugin) {
manager.registerEvents(listener, plugin);
}
}
@@ -27,8 +27,11 @@ class DistributionTest {
assertFalse(metadata.getVersion().contains("${"));
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
assertTrue(metadata.getCommands().isEmpty(), "Admin commands are not yet approved");
assertTrue(metadata.getPermissions().isEmpty());
assertEquals(java.util.Set.of("fantasystick"), metadata.getCommands().keySet());
assertEquals("fantasystick.admin", metadata.getCommands().get("fantasystick").get("permission"));
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -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,140 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Clock;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CharacterRenameTest {
@TempDir Path data;
@Test
void renamesAnExistingLiveCharacterWithoutChangingAnyProgressAndDoesNotRewriteTheSameName() throws Exception {
var original = live();
var repository = new YamlCharacterRepository(data);
repository.create(original);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var result = storage.renameCharacter(original.id(), "Mira of Dawn — 雪").join().orElseThrow();
var expected = YamlCharacterRepositoryTest.rename(original, "Mira of Dawn — 雪", BigInteger.ONE);
assertEquals(expected, result);
assertEquals(expected, storage.find(original.id()).join().orElseThrow());
assertEquals(expected, storage.renameCharacter(original.id(), expected.name()).join().orElseThrow());
}
assertEquals("Mira of Dawn — 雪", new YamlCharacterRepository(data).load(original.id()).orElseThrow().name());
}
@Test
void missingAndDeadReferencesCannotCreateRenameOrRestoreCharacters() throws Exception {
var dead = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
var repository = new YamlCharacterRepository(data);
repository.create(dead);
var file = data.resolve("characters").resolve(dead.id() + ".yml");
var bytes = java.nio.file.Files.readAllBytes(file);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var missing = UUID.randomUUID();
assertTrue(storage.renameCharacter(missing, "Counterfeit").join().isEmpty());
assertTrue(storage.find(missing).join().isEmpty());
assertTrue(storage.renameCharacter(dead.id(), "Not a revival").join().isEmpty());
assertTrue(storage.renameCharacter(dead.id(), dead.name()).join().isEmpty());
assertEquals(dead, storage.find(dead.id()).join().orElseThrow());
}
assertArrayEquals(bytes, java.nio.file.Files.readAllBytes(file));
}
@Test
void renameRunsOnTheWorkerAndAcknowledgesOnlyAfterTheReplacement() throws Exception {
var original = live();
var repository = new YamlCharacterRepository(data);
repository.create(original);
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var caller = Thread.currentThread();
var gated = new YamlCharacterRepository(data, (source, target) -> {
assertNotSame(caller, Thread.currentThread());
entered.countDown();
try {
if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("Test gate timed out");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt(); throw new java.io.IOException(exception);
}
java.nio.file.Files.move(source, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
});
try (var storage = new CharacterStorage(gated, Clock.systemUTC())) {
var pending = storage.renameCharacter(original.id(), "After acknowledgment");
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertFalse(pending.isDone());
assertEquals(original, repository.load(original.id()).orElseThrow());
} finally { release.countDown(); }
assertEquals("After acknowledgment", pending.join().orElseThrow().name());
}
}
@Test
void failedReplacementLeavesTheOriginalRecordAndCanBeRetriedWithoutRecreatingIt() throws Exception {
var original = live();
var repository = new YamlCharacterRepository(data);
repository.create(original);
var broken = new YamlCharacterRepository(data, (source, target) -> { throw new java.io.IOException("Simulated rename failure"); });
try (var storage = new CharacterStorage(broken, Clock.systemUTC())) {
assertThrows(java.util.concurrent.CompletionException.class, () -> storage.renameCharacter(original.id(), "Uncommitted").join());
assertEquals(original, repository.load(original.id()).orElseThrow());
}
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertEquals(YamlCharacterRepositoryTest.rename(original, "Saved later", BigInteger.ONE),
storage.renameCharacter(original.id(), "Saved later").join().orElseThrow());
}
}
@Test
void orderedRenamesUseCurrentProgressAndADeathPreventsLaterStaleSheetRenames() throws Exception {
var original = live();
var repository = new YamlCharacterRepository(data);
repository.create(original);
var current = new CharacterRecord(original.id(), original.name(), BigInteger.ONE, original.stats(), original.level(),
original.experience(), original.unspentPoints(), new BigDecimal("7.25"), original.learned(), original.loadout(),
original.cooldowns(), false, original.deathId(), original.diedAt());
repository.replace(current, original.revision());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var first = storage.renameCharacter(original.id(), "First");
var second = storage.renameCharacter(original.id(), "Second");
var death = storage.recordDeath(original.id(), BigInteger.valueOf(3));
var stale = storage.renameCharacter(original.id(), "Stale copy");
assertEquals(YamlCharacterRepositoryTest.rename(current, "First", BigInteger.TWO), first.join().orElseThrow());
assertEquals(YamlCharacterRepositoryTest.rename(current, "Second", BigInteger.valueOf(3)), second.join().orElseThrow());
assertTrue(death.join().orElseThrow().dead());
assertEquals("Second", death.join().orElseThrow().name());
assertTrue(stale.join().isEmpty());
assertEquals(death.join(), storage.find(original.id()).join());
}
}
@Test
void shutdownDrainsAnAcceptedRenameButRejectsNewRenameWork() throws Exception {
var original = live();
var repository = new YamlCharacterRepository(data);
repository.create(original);
var storage = new CharacterStorage(repository, Clock.systemUTC());
try {
var accepted = storage.renameCharacter(original.id(), "Accepted before disable");
storage.close();
assertEquals("Accepted before disable", accepted.join().orElseThrow().name());
assertThrows(java.util.concurrent.CompletionException.class,
() -> storage.renameCharacter(original.id(), "After disable").join());
assertEquals("Accepted before disable", repository.load(original.id()).orElseThrow().name());
} finally { storage.close(); }
}
private static CharacterRecord live() {
var original = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
return new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(), original.level(),
original.experience(), original.unspentPoints(), new BigDecimal("12.25"), original.learned(), original.loadout(),
original.cooldowns(), false, original.deathId(), original.diedAt());
}
}
@@ -129,6 +129,96 @@ class CharacterStorageTest {
}
}
@Test
void latestDeathPreservesQueuedRenameAndProgressAndAcknowledgesOnlyOneDeath() 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);
try (var storage = new CharacterStorage(new YamlCharacterRepository(data), Clock.fixed(now, ZoneOffset.UTC))) {
storage.create(live).join();
var rename = storage.renameCharacter(live.id(), "Newest anvil name");
// This is one worker operation, not a caller-side find -> expected-revision write.
var death = storage.recordDeath(live.id());
var duplicate = storage.recordDeath(live.id());
var latest = rename.join().orElseThrow();
var dead = death.join().orElseThrow();
assertEquals(latest.name(), dead.name());
assertEquals(latest.revision().add(BigInteger.ONE), dead.revision());
assertEquals(latest.stats(), dead.stats()); assertEquals(latest.cooldowns(), dead.cooldowns());
assertEquals(latest.experience(), dead.experience()); assertEquals(latest.loadout(), dead.loadout());
assertEquals(latest.level(), dead.level()); assertEquals(latest.unspentPoints(), dead.unspentPoints());
assertEquals(latest.learned(), dead.learned()); assertEquals(java.math.BigDecimal.ZERO, dead.health());
assertEquals(now, dead.diedAt()); assertNotNull(dead.deathId()); assertTrue(dead.dead());
assertTrue(duplicate.join().isEmpty());
assertEquals(dead, storage.find(live.id()).join().orElseThrow());
}
}
@Test
void deathCapturesRequestTimeAndDoesNotAcknowledgeBeforeAtomicReplacement() 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 requestedAt = template.diedAt().plusSeconds(10);
var time = new java.util.concurrent.atomic.AtomicReference<>(requestedAt);
var clock = new Clock() {
@Override public java.time.ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return time.get(); }
};
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var writes = new java.util.concurrent.atomic.AtomicInteger(); var caller = Thread.currentThread();
var repository = new YamlCharacterRepository(data, (source, target) -> {
assertNotSame(caller, Thread.currentThread());
if (writes.incrementAndGet() == 1) {
entered.countDown();
try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Gate timeout"); }
catch (InterruptedException e) { throw new IOException(e); }
}
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
});
try (var storage = new CharacterStorage(repository, clock)) {
var create = storage.create(live);
java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>> death;
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
death = storage.recordDeath(live.id());
time.set(requestedAt.plusSeconds(90));
assertFalse(death.isDone());
} finally { release.countDown(); }
create.get(5, TimeUnit.SECONDS);
var dead = death.get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(requestedAt, dead.diedAt()); assertEquals(dead, repository.load(live.id()).orElseThrow());
assertEquals(2, writes.get());
}
}
@Test
void deathWriteFailureOrLostAcknowledgmentNeverPermitsDuplicateDeathOfAnAlreadyDeadRecord() throws Exception {
for (boolean moved : new boolean[]{false, true}) {
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 repository = new YamlCharacterRepository(data); repository.create(live);
var broken = new YamlCharacterRepository(data, (source, target) -> {
if (moved) Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
throw new IOException("Unacknowledged death");
});
try (var storage = new CharacterStorage(broken, Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> storage.recordDeath(live.id()).join());
var current = repository.load(live.id()).orElseThrow(); assertEquals(moved, current.dead());
if (moved) {
assertTrue(storage.recordDeath(live.id()).join().isEmpty());
assertEquals(current, repository.load(live.id()).orElseThrow());
} else assertEquals(live, current);
}
}
}
@Test
void refusesAnOldOrUnknownDeathBookWithoutChangingTheSavedCharacter() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -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,577 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EquipmentSessionStoreTest {
@TempDir Path data;
// Synthetic clock, not a document/event timestamp.
private static final Instant NOW = Instant.parse("2000-01-01T00:00:00.123456789Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private final UUID player = UUID.randomUUID();
private final UUID session = UUID.randomUUID();
private final UUID character = UUID.randomUUID();
private static <T> T await(CompletionStage<T> stage) throws Exception {
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private Path record() { return data.resolve("sessions").resolve(player + ".yml"); }
private static Throwable failure(CompletionStage<?> stage) {
return assertThrows(ExecutionException.class,
() -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS)).getCause();
}
private static void replace(Path source, Path target) throws IOException {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
private static void waitFor(CountDownLatch latch) throws IOException {
try {
if (!latch.await(5, TimeUnit.SECONDS)) throw new IOException("Test gate timed out");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Test gate interrupted");
}
}
@Test
void completeExitAtomicallyClearsMatchingReceiptAndDrainsAcrossShutdown() throws Exception {
var store = new EquipmentSessionStore(data, CLOCK);
await(store.prepare(player, session, character));
var completed = store.completeExit(player, session, Duration.ofSeconds(900));
store.close();
var saved = await(completed);
assertNull(saved.pending(), "Accepted exit must include cleanup, not queue it after shutdown");
assertEquals(BigInteger.ONE, saved.revision(), "One atomic write");
assertEquals(NOW.plusSeconds(900), saved.lastExit().deadline());
try (var reopened = new EquipmentSessionStore(data, Clock.offset(CLOCK, Duration.ofHours(1)))) {
assertEquals(saved, await(reopened.completeExit(player, session, Duration.ofDays(1))));
UUID next = UUID.randomUUID();
var pending = await(reopened.prepare(player, next, character));
assertEquals(pending, await(reopened.completeExit(player, session, Duration.ZERO)));
var exited = await(reopened.recordExit(player, next, Duration.ofSeconds(30)));
var restored = await(reopened.completeExit(player, next, Duration.ofDays(2)));
assertNull(restored.pending());
assertEquals(exited.lastExit(), restored.lastExit());
assertInstanceOf(IOException.class, failure(reopened.completeExit(player, UUID.randomUUID(), Duration.ZERO)));
}
}
@Test
void atomicExitFailureKeepsReceiptAndExistingDeadlineUntilSingleWriteRetry() throws Exception {
EquipmentSessionStore.State exited;
try (var store = new EquipmentSessionStore(data, CLOCK)) {
await(store.prepare(player, session, character));
exited = await(store.recordExit(player, session, Duration.ofSeconds(30)));
}
byte[] before = Files.readAllBytes(record());
try (var failing = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
throw new IOException("Injected exit replacement failure");
})) {
assertInstanceOf(IOException.class, failure(failing.completeExit(player, session, Duration.ofDays(1))));
assertArrayEquals(before, Files.readAllBytes(record()));
assertEquals(exited, await(failing.find(player)).orElseThrow());
}
try (var reopened = new EquipmentSessionStore(data, Clock.offset(CLOCK, Duration.ofDays(1)))) {
for (Duration invalid : List.of(Duration.ofNanos(-1), Duration.ofSeconds(Long.MAX_VALUE))) {
failure(reopened.completeExit(player, session, invalid));
assertArrayEquals(before, Files.readAllBytes(record()));
}
var result = await(reopened.completeExit(player, session, Duration.ZERO));
assertNull(result.pending());
assertEquals(exited.lastExit(), result.lastExit());
assertEquals(exited.revision().add(BigInteger.ONE), result.revision());
}
}
@Test
void acknowledgmentsFollowWorkerCommitAndObserversCannotCancelOwnedWork() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
Thread caller = Thread.currentThread();
var diskThread = new AtomicReference<Thread>();
try (var store = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
diskThread.set(Thread.currentThread());
entered.countDown();
waitFor(release);
replace(source, target);
})) {
var stage = store.prepare(player, session, character);
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertNotSame(caller, diskThread.get());
assertFalse(Files.exists(record()));
assertFalse(stage.toCompletableFuture().isDone());
assertTrue(stage.toCompletableFuture().cancel(true));
assertTrue(stage.toCompletableFuture().complete(null));
assertFalse(stage.toCompletableFuture().isDone(), "Observations cannot complete the owned acknowledgment");
} finally {
release.countDown();
}
var saved = await(stage);
assertNotNull(saved.pending());
assertEquals(saved, await(store.find(player)).orElseThrow());
}
}
@Test
void closeIsNonblockingDrainsAcceptedWorkAndExitTimeIsCapturedBeforeTheQueueRuns() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
var time = new AtomicReference<>(NOW);
Clock clock = new Clock() {
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return time.get(); }
};
var store = new EquipmentSessionStore(data, clock, (source, target) -> {
entered.countDown();
waitFor(release);
replace(source, target);
});
var prepared = store.prepare(player, session, character);
CompletionStage<EquipmentSessionStore.State> exited = null;
CompletionStage<EquipmentSessionStore.State> cleared = null;
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
time.set(NOW.plusSeconds(2));
exited = store.recordExit(player, session, Duration.ofSeconds(30));
cleared = store.clearReceipt(player, session);
time.set(NOW.plusSeconds(600));
assertTimeoutPreemptively(Duration.ofSeconds(1), store::close);
assertInstanceOf(RejectedExecutionException.class, failure(store.find(player)));
assertInstanceOf(RejectedExecutionException.class, failure(store.prepare(player, UUID.randomUUID(), character)));
assertInstanceOf(RejectedExecutionException.class, failure(store.recordExit(player, session, Duration.ZERO)));
assertInstanceOf(RejectedExecutionException.class, failure(store.clearReceipt(player, session)));
} finally {
release.countDown();
store.close();
await(prepared);
if (exited != null) await(exited);
if (cleared != null) await(cleared);
}
var exit = await(exited);
assertEquals(NOW.plusSeconds(2), exit.lastExit().exitedAt());
assertEquals(NOW.plusSeconds(32), exit.lastExit().deadline());
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
assertEquals(await(cleared), await(reopened.find(player)).orElseThrow());
assertNull(await(cleared).pending());
}
}
@Test
void failedForcedTemporaryReplacementPreservesOriginalAndRetryIsPossible() throws Exception {
EquipmentSessionStore.State original;
try (var store = new EquipmentSessionStore(data, CLOCK)) {
original = await(store.prepare(player, session, character));
}
byte[] before = Files.readAllBytes(record());
try (var failing = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
assertTrue(Files.isRegularFile(source));
assertTrue(Files.readString(source).contains("schema-version: 1"));
assertArrayEquals(before, Files.readAllBytes(target));
throw new IOException("PRIVATE injected filesystem detail");
})) {
Throwable error = failure(failing.recordExit(player, session, Duration.ofSeconds(900)));
assertInstanceOf(IOException.class, error);
assertFalse(error.toString().contains("PRIVATE"));
assertNull(error.getCause());
assertArrayEquals(before, Files.readAllBytes(record()));
assertEquals(original, await(failing.find(player)).orElseThrow());
assertInstanceOf(IOException.class, failure(failing.clearReceipt(player, session)));
assertArrayEquals(before, Files.readAllBytes(record()));
}
try (var paths = Files.list(data.resolve("sessions"))) {
assertFalse(paths.anyMatch(path -> path.toString().endsWith(".tmp")));
}
try (var retry = new EquipmentSessionStore(data, CLOCK)) {
assertEquals(BigInteger.ONE, await(retry.recordExit(player, session, Duration.ZERO)).revision());
}
}
@Test
void failedPreparationDoesNotCreateAReceiptAndConstructorDoesNotTouchInvalidDirectory() throws Exception {
try (var failing = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
throw new IOException("Refused atomic replacement");
})) {
assertInstanceOf(IOException.class, failure(failing.prepare(player, session, character)));
assertFalse(Files.exists(record()));
assertTrue(await(failing.find(player)).isEmpty());
}
Path blocked = data.resolve("not-a-directory");
Files.writeString(blocked, "untouched");
try (var store = new EquipmentSessionStore(blocked, CLOCK)) {
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertEquals("untouched", Files.readString(blocked));
}
}
@Test
void oneFailFastLockCoordinatesInstancesWithoutLeakingPerSessionLockFiles() throws Exception {
try (var store = new EquipmentSessionStore(data, CLOCK)) {
var original = await(store.prepare(player, session, character));
byte[] before = Files.readAllBytes(record());
try (var channel = FileChannel.open(data.resolve("sessions/.sessions.lock"), StandardOpenOption.WRITE);
var lock = channel.lock();
var other = new EquipmentSessionStore(data, CLOCK)) {
assertTrue(lock.isValid());
assertInstanceOf(IOException.class, failure(other.prepare(player, UUID.randomUUID(), character)));
assertInstanceOf(IOException.class, failure(other.recordExit(player, session, Duration.ZERO)));
assertInstanceOf(IOException.class, failure(other.clearReceipt(player, session)));
assertInstanceOf(IOException.class, failure(other.find(player)));
assertArrayEquals(before, Files.readAllBytes(record()));
}
assertEquals(original, await(store.find(player)).orElseThrow());
for (int index = 0; index < 48; index++) {
UUID owner = UUID.randomUUID();
UUID attempt = UUID.randomUUID();
await(store.prepare(owner, attempt, character));
await(store.clearReceipt(owner, attempt));
}
try (var paths = Files.list(data.resolve("sessions"))) {
var files = paths.toList();
assertEquals(1L, files.stream().filter(path -> path.toString().endsWith(".lock")).count());
assertFalse(files.stream().anyMatch(path -> path.toString().endsWith(".tmp")));
}
}
}
@Test
void competingInstancesCannotReplaceAnAcknowledgedPendingSession() throws Exception {
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
try (var first = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
entered.countDown();
waitFor(release);
replace(source, target);
}); var second = new EquipmentSessionStore(data, CLOCK)) {
var preparation = first.prepare(player, session, character);
UUID competing = UUID.randomUUID();
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertInstanceOf(IOException.class, failure(second.prepare(player, competing, character)));
} finally {
release.countDown();
}
var saved = await(preparation);
assertInstanceOf(IOException.class, failure(second.prepare(player, competing, character)));
assertEquals(saved, await(second.prepare(player, session, character)));
assertEquals(saved, await(second.find(player)).orElseThrow());
}
}
@Test
void symlinkedRecordsAndLockFilesAreNeverFollowedOrReplaced() throws Exception {
try (var store = new EquipmentSessionStore(data, CLOCK)) {
await(store.prepare(player, session, character));
Path outside = data.resolve("outside.yml");
Files.move(record(), outside);
byte[] bytes = Files.readAllBytes(outside);
Files.createSymbolicLink(record(), outside);
assertInstanceOf(IOException.class, failure(store.find(player)));
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertInstanceOf(IOException.class, failure(store.recordExit(player, session, Duration.ZERO)));
assertInstanceOf(IOException.class, failure(store.clearReceipt(player, session)));
assertTrue(Files.isSymbolicLink(record()));
assertArrayEquals(bytes, Files.readAllBytes(outside));
Files.delete(record());
Files.move(outside, record());
Path lock = data.resolve("sessions/.sessions.lock");
Files.delete(lock); // Fixture only; no work is running or holding this lock.
Files.writeString(outside, "protected lock target");
Files.createSymbolicLink(lock, outside);
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertInstanceOf(IOException.class, failure(store.recordExit(player, session, Duration.ZERO)));
assertInstanceOf(IOException.class, failure(store.clearReceipt(player, session)));
assertInstanceOf(IOException.class, failure(store.find(player)));
assertEquals("protected lock target", Files.readString(outside));
assertArrayEquals(bytes, Files.readAllBytes(record()));
assertTrue(Files.isSymbolicLink(lock));
}
}
@Test
void symlinkedSessionDirectoryAndNonregularRecordAreRefused() throws Exception {
Path outside = Files.createDirectory(data.resolve("outside"));
Files.createSymbolicLink(data.resolve("sessions"), outside);
try (var store = new EquipmentSessionStore(data, CLOCK)) {
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
try (var paths = Files.list(outside)) { assertEquals(0, paths.count()); }
Files.delete(data.resolve("sessions"));
Files.createDirectories(record());
assertInstanceOf(IOException.class, failure(store.find(player)));
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertTrue(Files.isDirectory(record()));
}
}
private String validYaml() {
return """
schema-version: 1
player-id: %s
revision: '9'
pending:
session-id: %s
character-id: %s
prepared-at: '%s'
last-exit:
session-id: %s
character-id: %s
exited-at: '%s'
deadline: '%s'
""".formatted(player, session, character, NOW, session, character, NOW.plusSeconds(1), NOW.plusSeconds(901));
}
@Test
void malformedUnsafeAndInconsistentYamlIsPreservedWithSanitizedFailures() throws Exception {
Files.createDirectories(data.resolve("sessions"));
String valid = validYaml();
try (var store = new EquipmentSessionStore(data, CLOCK)) {
Files.writeString(record(), valid);
assertEquals(BigInteger.valueOf(9), await(store.find(player)).orElseThrow().revision());
for (String invalid : List.of(
valid.replace("schema-version: 1", "schema-version: 2"),
valid + "schema-version: 1\n",
valid.replace("revision: '9'", "revision: '-1'"),
valid.replace("revision: '9'", "revision: '01'"),
valid.replace("revision: '9'", "revision: 1.5"),
valid.replace(player.toString(), UUID.randomUUID().toString()),
valid.replace(session.toString(), "1-1-1-1-1"),
valid.replace(" prepared-at: '" + NOW + "'\n", ""),
valid.replace(" character-id: " + character + "\n", ""),
valid.replace("last-exit:\n session-id: " + session + "\n character-id: " + character,
"last-exit:\n session-id: " + session + "\n character-id: " + UUID.randomUUID()),
valid.replace(NOW.plusSeconds(901).toString(), NOW.toString()),
valid.replace(NOW.toString(), "2000-02-30T00:00:00Z"),
valid.replace(NOW.toString(), "2000-01-01T00:00:00+00:00"),
valid.replace(NOW.toString(), "2000-01-01T24:00:00Z"),
valid.replace(NOW.toString(), "2000-01-01T00:00:60Z"),
valid + "unknown-recovery-authority: true\n",
"pending: &receipt [*receipt]\n",
"!!java.net.URL ['https://PRIVATE.invalid/']",
"PRIVATE: [unterminated", "", valid + "---\n" + valid)) {
Files.writeString(record(), invalid);
for (CompletionStage<?> attempt : List.of(store.find(player), store.prepare(player, session, character),
store.recordExit(player, session, Duration.ZERO), store.clearReceipt(player, session))) {
Throwable error = failure(attempt);
assertInstanceOf(IOException.class, error);
assertFalse(error.toString().contains("PRIVATE"));
assertNull(error.getCause(), "No parser snippets through nested exceptions");
}
assertEquals(invalid, Files.readString(record()));
}
}
}
@Test
void boundsUtf8ReadsAndKeepsArbitraryPrecisionRevisionsExact() throws Exception {
Files.createDirectories(data.resolve("sessions"));
try (var store = new EquipmentSessionStore(data, CLOCK)) {
for (byte[] invalid : List.of(new byte[] {(byte) 0xc3, 0x28}, "x".repeat(16_385).getBytes(java.nio.charset.StandardCharsets.UTF_8))) {
Files.write(record(), invalid);
assertInstanceOf(IOException.class, failure(store.find(player)));
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertArrayEquals(invalid, Files.readAllBytes(record()));
}
BigInteger revision = BigInteger.TEN.pow(100);
Files.writeString(record(), validYaml().replace("revision: '9'", "revision: '" + revision + "'"));
assertEquals(revision, await(store.find(player)).orElseThrow().revision());
assertEquals(revision.add(BigInteger.ONE), await(store.clearReceipt(player, session)).revision());
}
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
assertEquals(BigInteger.TEN.pow(100).add(BigInteger.ONE), await(reopened.find(player)).orElseThrow().revision());
}
}
@Test
void boundedWritesCannotReplaceALoadableRecordWithAnOversizedOne() throws Exception {
Files.createDirectories(data.resolve("sessions"));
String original = "schema-version: 1\nplayer-id: " + player + "\nrevision: '" + "9".repeat(16_200) + "'\n";
Files.writeString(record(), original);
try (var store = new EquipmentSessionStore(data, CLOCK)) {
assertTrue(await(store.find(player)).isPresent(), "Input fits the bounded format");
assertInstanceOf(IOException.class, failure(store.prepare(player, session, character)));
assertEquals(original, Files.readString(record()));
assertNull(await(store.find(player)).orElseThrow().pending());
}
}
@Test
void anUnacknowledgedPostMoveFailureRetainsItsAmbiguousReceiptAndNeverReplaysOrDeletesIt() throws Exception {
try (var uncertain = new EquipmentSessionStore(data, CLOCK, (source, target) -> {
replace(source, target);
throw new IOException("Failure after replacement; result uncertain to caller");
})) {
assertInstanceOf(IOException.class, failure(uncertain.prepare(player, session, character)));
}
byte[] before = Files.readAllBytes(record());
try (var reopened = new EquipmentSessionStore(data, Clock.offset(CLOCK, Duration.ofDays(1)))) {
var retained = await(reopened.find(player)).orElseThrow();
assertEquals(new EquipmentSessionStore.Receipt(session, character, NOW), retained.pending());
assertEquals(BigInteger.ZERO, retained.revision());
assertInstanceOf(IOException.class, failure(reopened.prepare(player, UUID.randomUUID(), character)));
assertEquals(retained, await(reopened.prepare(player, session, character)));
assertArrayEquals(before, Files.readAllBytes(record()));
}
}
@Test
void abortedPreparationCanBeClearedWithoutInventingAnExitAndDoesNotTouchCharacterFiles() throws Exception {
Path characterFile = data.resolve("characters").resolve(character + ".yml");
Files.createDirectories(characterFile.getParent());
Files.writeString(characterFile, "existing character data is not this store's schema");
try (var store = new EquipmentSessionStore(data, CLOCK)) {
await(store.prepare(player, session, character));
var cleared = await(store.clearReceipt(player, session)); // Test caller supplies never-packed proof.
assertNull(cleared.pending());
assertNull(cleared.lastExit());
assertEquals(BigInteger.ONE, cleared.revision());
assertEquals(cleared, await(store.clearReceipt(player, session)));
assertInstanceOf(IOException.class, failure(store.recordExit(player, session, Duration.ZERO)));
assertNotNull(await(store.prepare(player, UUID.randomUUID(), character)).pending());
}
assertEquals("existing character data is not this store's schema", Files.readString(characterFile));
}
@Test
void publicRecordsValidateMembersAndPairedSessionIdentity() {
assertThrows(NullPointerException.class, () -> new EquipmentSessionStore.Receipt(session, null, NOW));
assertThrows(NullPointerException.class, () -> new EquipmentSessionStore.Exit(session, character, null, NOW));
assertThrows(IllegalArgumentException.class, () -> new EquipmentSessionStore.Exit(session, character, NOW, NOW.minusNanos(1)));
assertThrows(IllegalArgumentException.class, () -> new EquipmentSessionStore.Receipt(session, character, Instant.MAX));
assertThrows(IllegalArgumentException.class, () -> new EquipmentSessionStore.State(player, BigInteger.valueOf(-1), null, null));
var receipt = new EquipmentSessionStore.Receipt(session, character, NOW);
var wrongCharacter = new EquipmentSessionStore.Exit(session, UUID.randomUUID(), NOW, NOW);
assertThrows(IllegalArgumentException.class, () -> new EquipmentSessionStore.State(player, BigInteger.ZERO, receipt, wrongCharacter));
}
@Test
void switchChecksUseSavedDeadlineNotNewConfigurationAndZeroOnlyBypassesTheDelay() throws Exception {
EquipmentSessionStore.State exited;
UUID other = UUID.randomUUID();
try (var store = new EquipmentSessionStore(data, CLOCK)) {
var prepared = await(store.prepare(player, session, character));
assertTrue(prepared.canEnter(other, Duration.ofSeconds(900), NOW));
exited = await(store.recordExit(player, session, Duration.ofSeconds(900)));
assertTrue(exited.canEnter(character, Duration.ofSeconds(900), NOW));
assertFalse(exited.canEnter(other, Duration.ofSeconds(1), NOW.plusSeconds(899)));
assertFalse(exited.canEnter(other, Duration.ofDays(7), NOW.plusSeconds(899)));
assertTrue(exited.canEnter(other, Duration.ofDays(7), NOW.plusSeconds(900)));
assertTrue(exited.canEnter(other, Duration.ZERO, NOW));
assertFalse(exited.canEnter(other, Duration.ofSeconds(900), NOW), "Zero bypass does not erase saved deadline");
assertThrows(IllegalArgumentException.class, () -> exited.canEnter(other, Duration.ofSeconds(-1), NOW));
assertEquals(exited, await(store.find(player)).orElseThrow());
}
try (var reopened = new EquipmentSessionStore(data, Clock.offset(CLOCK, Duration.ofDays(1)))) {
assertTrue(await(reopened.find(player)).orElseThrow().canEnter(other, Duration.ofDays(7), NOW.plusSeconds(86400)));
}
}
@Test
void invalidDelaysFailUnchangedEvenForDuplicateExitsAndMissingSessionsAreNotInvented() throws Exception {
try (var store = new EquipmentSessionStore(data, CLOCK)) {
assertInstanceOf(IOException.class, failure(store.recordExit(player, session, Duration.ZERO)));
assertInstanceOf(IOException.class, failure(store.clearReceipt(player, session)));
assertTrue(await(store.find(player)).isEmpty());
var prepared = await(store.prepare(player, session, character));
for (Duration invalid : java.util.List.of(Duration.ofNanos(-1), Duration.ofSeconds(Long.MAX_VALUE),
Duration.ofDays(4_000_000))) {
failure(store.recordExit(player, session, invalid));
assertEquals(prepared, await(store.find(player)).orElseThrow());
}
var exited = await(store.recordExit(player, session, Duration.ZERO));
byte[] before = Files.readAllBytes(record());
for (Duration invalid : java.util.List.of(Duration.ofNanos(-1), Duration.ofSeconds(Long.MAX_VALUE))) {
failure(store.recordExit(player, session, invalid));
assertArrayEquals(before, Files.readAllBytes(record()));
assertEquals(exited, await(store.find(player)).orElseThrow());
}
assertEquals(NOW, exited.lastExit().deadline());
}
}
@Test
void exitsRetainReceiptsUntilProvenRestorationAndDuplicatesNeverExtendDeadlines() throws Exception {
EquipmentSessionStore.State cleared;
try (var store = new EquipmentSessionStore(data, CLOCK)) {
var prepared = await(store.prepare(player, session, character));
assertEquals(prepared, await(store.prepare(player, session, character)));
assertInstanceOf(IOException.class, failure(store.prepare(player, session, UUID.randomUUID())));
assertInstanceOf(IOException.class, failure(store.prepare(player, UUID.randomUUID(), character)));
assertInstanceOf(IOException.class, failure(store.recordExit(player, UUID.randomUUID(), Duration.ZERO)));
assertInstanceOf(IOException.class, failure(store.clearReceipt(player, UUID.randomUUID())));
assertEquals(prepared, await(store.find(player)).orElseThrow());
var exited = await(store.recordExit(player, session, Duration.ofMinutes(15)));
assertEquals(BigInteger.ONE, exited.revision());
assertEquals(prepared.pending(), exited.pending(), "Exit is not proof of restoration");
assertEquals(new EquipmentSessionStore.Exit(session, character, NOW, NOW.plusSeconds(900)), exited.lastExit());
assertEquals(exited, await(store.recordExit(player, session, Duration.ofDays(1))));
assertEquals(exited, await(store.prepare(player, session, character)));
cleared = await(store.clearReceipt(player, session));
assertEquals(BigInteger.TWO, cleared.revision());
assertNull(cleared.pending());
assertEquals(exited.lastExit(), cleared.lastExit());
assertEquals(cleared, await(store.clearReceipt(player, session)));
}
try (var reopened = new EquipmentSessionStore(data, Clock.offset(CLOCK, Duration.ofHours(1)))) {
assertEquals(cleared, await(reopened.recordExit(player, session, Duration.ofDays(7))));
assertInstanceOf(IOException.class, failure(reopened.prepare(player, session, character)));
UUID nextSession = UUID.randomUUID();
var next = await(reopened.prepare(player, nextSession, character));
assertEquals(BigInteger.valueOf(3), next.revision());
assertEquals(cleared.lastExit(), next.lastExit());
assertInstanceOf(IOException.class, failure(reopened.clearReceipt(player, session)));
assertEquals(next, await(reopened.recordExit(player, session, Duration.ZERO)), "Old retry must not mutate new pending session");
var nextExit = await(reopened.recordExit(player, nextSession, Duration.ofSeconds(30)));
assertEquals(BigInteger.valueOf(4), nextExit.revision());
assertEquals(NOW.plusSeconds(3630), nextExit.lastExit().deadline());
}
}
@Test
void preparationAcknowledgesRealVersionedYamlAndSurvivesReopen() throws Exception {
EquipmentSessionStore.State saved;
try (var store = new EquipmentSessionStore(data, CLOCK)) {
assertFalse(Files.exists(data.resolve("sessions")), "Constructor must defer disk IO");
assertTrue(await(store.find(player)).isEmpty());
saved = await(store.prepare(player, session, character));
assertEquals(player, saved.playerId());
assertEquals(BigInteger.ZERO, saved.revision());
assertEquals(new EquipmentSessionStore.Receipt(session, character, NOW), saved.pending());
assertNull(saved.lastExit());
assertTrue(Files.isRegularFile(record()), "Acknowledgment follows persistence");
String yaml = Files.readString(record());
assertTrue(yaml.contains("schema-version: 1"));
assertFalse(yaml.contains("!!"));
}
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
assertEquals(saved, await(reopened.find(player)).orElseThrow());
}
assertFalse(Files.exists(data.resolve("characters")), "Existing character storage is untouched");
}
}
@@ -43,6 +43,67 @@ class YamlCharacterRepositoryTest {
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
}
@Test
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
var repository = new YamlCharacterRepository(data);
for (int index = 0; index < 96; index++) {
var created = repository.createTracked(sample(UUID.randomUUID()));
assertTrue(repository.discardUnissued(created));
}
try (var paths = Files.list(data.resolve("characters"))) {
var remaining = paths.toList();
assertTrue(remaining.size() <= 1, "Abandoned crafts must not leave one coordination file per generated ID");
assertTrue(remaining.stream().allMatch(path -> path.getFileName().toString().endsWith(".lock")));
}
var legitimate = sample(UUID.randomUUID());
repository.create(legitimate);
assertEquals(legitimate, repository.load(legitimate.id()).orElseThrow());
}
@Test
void sharedCoordinationRefusesEveryMutationWhileBusyAndPermitsRetryAfterRelease() throws Exception {
var repository = new YamlCharacterRepository(data);
var original = sample(UUID.randomUUID());
var unissued = repository.createTracked(sample(UUID.randomUUID()));
var candidate = sample(UUID.randomUUID());
repository.create(original);
var update = rename(original, "Retry after contention", BigInteger.ONE);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
assertThrows(IOException.class, () -> new YamlCharacterRepository(data).create(candidate));
assertThrows(IOException.class, () -> repository.replace(update, BigInteger.ZERO));
assertThrows(IOException.class, () -> repository.discardUnissued(unissued));
assertEquals(original, repository.load(original.id()).orElseThrow());
assertEquals(unissued.record, repository.load(unissued.record.id()).orElseThrow());
assertTrue(repository.load(candidate.id()).isEmpty());
}
repository.create(candidate);
repository.replace(update, BigInteger.ZERO);
assertTrue(repository.discardUnissued(unissued));
assertEquals(update, repository.load(original.id()).orElseThrow());
}
@Test
void leavesLegacyCoordinationFilesIntactAndRefusesASymlinkedSharedLock() throws Exception {
var repository = new YamlCharacterRepository(data);
var original = sample(UUID.randomUUID());
repository.create(original);
var legacy = data.resolve("characters").resolve(original.id() + ".lock");
Files.writeString(legacy, "Legacy coordination fixture");
repository.replace(rename(original, "Updated", BigInteger.ONE), BigInteger.ZERO);
assertEquals("Legacy coordination fixture", Files.readString(legacy));
var shared = data.resolve("characters/.records.lock");
Files.delete(shared); // Test setup only: no writer is holding this fixture lock.
var outside = data.resolve("outside-lock");
Files.writeString(outside, "Protected outside fixture");
Files.createSymbolicLink(shared, outside);
assertThrows(IOException.class, () -> repository.create(sample(UUID.randomUUID())));
assertEquals("Protected outside fixture", Files.readString(outside));
assertTrue(Files.isSymbolicLink(shared));
}
@Test
void updatesExactlyTheExpectedRevisionAndNeverOverwritesWithAStaleCopy() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -19,6 +19,55 @@ class CharacterProgressTest {
character.buyStat(before.characterId(), before.revision(), stat));
}
@Test
void nativeHealthCeilingDoesNotCapConOrTurnFurtherPurchasesIntoHealing() {
var character = new CharacterProgress(UUID.randomUUID(),
new ProgressionRules(integer(2000), integer(100), 30));
for (int i = 0; i < 1013; i++) buy(character, Stat.CON);
character.recordHealth(new BigDecimal("1019.25")); // 3.75 health points missing
buy(character, Stat.CON);
assertEquals(new BigDecimal("1024"), character.snapshot().maximumHealth());
assertEquals(new BigDecimal("1020.25"), character.snapshot().currentHealth());
var before = character.snapshot();
buy(character, Stat.CON);
var capped = character.snapshot();
assertEquals(new BigDecimal("1024"), capped.maximumHealth());
assertEquals(before.currentHealth(), capped.currentHealth());
assertEquals(integer(1015), capped.stats().get(Stat.CON));
assertEquals(before.unspentPoints().subtract(BigInteger.ONE), capped.unspentPoints());
assertEquals(CharacterProgress.Purchase.STALE_REVISION,
character.buyStat(before.characterId(), before.revision(), Stat.CON));
assertEquals(capped, character.snapshot());
assertThrows(IllegalArgumentException.class, () -> character.recordHealth(new BigDecimal("1024.01")));
assertEquals(capped, character.snapshot());
}
@Test
void suppliedNativeLimitAllowsPartialConGainAndZeroGainOnceReached() {
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("10.5"));
character.recordHealth(new BigDecimal("6.25"));
buy(character, Stat.CON);
assertEquals(new BigDecimal("10.5"), character.snapshot().maximumHealth());
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
buy(character, Stat.CON);
assertEquals(new BigDecimal("6.75"), character.snapshot().currentHealth());
assertEquals(integer(2), character.snapshot().stats().get(Stat.CON));
var low = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults(), new BigDecimal("5"));
assertEquals(new BigDecimal("5"), low.snapshot().maximumHealth());
assertEquals(new BigDecimal("5"), low.snapshot().currentHealth());
var higher = new CharacterProgress(UUID.randomUUID(),
new ProgressionRules(integer(2000), integer(100), 30), new BigDecimal("2048"));
for (int i = 0; i < 1015; i++) buy(higher, Stat.CON);
assertEquals(new BigDecimal("1025"), higher.snapshot().maximumHealth());
assertEquals(new BigDecimal("1025"), higher.snapshot().currentHealth());
for (String invalid : new String[] {"0", "-1"}) {
assertThrows(IllegalArgumentException.class, () -> new CharacterProgress(UUID.randomUUID(),
ProgressionRules.defaults(), new BigDecimal(invalid)));
}
assertThrows(NullPointerException.class, () -> new CharacterProgress(UUID.randomUUID(),
ProgressionRules.defaults(), null));
}
@Test
void conAddsItsGainToBothPoolsPreservingTheFiveOfSevenToSevenOfNineHeartExample() {
var character = new CharacterProgress(UUID.randomUUID(), ProgressionRules.defaults());
@@ -0,0 +1,161 @@
package games.dmg.fantasystick.session;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class SessionClaimsTest {
@Test
void releasingAllowsTransferAndSwitchingButAStaleReleaseCannotClearTheReplacement() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var old = claims.tryClaim(a, first).orElseThrow();
old.close();
assertFalse(old.isCurrent());
var transferred = claims.tryClaim(b, first).orElseThrow();
assertNotEquals(old.sessionId(), transferred.sessionId());
var switched = claims.tryClaim(a, second).orElseThrow();
old.close(); old.close();
assertTrue(transferred.isCurrent());
assertTrue(switched.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty());
transferred.close();
switched.close();
var resumed = claims.tryClaim(a, first).orElseThrow();
transferred.close();
assertTrue(resumed.isCurrent());
}
}
@Test
void deadOrZeroHealthRecordsAndInvalidArgumentsCannotReserveEitherIdentity() {
var live = new CharacterFactory().create(ProgressionRules.defaults());
var player = UUID.randomUUID();
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.TEN,
live.learned(), live.loadout(), live.cooldowns(), true, UUID.randomUUID(), java.time.Instant.EPOCH);
var empty = new games.dmg.fantasystick.persistence.CharacterRecord(live.id(), live.name(), live.revision(),
live.stats(), live.level(), live.experience(), live.unspentPoints(), java.math.BigDecimal.ZERO,
live.learned(), live.loadout(), live.cooldowns(), false, null, null);
try (var claims = new SessionClaims()) {
assertTrue(claims.tryClaim(player, dead).isEmpty());
assertTrue(claims.tryClaim(player, empty).isEmpty());
assertThrows(NullPointerException.class, () -> claims.tryClaim(null, live));
assertThrows(NullPointerException.class, () -> claims.tryClaim(player, null));
assertTrue(claims.tryClaim(player, live).orElseThrow().isCurrent());
assertTrue(dead.dead());
assertEquals(java.math.BigDecimal.ZERO, empty.health());
}
}
@Test
@SuppressWarnings("try")
void shutdownInvalidatesAllTokensAndDoesNotPretendToPersistOwnershipAcrossRestart() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var old = claims.tryClaim(a, first).orElseThrow();
var other = claims.tryClaim(b, second).orElseThrow();
claims.close(); claims.close();
assertFalse(old.isCurrent());
assertFalse(other.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty());
try (var restarted = new SessionClaims()) {
var fresh = restarted.tryClaim(b, first).orElseThrow();
old.close(); other.close();
assertTrue(fresh.isCurrent());
assertNotEquals(old.sessionId(), fresh.sessionId());
// This only establishes an empty process-local registry, not recovery of native equipment/state.
}
}
}
@Test
void competingCallbacksHaveOneWinnerForEitherSharedCharacterOrSharedPlayer() throws Exception {
var factory = new CharacterFactory();
for (boolean sharedCharacter : new boolean[] {true, false}) {
for (int round = 0; round < 12; round++) {
var character = factory.create(ProgressionRules.defaults());
var player = UUID.randomUUID();
var ready = new java.util.concurrent.CountDownLatch(16);
var start = new java.util.concurrent.CountDownLatch(1);
try (var claims = new SessionClaims(); var workers = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
var pending = new java.util.ArrayList<java.util.concurrent.Future<java.util.Optional<SessionClaims.Claim>>>();
for (int index = 0; index < 16; index++) pending.add(workers.submit(() -> {
ready.countDown();
if (!start.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new AssertionError("Start gate timed out");
return claims.tryClaim(sharedCharacter ? UUID.randomUUID() : player,
sharedCharacter ? character : factory.create(ProgressionRules.defaults()));
}));
assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS));
start.countDown();
var won = new java.util.ArrayList<SessionClaims.Claim>();
for (var future : pending) future.get(5, java.util.concurrent.TimeUnit.SECONDS).ifPresent(won::add);
assertEquals(1, won.size());
assertTrue(won.getFirst().isCurrent());
won.getFirst().close();
assertFalse(won.getFirst().isCurrent());
} finally { start.countDown(); }
}
}
}
@Test
void identityNotNameControlsExclusionAndNoGameplayStatOrNativeHealthCapIsAdded() {
var original = new CharacterFactory().create(ProgressionRules.defaults());
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(original.stats());
stats.put(games.dmg.fantasystick.progression.Stat.CON, java.math.BigInteger.TEN.pow(100));
var health = new java.math.BigDecimal(stats.get(games.dmg.fantasystick.progression.Stat.CON).add(java.math.BigInteger.TEN));
var large = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Shared name", original.revision(),
stats, original.level(), original.experience(), original.unspentPoints(), health,
original.learned(), original.loadout(), original.cooldowns(), false, null, null);
var independent = new games.dmg.fantasystick.persistence.CharacterRecord(UUID.randomUUID(), large.name(), large.revision(),
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
var renamedCopy = new games.dmg.fantasystick.persistence.CharacterRecord(large.id(), "Different name", java.math.BigInteger.ONE,
large.stats(), large.level(), large.experience(), large.unspentPoints(), large.health(),
large.learned(), large.loadout(), large.cooldowns(), false, null, null);
try (var claims = new SessionClaims()) {
var held = claims.tryClaim(UUID.randomUUID(), large).orElseThrow();
assertTrue(claims.tryClaim(UUID.randomUUID(), independent).isPresent());
assertTrue(claims.tryClaim(UUID.randomUUID(), renamedCopy).isEmpty());
assertTrue(held.isCurrent());
assertEquals(health, large.health());
assertEquals(java.math.BigInteger.TEN.pow(100), large.stats().get(games.dmg.fantasystick.progression.Stat.CON));
// This registry does not project these values into native attributes or grant permission to activate them.
}
}
@Test
void excludesOverlappingCharactersAndPlayerModesWithoutRestrictingStoredCharacterAcquisition() {
var factory = new CharacterFactory();
var first = factory.create(ProgressionRules.defaults());
var second = factory.create(ProgressionRules.defaults());
var a = UUID.randomUUID();
var b = UUID.randomUUID();
try (var claims = new SessionClaims()) {
var held = claims.tryClaim(a, first).orElseThrow();
assertEquals(first.id(), held.characterId());
assertEquals(a, held.playerId());
assertNotNull(held.sessionId());
assertTrue(held.isCurrent());
assertTrue(claims.tryClaim(a, first).isEmpty(), "Repeated entry is not a second session");
assertTrue(claims.tryClaim(b, first).isEmpty(), "Copied references cannot activate the same character twice");
assertTrue(claims.tryClaim(a, second).isEmpty(), "A sheet switch must finish exiting before claiming another active mode");
var independent = claims.tryClaim(b, second).orElseThrow();
assertTrue(independent.isCurrent());
assertNotEquals(held.sessionId(), independent.sessionId());
// Both records exist independently; this limits active/pending sessions, not sheets a player can own.
}
}
}