Compare commits

...
2 Commits
Author SHA1 Message Date
dmg f7d01381ec feat(equipment): complete safe session packing and restoration
Release / release (push) Successful in 6m29s
CI / build (push) Successful in 3m27s
2026-09-18 18:52:31 -04:00
dmg db87505142 feat(health): honor native ceilings without capping CON
Release / release (push) Successful in 5m14s
CI / build (push) Successful in 2m50s
2026-09-18 11:35:40 -04:00
27 changed files with 7461 additions and 35 deletions

No files matched your search

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