Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9771d1e7c3 | ||
|
|
259cd8cf29 | ||
|
|
1e7cdf888a | ||
|
|
e7912d03ce |
No files matched your search
@@ -1,8 +1,8 @@
|
||||
# Fantasy Stick
|
||||
|
||||
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
|
||||
**Development in progress. Manual character crafting is registered; fantasy sessions and the full RPG are not ready for deployment.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. 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 is now assembled; fantasy-mode activation and most gameplay integrations remain planned. 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; health, session 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. Commands, permissions, fantasy sessions and spells remain absent.
|
||||
|
||||
## Character storage and keepsakes
|
||||
|
||||
@@ -37,7 +37,7 @@ File-level atomic replacement is **not** atomicity with Minecraft inventory/play
|
||||
|
||||
Abandonment skips remaining queued writes and schedules cleanup for acknowledged, never-issued creations. Cleanup checks the original file identity/creation and modification attributes and exact bytes under the repository lock. Changed, replaced, corrupt, symlinked, busy or unidentifiable files are retained. Partial-save failure cleans only acknowledged creations and reports uncertain targets without deleting them. Shutdown rejection preserves records rather than inventing successful cleanup. Repeated cleanup cannot delete a later file. Restart never reconstructs cleanup authority from revision zero or scans records for automatic deletion/reissue.
|
||||
|
||||
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This is **not native inventory delivery**: the controller still must contain vanilla consumption, validate current view/lifecycle/content and commit actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. 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.
|
||||
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.
|
||||
|
||||
@@ -45,31 +45,67 @@ Thirteen craft-persistence tests cover these backend boundaries, including worke
|
||||
|
||||
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
|
||||
|
||||
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The actual craft-event controller is still absent.
|
||||
`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; register the guard with the eventual completed-crafting adapter before exposing the recipe. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
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**. Ten native planner/composition tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. A controller still needs one-use lifecycle tokens (a native menu may be reused after close/reopen), wiring of name/ID allocation and asynchronous persistence, reentrant matrix-update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
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 (not registered)
|
||||
## 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. No completed-craft listener or recipe is exposed yet.
|
||||
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 (not registered)
|
||||
## 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. That controller and recipe registration remain pending.
|
||||
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. The 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. Fantasy-mode entry and the remaining RPG are not implemented.
|
||||
|
||||
## 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.
|
||||
|
||||
## Session claims (not wired to activation)
|
||||
|
||||
`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. No fantasy activation is registered yet. The foundation passes 182 tests, clean/incremental packaging, audit and static analysis; remote publication is tracked separately.
|
||||
|
||||
## Native equipment adapter
|
||||
|
||||
@@ -95,4 +131,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,79 @@
|
||||
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.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. */
|
||||
/** Manual character crafting and anvil names; fantasy sessions and remaining gameplay are not registered yet. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
private ManualCraftingGuard guard;
|
||||
private CharacterAnvilNames anvilNames;
|
||||
private boolean recipeRegistered;
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
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());
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
} catch (RuntimeException failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
if (crafting != null) crafting.close();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,23 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,424 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
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.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", "ManualCraftingGuard", "CharacterAnvilNames")) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
||||
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 List<Listener> listeners = new ArrayList<>();
|
||||
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);
|
||||
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" -> {
|
||||
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" -> true;
|
||||
case "getOnlinePlayers" -> List.of();
|
||||
case "addRecipe" -> {
|
||||
order.add("recipe");
|
||||
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();
|
||||
}
|
||||
|
||||
private NativeEventDispatcher dispatcher() { return dispatcher; }
|
||||
@Override public void close() throws IOException {
|
||||
try { plugin.setEnabled(false); }
|
||||
finally { HandlerList.unregisterAll(plugin); loader.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -293,16 +293,26 @@ class ManualCraftPlanNativeTest {
|
||||
@Override public InventoryHolder getOwner() { return null; }
|
||||
});
|
||||
final InventoryView view;
|
||||
HumanEntity human;
|
||||
boolean failMatrixRead;
|
||||
|
||||
Fixture(int width, int amount) {
|
||||
matrix = new TransientCraftingContainer(menu, width, width);
|
||||
var result = new ResultContainer();
|
||||
matrix.resultInventory = result;
|
||||
grid = new CraftInventoryCrafting(matrix, result);
|
||||
grid = new CraftInventoryCrafting(matrix, result) {
|
||||
@Override public ItemStack[] getMatrix() {
|
||||
if (failMatrixRead) throw new IllegalStateException("Simulated native matrix read failure");
|
||||
return super.getMatrix();
|
||||
}
|
||||
};
|
||||
view = new CraftAbstractInventoryView() {
|
||||
@Override public CraftInventoryCrafting getTopInventory() { return grid; }
|
||||
@Override public CraftInventoryPlayer getBottomInventory() { return inventory; }
|
||||
@Override public HumanEntity getPlayer() { throw new AssertionError("Unexpected player/network access"); }
|
||||
@Override public HumanEntity getPlayer() {
|
||||
if (human == null) throw new AssertionError("Unexpected player/network access");
|
||||
return human;
|
||||
}
|
||||
@Override public InventoryType getType() { return grid.getType(); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,8 @@ public final class NativeEventDispatcher implements Consumer<Event> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user