Compare commits

...
16 Commits
Author SHA1 Message Date
dmg 74e717c6ca feat(abilities): add Fireball
Release / release (push) Successful in 6m11s
2026-09-19 17:52:37 -04:00
dmg 644b343fc9 feat(abilities): add extensible engine
Release / release (push) Successful in 6m8s
2026-09-19 17:29:11 -04:00
dmg ba69a55124 feat(progression): add shared combat experience
Release / release (push) Successful in 6m53s
2026-09-19 17:00:10 -04:00
dmg db35c28f0b feat(nutrition): isolate fantasy hunger and healing
Release / release (push) Successful in 5m54s
2026-09-19 15:26:02 -04:00
dmg 636ff9f076 feat(combat): add built-in basic attack
Release / release (push) Successful in 5m48s
2026-09-19 15:17:53 -04:00
dmg 7158817726 feat(progression): add permanent stat purchases
Release / release (push) Successful in 5m55s
2026-09-19 15:08:54 -04:00
dmg 8ee0d98599 ci(fantasy-stick): avoid duplicate release builds
Release / release (push) Successful in 3m31s
2026-09-19 14:51:20 -04:00
dmg 539900eb14 fix(tests): synchronize health saves before reentry and transfer
Release / release (push) Successful in 8m26s
CI / build (push) Successful in 4m19s
2026-09-19 14:32:35 -04:00
dmg 9828590009 fix(sheets): wrap character instructions into three lore lines
Release / release (push) Successful in 8m5s
CI / build (push) Successful in 4m36s
2026-09-19 14:11:14 -04:00
dmg c85b5e91cc fix(sessions): preserve client life and group Tail pages
Release / release (push) Successful in 8m2s
CI / build (push) Successful in 4m49s
2026-09-19 14:04:57 -04:00
dmg bda7419b8b test(sessions): await health persistence in lifecycle assertions
Release / release (push) Successful in 8m33s
CI / build (push) Successful in 4m33s
2026-09-19 12:51:26 -04:00
dmg 6ac4c4bfde feat(sessions): complete native health and fantasy defeat
CI / build (push) Failing after 4m43s
Release / release (push) Failing after 4m45s
2026-09-19 12:37:20 -04:00
dmg 6f42082383 feat(book): complete paginated character inspection
Release / release (push) Successful in 8m20s
CI / build (push) Successful in 4m24s
2026-09-19 11:12:19 -04:00
dmg 9ddc139544 test(inventory): verify complete guarded control journey
CI / build (push) Successful in 5m52s
Release / release (push) Successful in 5m54s
2026-09-19 11:00:03 -04:00
dmg 58d5ea82f1 feat(parties): complete book parties and native damage protection
Release / release (push) Successful in 8m24s
CI / build (push) Successful in 4m21s
Complete US-012 with consent-based player parties, lifecycle rules, session-bound book controls and current-session native damage protection. Preserve the existing approved inventory/input and read-only book prerequisites without claiming their broader stories complete.

Verify 516 tests with clean and versioned builds; scoped Semgrep and npm audit report no findings. No deployment changes.
2026-09-19 10:37:02 -04:00
dmg 40e41d6b7a feat(sessions): add configurable Adventure-mode sessions
Release / release (push) Successful in 7m58s
CI / build (push) Successful in 4m6s
2026-09-18 23:36:03 -04:00
74 changed files with 8477 additions and 134 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches-ignore: [main]
tags-ignore: ['**']
pull_request:
jobs:
+57 -12
View File
@@ -1,15 +1,15 @@
# Fantasy Stick
**Transferable characters and standalone names (US-001), equipment restoration (US-002), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), native session health/defeat (US-006), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. 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 and equipment-only offhand sessions are assembled; fantasy health/nutrition, 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 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, native-health offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; active food overrides, stat/ability book actions, 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 and accepted anvil-name persistence are wired. Stat purchases and spell execution remain incomplete.
## Build and verify
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
- Node.js 20 or later and npm for the TypeScript release-tool tests.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled. Install the official [server distribution](https://github.com/dmulloy2/ProtocolLib/releases/tag/5.4.0), not the Maven library JAR (which lacks bundled runtime dependencies).
```sh
./gradlew clean check jar
@@ -28,7 +28,7 @@ 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 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.
`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. Native health/session integration is described below.
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.
@@ -44,7 +44,7 @@ Thirteen craft-persistence tests cover these backend boundaries, including worke
## Paper sheet and recipe definition
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and three separate lore lines: `Character sheet:`, `Place in off hand`, `to enter fantasy world.` Recipe previews and acknowledged crafted sheets use the same layout; existing items are not bulk-rewritten. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The assembled craft-event controller now invokes this allocator only for accepted current attempts.
@@ -102,13 +102,13 @@ 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 now refreshes after acknowledged saves, including renaming a copied reference held by someone else; stale session/revision callbacks cannot rename a replacement holder. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
## Native health ceiling (activation still unwired)
## Native health ceiling (historical core checkpoint)
`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.
At this historical checkpoint, no virtual health, global configuration change, stored-data migration or active-player health adapter was introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility. The later native session integration is described below; a complete Revive spell remains unfinished. 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
@@ -122,22 +122,23 @@ 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 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 and nutrition overrides remain separate unfinished features; standalone display projection is implemented below. 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 sessions. Native health/vitals integration is described below; active nutrition overrides remain unfinished. Standalone display projection is implemented below. 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 historical US-002 completion checkpoint passed 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; book/ability actions were not yet implemented at that checkpoint. Current inspection and party actions are described below; basic/learned attacks remain unfinished.
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.
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. The switch-delay setting is read on enable; there is no general 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.
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
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.
@@ -151,7 +152,51 @@ Output resources belong to a player connection/session, not the sheet. Exit/defe
**Cross-plugin display compatibility is unsupported**, as explicitly requested. There is no Stealth/Leaf/Ignis bridge or nickname/concealment guarantee. Native/platform visibility and ordinary cancellations are covered; supplemental presentation-owned packets bypass ProtocolLib filters to avoid recursive projection. Names can therefore conflict with other plugins' presentation or packet-only concealment. This does not disable ordinary protection or inventory safeguards.
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is approved separately but not implemented in this source yet.
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is described below; its verification does not complete the remaining fantasy health, nutrition or combat features.
## Optional Adventure mode
`adventure-mode: true` is the default. A new entry captures this policy; enabled entries save the holder's original game mode in the player/session receipt before changing it to Adventure. Disabled entries leave the mode untouched, as do holders already in Adventure. This state never transfers with the character.
The admin command saves on a worker, preserving unrelated configuration values, before publishing its effective value. Invalid values and failed writes leave that value unchanged. Already active sessions keep their captured policy. Accepted writes drain without blocking shutdown; immediate re-enable defers startup asynchronously until those writes finish, then reads the saved policy.
Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Adventure change. Observed later admin changes relinquish ownership, including an observed change away and back. Native cancellation refuses entry without packing; a cancelled restoration retains its receipt for inspection/retry. Interrupted or mismatched player markers are not replay permission. Mode cleanup is independent of uncertain equipment cleanup.
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
## Native fantasy health, exit and defeat
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
An intercepted fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Ordinary health is restored before health-scale setters can send or queue client health packets: cancelled defeat must not deliver zero health and strand an otherwise alive player on a death screen. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. New Tails group ordinary-sized name/level/XP/points/stats on a summary page, then give each learned ability its own section with saved ID, assigned ability slots and cooldown deadline. Unknown IDs, saved unlearned cooldowns, empty abilities and death time stay explicit; long content continues onto additional pages without truncation. No ability descriptions are invented and existing books are not rewritten. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
Interrupted recovery requires matching player/session receipt and native proof. It preserves current native modifier ownership and does not resurrect expired effects. Repeated/stale recovery cannot replay ordinary health or recreate an already released claim. This is **not crash-atomic** across player, inventory and plugin files; unresolved native setter phases and unacknowledged progress may require inspection. Legacy equipment-only receipts remain readable but do not grant health-restoration authority. Older binaries do not understand the new optional ordinary-vitals receipt field; do not downgrade across unresolved newer receipts.
Active hunger/exhaustion suppression and food-regeneration discrimination remain US-026 work. Book stat purchases, XP delivery, basic/learned attacks and Revive remain separate unfinished stories. The preceding equipment/progression checkpoint sections are historical evidence, not the current health scope.
## Character inspection and inventory safety
The first-hotbar-slot book reads the active character's authoritative name, six stats, health, level, XP/MAX, points and learned identities. Inspection does not change character data or inventory. Plain-text pages wrap conservatively for the vanilla font without splitting Unicode code points. Large supported records use volumes of at most 80 inspection pages, leaving room for navigation and party controls while bounding each opened view's payload. This is a plugin presentation bound, not Minecraft's 100-page writable-book cap; signed books use a different codec. `[Next pages]` / `[Previous pages]` reread current data; unknown ability IDs and arbitrarily large saved numbers are not silently discarded.
Deferred reads and single-use navigation validate the player, session, character and held control. Superseded requests, ended sessions, quit/disable and invalid client tokens cannot open a stale view. Read failures preserve data and allow reopening. Native tests cover overflow, multiple volumes, rename, transfer, reload and unchanged inventory/data; actual client rendering and custom resource-pack fonts are not claimed as tested.
All nine hotbar controls remain bound, with seven selectable placeholders on a new character. Native containment tests cover export/consumption, hidden swaps, recipe autofill, armor/cosmetics, cursor custody and sorting relocation. The packaged inventory journey proves that these guards still permit the real book action and that removing the transferable offhand sheet restores ordinary equipment. Future attack/ability consumers must retain these same safeguards.
## Character-book parties and combat protection
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
A disconnected leader remains leader while another member is online. All-offline parties, invitations and book views are discarded on shutdown/restart. This does not persist or restore party rosters.
Native damage protection blocks friendly fire between active fantasy party members and player-attributable mixed-mode damage in both directions. Ordinary-versus-ordinary and environmental/mob combat retain their native rules and prior cancellations. Projectiles, TNT, fangs, clouds, fireworks and pet attacks retain player attribution; delayed fantasy sources cannot borrow a new session after exit, transfer or reconnect. Valid native healing is unchanged; stale projectile impacts are rejected before execution. Full RPG attacks and support/XP abilities remain separate unfinished work.
Attributable poison, wither and burning retain bounded source/recipient provenance in native entity/player PDC (`combat_origin_v1`, `combat_dot_*_v1`). Each damage tick checks current membership, source/recipient session identity and native effect state. Poison/wither hidden layers keep their own source rather than inheriting a covering effect's origin. Removal, expiry and unrelated replacement clear only the matching provenance; cleanup does not remove native effects or change health, equipment, character YAML or XP. A tick-driven cleanup checks only the three relevant player metadata keys and stops on disable. Native PDC serialization retains source proofs across reload; ended fantasy sessions remain invalid, while parties themselves never survive restart. These are native save guarantees, not cross-save atomicity or dedicated cross-plugin integration.
Automated party tests exercise actual packaged listeners, native book custom-click payloads, native event dispatch, effect-merging semantics and PDC serialization. Player/network/world execution remains represented by explicit test ports; live multiplayer testing and deployment are separate.
## CI and publication
@@ -21,7 +21,7 @@ import java.time.Clock;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Crafting, equipment sessions and standalone names; health, nutrition and spells remain inactive. */
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
@@ -31,20 +31,51 @@ public final class FantasyStickPlugin extends JavaPlugin {
private EquipmentSessionStore receipts;
private EquipmentSessions sessions;
private EquipmentSessionEvents sessionEvents;
private games.dmg.fantasystick.book.CharacterBookController books;
private games.dmg.fantasystick.party.PartyRoster parties;
private games.dmg.fantasystick.party.PartyEvents partyEvents;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private volatile Object lifetime;
private CharacterPresentation presentation;
private ProtocolPresentation protocolPresentation;
private CharacterChatNames chatNames;
private games.dmg.fantasystick.persistence.AdventureSettings adventureSettings;
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
try {
Object generation = new Object();
lifetime = generation;
if (!configurationDrain.toCompletableFuture().isDone()) {
configurationDrain.whenComplete((ignored, failure) -> {
if (lifetime != generation) return;
try {
getServer().getScheduler().runTask(this, () -> {
if (lifetime == generation && isEnabled()) onEnable();
});
} catch (RuntimeException rejected) {
getLogger().log(Level.WARNING, "Configuration startup continuation unavailable", rejected);
}
});
return;
}
reloadConfig();
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
var switchDelay = switchDelay();
var defaults = ProgressionRules.defaults();
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
defaults.xpPerLevel(), defaults.maximumLevel());
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -80,12 +111,53 @@ public final class FantasyStickPlugin extends JavaPlugin {
java.util.function.Consumer<EquipmentSessions.Failure> diagnostics = problem ->
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
+ "; session " + problem.session(), problem.cause());
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation);
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics);
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
abilityRegistry.register(fireball.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
return task::cancel;
};
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
String name = localPresentation.get().name(id);
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
}, abilityRegistry, abilityEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
localBasicAttack.accept(input);
abilityEngine.accept(input);
});
sessionEvents.attachAbilityRegistry(abilityRegistry);
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()));
+ " for player " + problem.player(), problem.cause()), adventureSettings);
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -94,6 +166,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(anvilNames, this);
getServer().getPluginManager().registerEvents(sessionEvents, this);
getServer().getPluginManager().registerEvents(chatNames, this);
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(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);
@@ -112,20 +197,56 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
var closingParties = parties; parties = null;
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
var closingSessions = sessions; sessions = null;
var closingReceipts = receipts; receipts = null;
var closingCharacters = characters; characters = null;
var closingSettings = adventureSettings; adventureSettings = null;
var closingModes = gameModes; gameModes = null;
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = null;
var closingBasicAttack = basicAttack; basicAttack = null;
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingSettings != null) {
configurationDrain = closingSettings.drained();
cleanup("configuration", closingSettings::close);
}
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
if (closingEvents != null) cleanup("session events", closingEvents::close);
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingCombatExperience);
unregister(closingFireball);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
unregister(closingGuard);
@@ -155,6 +276,43 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private ProgressionRules progressionRules() {
try {
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
return new ProgressionRules(starting, threshold, maximum);
} catch (ArithmeticException invalid) {
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
}
}
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
try {
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
@@ -184,7 +342,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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 (notice.code().equals("DEFEAT")) games.dmg.fantasystick.session.SessionVisuals.burst(player, org.bukkit.Color.RED, 12);
else 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();
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT }
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
throw new IllegalArgumentException("Invalid ability definition");
}
}
}
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.ability;
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
@FunctionalInterface
public interface AbilityEffect {
void activate(AbilityExecution execution);
}
@@ -0,0 +1,330 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
/** Shared character mutation, cooldown, target, execution, report and cleanup boundary. */
public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseable {
public record Failure(UUID character, String ability, Throwable cause) { }
public record Mutation(CharacterStorage.AbilityMutationStatus status, CharacterRecord record) { }
private record Key(UUID character, String ability) { }
private final AbilityRegistry registry;
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final AbilityScheduler scheduler;
private final Consumer<AbilityReport> reports;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
this.failures = Objects.requireNonNull(failures);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || sweepTask != null) throw new IllegalStateException("Ability engine is already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public CompletableFuture<Mutation> purchase(ControlInput input, String ability, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
var definition = registry.find(ability).orElse(null);
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.unlockAbility(input.session().character(), expectedRevision, ability,
definition.requirements(), definition.pointCost()));
}
public CompletableFuture<Mutation> assign(ControlInput input, String ability, int slot, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
}
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
var result = new CompletableFuture<Mutation>();
CompletableFuture<CharacterStorage.AbilityMutation> work;
try { work = operation.get(); }
catch (RuntimeException failure) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return result; }
work.whenComplete((mutation, failure) -> schedule(input.session().character(), ability, result, () -> {
if (failure != null) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return; }
var record = mutation.record().orElse(null);
if (mutation.status() == CharacterStorage.AbilityMutationStatus.APPLIED) {
try {
if (record == null || !input.stillCurrent()
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record)) {
result.complete(new Mutation(CharacterStorage.AbilityMutationStatus.STALE, record)); return;
}
} catch (RuntimeException nativeFailure) {
report(input.session().character(), ability, nativeFailure);
result.completeExceptionally(nativeFailure); return;
}
}
result.complete(new Mutation(mutation.status(), record));
}));
return result;
}
@Override public void accept(ControlInput input) {
if (closed || input.kind() != ControlInput.Kind.ABILITY || !input.stillCurrent()) return;
var definition = registry.find(input.ability()).orElse(null);
if (definition == null || definition.gesture() != input.gesture()) {
input.player().sendMessage("That ability is unavailable."); return;
}
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null || !record.learned().contains(definition.id())
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
}
Instant now = clock.instant();
Instant existing = record.cooldowns().get(definition.id());
if (existing != null && now.isBefore(existing)) {
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
if (!eligible(definition.targetPolicy(), input, input.target())) {
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
}
var key = new Key(input.session().character(), definition.id());
Object token = new Object();
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
catch (DateTimeException | ArithmeticException invalid) {
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
if (failure != null) {
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
boolean unchangedDeadline = mutation.status() == CharacterStorage.AbilityMutationStatus.UNCHANGED
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
case NOT_LEARNED, UNAVAILABLE -> "That ability is no longer available to this character.";
default -> "The cast was refused without applying an effect.";
});
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
/** Future refund/removal paths invalidate pending callbacks and close only this ability's owned work. */
public void remove(UUID character, String ability) {
var key = new Key(Objects.requireNonNull(character), Objects.requireNonNull(ability));
generations.put(key, generations.getOrDefault(key, 0L) + 1);
pending.remove(key);
for (var runtime : List.copyOf(active.getOrDefault(key, Set.of()))) runtime.close();
}
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
public void sweep() {
if (closed) return;
int checked = 0;
outer: for (var runtimes : List.copyOf(active.values())) {
for (var runtime : List.copyOf(runtimes)) {
if (!runtime.stillCurrent()) runtime.close();
if (++checked == 64) break outer;
}
}
}
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
return switch (policy) {
case SELF -> target == null || target == input.player();
case HOSTILE -> hostile(input, target);
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
case ALLY -> target instanceof Player player && fantasy(player);
case PARTY -> target instanceof Player player
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
};
}
private boolean hostile(ControlInput input, Entity target) {
return target instanceof LivingEntity && target != input.player()
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
}
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
private boolean sameParty(Player source, Player target) {
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
}
private <T> void schedule(UUID character, String ability, CompletableFuture<T> result, Runnable action) {
try { nextTick.accept(action); }
catch (RuntimeException rejected) {
report(character, ability, rejected);
if (result != null) result.completeExceptionally(rejected);
}
}
private void report(UUID character, String ability, Throwable failure) {
try { failures.accept(new Failure(character, ability, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never authorize or replay an effect. */ }
}
private final class Runtime implements AbilityExecution {
private final Key key;
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final Set<AutoCloseable> owned = new HashSet<>();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@Override public CharacterRecord character() {
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
}
@Override public List<Entity> eligible(Collection<? extends Entity> candidates, int maximum) {
Objects.requireNonNull(candidates);
if (maximum < 1 || maximum > 64) throw new IllegalArgumentException("Recipient bound must be 1..64");
var result = new ArrayList<Entity>(); int inspected = 0;
for (var candidate : candidates) {
if (++inspected > 256 || result.size() == maximum) break;
if (stillCurrent() && AbilityEngine.this.eligible(definition.targetPolicy(), input, candidate)) result.add(candidate);
}
return List.copyOf(result);
}
@Override public AbilityScheduler.Task later(Duration delay, Runnable action) {
Objects.requireNonNull(delay); Objects.requireNonNull(action);
if (delay.isNegative() || delay.compareTo(Duration.ofDays(365)) > 0 || ++scheduled > 64) {
throw new IllegalArgumentException("Delayed effect is outside the bounded execution contract");
}
var task = scheduler.later(delay, () -> {
if (!stillCurrent() || !AbilityEngine.this.eligible(definition.targetPolicy(), input, input.target())) {
close(); return;
}
try { action.run(); }
catch (RuntimeException failure) {
close(); AbilityEngine.this.report(key.character(), key.ability(), failure);
}
});
own(task); return task;
}
@Override public void own(AutoCloseable resource) {
Objects.requireNonNull(resource);
if (finished || owned.size() >= 64) {
try { resource.close(); } catch (Exception ignored) { }
if (!finished) throw new IllegalStateException("Ability owns too many resources");
return;
}
owned.add(resource);
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
}
@Override public void finish() { close(); }
@Override public void close() {
if (finished) return;
finished = true;
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
pending.clear();
for (var runtimes : List.copyOf(active.values())) for (var runtime : List.copyOf(runtimes)) runtime.close();
active.clear(); generations.clear();
}
}
@@ -0,0 +1,22 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import org.bukkit.entity.Entity;
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
public interface AbilityExecution extends AutoCloseable {
AbilityDefinition definition();
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
void finish();
@Override void close();
}
@@ -0,0 +1,27 @@
package games.dmg.fantasystick.ability;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** Process-local validated definitions; saved unknown IDs remain character data, not executable definitions. */
public final class AbilityRegistry {
private final ConcurrentHashMap<String, AbilityDefinition> definitions = new ConcurrentHashMap<>();
public void register(AbilityDefinition definition) {
Objects.requireNonNull(definition);
if (definitions.putIfAbsent(definition.id(), definition) != null) {
throw new IllegalArgumentException("Duplicate ability ID: " + definition.id());
}
}
public Optional<AbilityDefinition> find(String id) {
return id == null ? Optional.empty() : Optional.ofNullable(definitions.get(id));
}
public List<AbilityDefinition> definitions() {
return definitions.values().stream().sorted(Comparator.comparing(AbilityDefinition::id)).toList();
}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Evidence of an actual accepted effect; consumers decide rewards separately. */
public record AbilityReport(Type type, UUID sourceCharacter, UUID sourceSession, UUID target,
BigDecimal amount, Instant occurredAt) {
public enum Type { DAMAGE, HEALING, CLEANSING, PREVENTION, BOOST_USED }
public AbilityReport {
Objects.requireNonNull(type); Objects.requireNonNull(sourceCharacter); Objects.requireNonNull(sourceSession);
Objects.requireNonNull(target); Objects.requireNonNull(amount); Objects.requireNonNull(occurredAt);
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) {
throw new IllegalArgumentException("An effective report requires a positive finite amount");
}
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.ability;
import java.time.Duration;
/** Server-thread delayed-work boundary; returned tasks cancel only their owned callback. */
@FunctionalInterface
public interface AbilityScheduler {
Task later(Duration delay, Runnable action);
interface Task extends AutoCloseable {
@Override void close();
}
}
@@ -0,0 +1,162 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
}
}
private final Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
}
@Override public void close() {
if (finished) return;
finished = true;
projectiles.remove(projectile.getUniqueId(), this);
projectile.remove();
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(6), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,37 @@
package games.dmg.fantasystick.book;
import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
/** Plain native book pages, conservatively bounded for the vanilla font (no styled/bold text). */
public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
for (int offset = 0; offset < text.length();) {
int codePoint = text.codePointAt(offset);
offset += Character.charCount(codePoint);
if (codePoint == '\n') {
lines.add(line.toString()); line.setLength(0); width = 0;
continue;
}
// ASCII is at most six pixels; vanilla's Unicode fallback is at most nine.
// Leave six pixels of horizontal slack and one line of vertical slack.
int advance = codePoint < 128 ? 6 : 9;
if (width + advance > 108) {
lines.add(line.toString()); line.setLength(0); width = 0;
}
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
}
@@ -0,0 +1,345 @@
package games.dmg.fantasystick.book;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.NativeHealthLimit;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
public record Failure(UUID player, UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules rules;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
private final PartyBookControls partyControls;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
this(characters, rules, sessions, nextTick, failures, parties, server, names,
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
}
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
games.dmg.fantasystick.ability.AbilityRegistry abilities,
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
this.characters = Objects.requireNonNull(characters);
this.rules = Objects.requireNonNull(rules);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
this.abilities = Objects.requireNonNull(abilities);
this.abilityEngine = abilityEngine;
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) { load(input, 0); }
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
if (closed || pending.get(owner) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
input.player().sendMessage("This character is no longer available.");
return;
}
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
report(input, rejected);
}
});
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString()
+ "\n\nAbilities: " + learned;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var all = BookPagination.pages(first + "\n" + second);
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
statViews.put(input.session().player(), statView);
Component purchases = Component.text("Spend 1 point\n\n");
for (var stat : Stat.values()) {
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
.append(Component.newline());
}
pages.add(purchases);
var definitions = abilities.definitions();
if (!definitions.isEmpty() && end == all.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
abilityViews.put(input.session().player(), abilityView);
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
}
if (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
}
pages.addAll(partyControls.pages(input));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
boolean learned = record.learned().contains(definition.id());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
String text = definition.name() + "\nIcon: " + definition.icon().name()
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
+ "\nCooldown: " + definition.cooldown().toSeconds() + "s" + parameters + "\n\n" + state;
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
} else if (learned) {
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
for (int slot = 0; slot < 7; slot++) {
if (slot > 0) action = action.append(Component.space());
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
}
pages.add(action);
}
return java.util.List.copyOf(pages);
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
String ability, Integer slot) {
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
}
private static Component pageButton(String label, View view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 64) return;
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@org.bukkit.event.EventHandler
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
return;
}
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
case UNAVAILABLE -> "This character is no longer available.";
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
}
@org.bukkit.event.EventHandler
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !assign
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var view = abilityViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string();
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| unlock != (match.group(3) == null)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, String ability, CharacterStorage.AbilityMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + ability + "." : "Assigned " + ability + ".";
case UNCHANGED -> ability + " is already assigned there.";
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> "Learn " + ability + " before assigning it.";
case STALE -> "That character page is stale; reopen it before changing abilities.";
case COOLDOWN -> "That ability is still on cooldown.";
case UNAVAILABLE -> "That character or ability is unavailable.";
};
}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
}
}
@@ -0,0 +1,182 @@
package games.dmg.fantasystick.book;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.ControlInput;
import io.papermc.paper.connection.PlayerGameConnection;
import io.papermc.paper.event.player.PlayerCustomClickEvent;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.regex.Pattern;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.nbt.api.BinaryTagHolder;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
/** One single-use view per player; client payloads select server-owned actions, never arbitrary UUIDs/commands. */
public final class PartyBookControls implements AutoCloseable {
private static final Key ACTION = Key.key("fantasystick", "party_book");
private static final Pattern PAYLOAD = Pattern.compile("\"([a-f0-9-]{36}):([0-9]{1,3})\"");
private static final int PAGE_SIZE = 8;
private enum Menu { ROOT, PLAYERS, INVITATIONS }
private record View(String nonce, ControlInput input, List<Runnable> actions) {
private View { actions = List.copyOf(actions); }
}
private final Map<UUID, View> views = new HashMap<>();
private final PartyRoster parties;
private final Server server;
private final Function<UUID, Component> names;
private final Consumer<ControlInput> refresh;
private final BiConsumer<ControlInput, Throwable> failures;
private boolean closed;
public PartyBookControls(PartyRoster parties, Server server, Function<UUID, Component> names,
Consumer<ControlInput> refresh, BiConsumer<ControlInput, Throwable> failures) {
this.parties = Objects.requireNonNull(parties);
this.server = Objects.requireNonNull(server);
this.names = Objects.requireNonNull(names);
this.refresh = Objects.requireNonNull(refresh);
this.failures = Objects.requireNonNull(failures);
}
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
var owner = input.session().player();
var party = parties.partyOf(owner);
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
if (menu == Menu.ROOT) {
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
build.pages.add(summary);
} else {
var group = party.orElseThrow();
boolean leader = group.leader().equals(owner);
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
if (leader) {
summary = summary.append(Component.newline())
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
if (group.members().size() < 6) summary = summary.append(Component.newline())
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
}
build.pages.add(summary);
for (var member : group.members()) {
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
build.pages.add(row);
}
}
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
.map(player -> player.getUniqueId()).filter(id -> parties.partyOf(id).isEmpty())
.sorted(Comparator.comparing(UUID::toString)).toList();
int start = start(offset, candidates.size());
build.navigation("Invite players", Menu.PLAYERS, start, candidates.size());
for (int i = start; i < Math.min(candidates.size(), start + PAGE_SIZE); i++) {
UUID target = candidates.get(i);
build.pages.add(Component.text("Invite\n\n").append(names.apply(target)).append(Component.text("\n\n"))
.append(build.button("Invite", () -> mutate(input, () -> {
boolean invited = parties.invite(owner, group.id(), target).isPresent();
if (invited) {
var recipient = server.getPlayer(target);
if (recipient != null && recipient.isOnline()) recipient.sendMessage(
"Party invitation received. Open your character book to accept within 60 seconds.");
}
return invited;
}))));
}
} else {
var invitations = parties.invitationsFor(owner);
int start = start(offset, invitations.size());
build.navigation("Invitations", Menu.INVITATIONS, start, invitations.size());
for (int i = start; i < Math.min(invitations.size(), start + PAGE_SIZE); i++) {
var invitation = invitations.get(i);
build.pages.add(Component.text("Invitation from\n\n").append(names.apply(invitation.leader()))
.append(Component.text("\n\n")).append(build.button("Accept",
() -> mutate(input, () -> parties.accept(owner, invitation.id())))));
}
}
views.put(owner, new View(build.nonce, input, build.actions));
return List.copyOf(build.pages);
}
private static int start(int requested, int size) {
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
}
private final class Pages {
final ControlInput input;
final String nonce = UUID.randomUUID().toString();
final List<Component> pages = new ArrayList<>();
final List<Runnable> actions = new ArrayList<>();
Pages(ControlInput input) { this.input = input; }
Component button(String label, Runnable action) {
int index = actions.size();
actions.add(action);
return Component.text("[" + label + "]", NamedTextColor.DARK_BLUE).clickEvent(ClickEvent.custom(ACTION,
BinaryTagHolder.binaryTagHolder("\"" + nonce + ":" + index + "\"")));
}
void navigation(String title, Menu menu, int start, int count) {
Component page = Component.text(title + "\n\n" + (count == 0 ? "None available\n\n" : ""))
.append(button("Character book", () -> refresh.accept(input)));
if (start > 0) page = page.append(Component.newline()).append(button("Previous", () -> show(input, menu, start - PAGE_SIZE)));
if (start + PAGE_SIZE < count) page = page.append(Component.newline()).append(button("Next", () -> show(input, menu, start + PAGE_SIZE)));
pages.add(page);
}
}
private void show(ControlInput input, Menu menu, int offset) {
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Party"); meta.setAuthor("Fantasy Stick");
meta.pages(render(input, menu, offset));
item.setItemMeta(meta);
input.player().openBook(item);
}
public void click(PlayerCustomClickEvent event) {
if (closed || !ACTION.equals(event.getIdentifier()) || !(event.getCommonConnection() instanceof PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
var tag = event.getTag();
if (tag == null) return;
String raw = tag.string();
if (raw.length() > 64) return;
var match = PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int index = Integer.parseInt(match.group(2));
if (index >= view.actions().size()) return;
views.remove(player.getUniqueId());
if (!view.input().stillCurrent()) return;
try { view.actions().get(index).run(); }
catch (RuntimeException failure) { forget(player.getUniqueId()); failures.accept(view.input(), failure); }
}
private void mutate(ControlInput input, BooleanSupplier operation) {
if (!operation.getAsBoolean()) input.player().sendMessage("Party action unavailable: membership, authority or invitation changed.");
refresh.accept(input);
}
public void forget(UUID player) { views.remove(player); }
@Override public void close() { closed = true; views.clear(); }
}
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
private static final class Pending {
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
}
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
}
@Override public void accept(ControlInput input) {
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|| input.gesture() != ControlInput.Gesture.ATTACK
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
pending = attempt;
try {
target.damage(nativeDamage, input.player());
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
}
@Override public void close() {
closed = true;
pending = null;
readyAt.clear();
}
}
@@ -16,7 +16,8 @@ public final class CharacterSheets {
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
public ItemStack create(CharacterRecord character) {
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
@@ -40,7 +41,7 @@ public final class CharacterSheets {
var item = new ItemStack(Material.PAPER);
var meta = item.getItemMeta();
meta.displayName(Component.text(name));
meta.lore(List.of(Component.text(INSTRUCTION)));
meta.lore(INSTRUCTION);
item.setItemMeta(meta);
return item;
}
@@ -17,6 +17,7 @@ import org.bukkit.persistence.PersistentDataType;
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
*/
public final class SessionControls {
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
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");
@@ -51,6 +52,16 @@ public final class SessionControls {
public record Identity(UUID owner, UUID session, int slot) { }
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
this.registry = Objects.requireNonNull(registry);
}
private Icon icon(String ability) {
var definition = registry.find(ability).orElse(null);
return definition == null ? ABILITIES.get(ability) : new Icon(definition.icon(), definition.name());
}
/**
* 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.
@@ -62,10 +73,11 @@ public final class SessionControls {
var items = new ItemStack[9];
for (int slot = 0; slot < items.length; slot++) {
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
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));
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
var item = new ItemStack(icon.material());
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
@@ -76,7 +88,7 @@ public final class SessionControls {
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");
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
item.setItemMeta(meta);
items[slot] = item;
@@ -107,7 +119,15 @@ public final class SessionControls {
}
}
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
/** A structurally recognized selectable ability; still not session/cast authorization. */
public Optional<String> ability(ItemStack item) {
if (identify(item).isEmpty()) return Optional.empty();
var data = item.getPersistentDataContainer();
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
}
private 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);
@@ -115,7 +135,7 @@ public final class SessionControls {
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);
Icon icon = icon(ability);
return kind.equals("ability") ? icon != null && icon.material() == material
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.book.BookPagination;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.util.ArrayList;
@@ -35,14 +36,26 @@ public final class TailBooks {
meta.setGeneration(BookMeta.Generation.ORIGINAL);
meta.displayName(Component.text(fullTitle));
List<Component> pages = new ArrayList<>();
pages.add(Component.text(fullTitle));
pages.add(Component.text("Level: " + character.level()));
pages.add(Component.text("XP: " + character.experience()));
pages.add(Component.text("Unspent points: " + character.unspentPoints()));
for (Stat stat : Stat.values()) pages.add(Component.text(stat + ": " + character.stats().get(stat)));
pages.add(Component.text("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
character.learned().stream().sorted().forEach(ability -> pages.add(Component.text(ability)));
pages.add(Component.text("Fell: " + character.diedAt()));
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
pages.addAll(BookPagination.pages(summary.toString()));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
for (int slot = 0; slot < character.loadout().size(); slot++) {
if (character.loadout().get(slot).equals(ability)) slots.add(Integer.toString(slot + 1));
}
var section = new StringBuilder("Ability: ").append(ability)
.append("\nSlots: ").append(slots.isEmpty() ? "none" : String.join(", ", slots));
var deadline = character.cooldowns().get(ability);
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
pages.addAll(BookPagination.pages(section.toString()));
});
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
pages.addAll(BookPagination.pages("Fell:\n" + character.diedAt()));
meta.pages(pages);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -0,0 +1,253 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatContributions;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.SessionVisuals;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
/** Accepted native contribution capture and durable character-bound XP delivery. */
public final class CombatExperience implements Listener, AutoCloseable {
public record Failure(UUID character, Throwable cause) { }
private record Defeat(EquipmentSessions.Active active, int level) { }
private static final Set<EntityType> PASSIVE = Set.of(
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
private final CharacterStorage characters;
private final ProgressionRules progression;
private final CombatRewardRules rewards;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final CombatSources sources;
private final CombatContributions contributions;
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
private org.bukkit.scheduler.BukkitTask retryTask;
private boolean closed;
private final class PendingAward {
final UUID id = UUID.randomUUID();
final UUID character;
final BigInteger reward;
final CombatRewardRules.KillPosition restricted;
BigInteger expectedRevision;
boolean running;
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
this.character = character; this.reward = reward; this.restricted = restricted;
}
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || event.getFinalDamage() <= 0) return;
var origin = source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
|| event.getEntity().getUniqueId().equals(origin.player())) return;
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
}
private CombatSources.Origin source(EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = sources.read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
return origin;
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void beforePlayerDeath(PlayerDeathEvent event) {
if (closed) return;
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) defeats.put(event, new Defeat(active, record.level()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void playerDeath(PlayerDeathEvent event) {
var defeat = defeats.remove(event);
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
award(event.getEntity(), classify(event.getEntity()), 0, null);
}
@EventHandler(priority = EventPriority.MONITOR)
public void removed(EntityRemoveEvent event) {
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
contributions.invalidate(event.getEntity().getUniqueId());
}
}
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
Instant now = clock.instant();
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
if (contributors.isEmpty()) return;
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
for (UUID character : contributors) {
var operation = new PendingAward(character, share, restricted);
pending.put(operation.id, operation);
attempt(operation);
}
}
private static CombatRewardRules.KillPosition position(Location location) {
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
}
private void attempt(PendingAward operation) {
synchronized (operation) {
if (closed || operation.running || !pending.containsKey(operation.id)) return;
operation.running = true;
}
if (operation.expectedRevision == null) {
characters.find(operation.character).whenComplete((record, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
operation.expectedRevision = record.orElseThrow().revision();
attempt(operation);
});
return;
}
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
}
pending.remove(operation.id);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
try { nextTick.accept(() -> acknowledge(operation, award)); }
catch (RuntimeException rejected) { report(operation.character, rejected); }
});
}
private void fail(PendingAward operation, Throwable failure) {
report(operation.character, failure);
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
}
public void retryPending() {
if (closed) return;
for (int attempted = 0; attempted < 64; attempted++) {
UUID id = retries.poll();
if (id == null) break;
var operation = pending.get(id);
if (operation != null) attempt(operation);
}
}
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
}
static CombatRewardRules.Target classify(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
}
@Override public void close() {
closed = true;
if (retryTask != null) retryTask.cancel();
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
}
}
@@ -0,0 +1,66 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.persistence.PersistentDataType;
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
final class CombatSources {
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
record Origin(UUID player, UUID session, UUID character) {
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
static Origin decode(String value) {
if (value == null || value.length() > 110) return null;
var parts = value.split("/", -1);
if (parts.length != 3) return null;
try {
var origin = new Origin(UUID.fromString(parts[0]), parts[1].equals("-") ? null : UUID.fromString(parts[1]),
parts[2].equals("-") ? null : UUID.fromString(parts[2]));
return (origin.session == null) == (origin.character == null) && value.equals(origin.encode()) ? origin : null;
} catch (IllegalArgumentException malformed) { return null; }
}
}
private final EquipmentSessions sessions;
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
Origin player(UUID player) {
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
.orElseGet(() -> new Origin(player, null, null));
}
boolean current(Origin origin) {
return origin.equals(player(origin.player())) && (origin.session() == null
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
}
Origin read(Entity entity) { return read(entity, 0); }
private Origin read(Entity entity, int depth) {
if (entity == null || depth >= 8) return null;
if (entity instanceof Player player) return player(player.getUniqueId());
var saved = Origin.decode(entity.getPersistentDataContainer().get(ORIGIN, PersistentDataType.STRING));
if (saved != null) return saved;
if (entity instanceof org.bukkit.entity.Tameable pet && pet.getOwnerUniqueId() != null) {
return player(pet.getOwnerUniqueId()); // A pet's new attack, not its old taming session.
}
var owner = owner(entity, depth + 1);
// No launch proof (e.g. predating plugin startup): never assign a current fantasy session.
return owner == null ? null : new Origin(owner.player(), null, null);
}
private Origin owner(Entity entity, int depth) {
if (entity instanceof Projectile projectile && projectile.getShooter() instanceof Entity shooter) return read(shooter, depth);
if (entity instanceof org.bukkit.entity.Firework firework && firework.getSpawningEntity() != null) return player(firework.getSpawningEntity());
if (entity instanceof org.bukkit.entity.TNTPrimed tnt) return read(tnt.getSource(), depth);
if (entity instanceof org.bukkit.entity.EvokerFangs fangs) return read(fangs.getOwner(), depth);
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Entity source) return read(source, depth);
return null;
}
void launch(Entity entity) {
if (entity.getPersistentDataContainer().has(ORIGIN)) return;
store(entity, owner(entity, 0));
}
void store(Entity entity, Origin origin) {
if (origin != null) entity.getPersistentDataContainer().set(ORIGIN, PersistentDataType.STRING, origin.encode());
}
}
@@ -0,0 +1,119 @@
package games.dmg.fantasystick.party;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Bounded provenance for native damage layers, not copied effects or character/XP state.
* Native effects remain authoritative. Durations reconcile in native ticks (also across offline saves),
* so a hidden effect regaining the foreground keeps its own source, not a covering effect's source.
*/
final class OngoingDamage {
record Application(CombatSources.Origin source, CombatSources.Origin target) { }
private record Layer(int amplifier, int duration, Application application) { }
private static final List<DamageCause> CAUSES = List.of(DamageCause.POISON, DamageCause.WITHER, DamageCause.FIRE_TICK);
private static NamespacedKey key(DamageCause cause) {
return new NamespacedKey("fantasystick", "combat_dot_" + cause.name().toLowerCase(java.util.Locale.ROOT) + "_v1");
}
static DamageCause cause(PotionEffectType type) {
if (type.equals(PotionEffectType.POISON)) return DamageCause.POISON;
if (type.equals(PotionEffectType.WITHER)) return DamageCause.WITHER;
return null;
}
static void clear(Player player, DamageCause cause) { player.getPersistentDataContainer().remove(key(cause)); }
static void potion(Player player, PotionEffect old, PotionEffect incoming, boolean override, Application application) {
var cause = cause(incoming.getType());
var layers = old == null ? new ArrayList<Layer>() : reconcile(load(player, cause), old.getAmplifier(), old.getDuration());
var next = new Layer(incoming.getAmplifier(), incoming.getDuration(), application);
// Purpur also stores a longer weaker effect behind the active one when override=false.
if (layers.isEmpty() || override || next.amplifier() < layers.getFirst().amplifier()
&& longer(next.duration(), layers.getFirst().duration())) merge(layers, 0, next);
save(player, cause, layers);
}
private static boolean longer(int a, int b) { return a == -1 ? b != -1 : b != -1 && a > b; }
private static void merge(List<Layer> layers, int index, Layer next) {
if (index == layers.size()) { layers.add(next); return; }
var old = layers.get(index);
if (next.amplifier() > old.amplifier()) {
if (longer(old.duration(), next.duration())) layers.add(index, next);
else layers.set(index, next);
} else if (next.amplifier() == old.amplifier()) {
if (longer(next.duration(), old.duration())) layers.set(index, next);
} else if (longer(next.duration(), old.duration())) merge(layers, index + 1, next);
}
static void fire(Player player, int duration, Application application) {
save(player, DamageCause.FIRE_TICK, List.of(new Layer(0, duration, application)));
}
static Application read(Player player, DamageCause cause) {
if (!CAUSES.contains(cause) || !player.getPersistentDataContainer().has(key(cause))) return null;
List<Layer> layers = load(player, cause);
if (cause == DamageCause.FIRE_TICK) {
int fire = player.getFireTicks();
layers = fire > 0 && !layers.isEmpty() && fire <= layers.getFirst().duration()
? List.of(new Layer(0, fire, layers.getFirst().application())) : List.of();
} else {
var effect = player.getPotionEffect(cause == DamageCause.POISON ? PotionEffectType.POISON : PotionEffectType.WITHER);
layers = effect == null ? List.of() : reconcile(layers, effect.getAmplifier(), effect.getDuration());
}
save(player, cause, layers);
return layers.isEmpty() ? null : layers.getFirst().application();
}
static void sweep(Player player) { for (var cause : CAUSES) read(player, cause); }
private static ArrayList<Layer> reconcile(List<Layer> saved, int amplifier, int duration) {
var result = new ArrayList<Layer>();
for (int i = 0; i < saved.size(); i++) {
var layer = saved.get(i);
if (layer.amplifier() != amplifier || longer(duration, layer.duration())) continue;
int elapsed = layer.duration() == -1 ? 0 : layer.duration() - duration;
for (int j = i; j < saved.size(); j++) {
var remaining = saved.get(j);
int ticks = remaining.duration() == -1 ? -1 : Math.max(0, remaining.duration() - elapsed);
if (ticks != 0) result.add(new Layer(remaining.amplifier(), ticks, remaining.application()));
}
return result;
}
// Unknown/out-of-band replacement never borrows old attribution or gains party immunity.
if (duration != 0) result.add(new Layer(amplifier, duration, null));
return result;
}
private static List<Layer> load(Player player, DamageCause cause) {
String value = player.getPersistentDataContainer().get(key(cause), PersistentDataType.STRING);
if (value == null || value.length() > 65536) return List.of();
var result = new ArrayList<Layer>();
try {
for (var row : value.split("\n", -1)) {
var cells = row.split(",", -1);
if (cells.length != 4 || result.size() >= 256) return List.of();
int amplifier = Integer.parseInt(cells[0]), duration = Integer.parseInt(cells[1]);
if (amplifier < 0 || amplifier > 255 || duration < -1 || duration == 0
|| !result.isEmpty() && amplifier >= result.getLast().amplifier()) return List.of();
Application application = null;
if (!cells[2].equals("-") || !cells[3].equals("-")) {
var source = CombatSources.Origin.decode(cells[2]);
var target = CombatSources.Origin.decode(cells[3]);
if (source == null || target == null || !target.player().equals(player.getUniqueId())) return List.of();
application = new Application(source, target);
}
result.add(new Layer(amplifier, duration, application));
}
} catch (IllegalArgumentException malformed) { return List.of(); }
return result;
}
private static void save(Player player, DamageCause cause, List<Layer> layers) {
if (layers.stream().noneMatch(layer -> layer.application() != null)) { clear(player, cause); return; }
var value = new StringBuilder();
for (var layer : layers) {
if (!value.isEmpty()) value.append('\n');
value.append(layer.amplifier()).append(',').append(layer.duration()).append(',');
var application = layer.application();
value.append(application == null ? "-,-" : application.source().encode() + "," + application.target().encode());
}
player.getPersistentDataContainer().set(key(cause), PersistentDataType.STRING, value.toString());
}
}
@@ -0,0 +1,127 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntitySpawnEvent;
import org.bukkit.event.entity.LingeringPotionSplashEvent;
/** Server-thread native damage protection; never uncancels another protection's decision. */
public final class PartyCombat implements Listener, AutoCloseable {
private org.bukkit.scheduler.BukkitTask cleanup;
private boolean closed;
private final PartyRoster parties;
private final CombatSources sources;
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
this.parties = Objects.requireNonNull(parties);
this.sources = new CombatSources(sessions);
}
public void install(org.bukkit.plugin.Plugin plugin) {
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
}, 1, 1);
}
@Override public void close() {
closed = true;
if (cleanup != null) cleanup.cancel();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void launch(EntitySpawnEvent event) { sources.launch(event.getEntity()); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void splash(org.bukkit.event.entity.PotionSplashEvent event) {
// Native splash passes only its owner to addEffect, losing the projectile's session.
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
// actual harmful applications and instantaneous damage have their own guards below.
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void staleLingering(LingeringPotionSplashEvent event) {
var origin = sources.read(event.getEntity());
if (origin != null && !sources.current(origin)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void lingering(LingeringPotionSplashEvent event) {
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
event.setCancelled(true); return;
}
}
var origin = sources.read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
if (origin != null && (!sources.current(origin)
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void potion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
var origin = sources.read(event.getSource());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var cause = OngoingDamage.cause(event.getModifiedType());
if (cause == null) return;
if (event.getNewEffect() == null) OngoingDamage.clear(target, cause);
else {
var origin = sources.read(event.getSource());
OngoingDamage.potion(target, event.getOldEffect(), event.getNewEffect(), event.isOverride(), origin == null ? null
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var origin = sources.read(event.getCombuster());
if (origin != null && blocked(origin, target)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedCombust(org.bukkit.event.entity.EntityCombustEvent event) {
if (!(event.getEntity() instanceof Player target)) return;
var origin = event instanceof org.bukkit.event.entity.EntityCombustByEntityEvent entity
? sources.read(entity.getCombuster()) : null;
int duration = Math.max(target.getFireTicks(), (int) Math.ceil(event.getDuration() * 20));
OngoingDamage.fire(target, duration, origin == null ? null
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
private boolean blocked(CombatSources.Origin origin, Player target) {
var recipient = sources.player(target.getUniqueId());
boolean fantasy = origin.session() != null;
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
&& !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
}
}
@@ -0,0 +1,17 @@
package games.dmg.fantasystick.party;
import java.util.Objects;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
/** Quit is authoritative even before Bukkit's online flag changes. */
public final class PartyEvents implements Listener {
private final PartyRoster parties;
public PartyEvents(PartyRoster parties) { this.parties = Objects.requireNonNull(parties); }
@EventHandler(priority = EventPriority.MONITOR)
public void join(org.bukkit.event.player.PlayerJoinEvent event) { parties.arrive(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR)
public void quit(PlayerQuitEvent event) { parties.disconnect(event.getPlayer().getUniqueId()); }
}
@@ -0,0 +1,122 @@
package games.dmg.fantasystick.party;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
public final class PartyRoster implements AutoCloseable {
public record Party(UUID id, UUID leader, List<UUID> members) {
public Party { members = List.copyOf(members); }
}
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
private final Map<UUID, Party> membership = new HashMap<>();
private final Map<UUID, Invitation> invitations = new HashMap<>();
private final Set<UUID> departed = new HashSet<>();
private boolean closed;
private final Clock clock;
private final Predicate<UUID> online;
public PartyRoster(Clock clock, Predicate<UUID> online) {
this.clock = Objects.requireNonNull(clock);
this.online = Objects.requireNonNull(online);
}
public Optional<Party> create(UUID player) {
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
var party = new Party(UUID.randomUUID(), player, List.of(player));
membership.put(player, party);
return Optional.of(party);
}
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
public List<Invitation> invitationsFor(UUID player) {
expire();
return invitations.values().stream().filter(i -> i.invitee().equals(player))
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
}
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
expire();
var group = managedBy(leader, party);
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
if (existing.isPresent()) return existing;
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
invitations.put(invitation.id(), invitation);
return Optional.of(invitation);
}
public boolean accept(UUID player, UUID invitation) {
expire();
var offer = invitations.get(invitation);
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
var group = membership.get(offer.leader());
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
var members = new ArrayList<>(group.members());
members.add(player);
var updated = new Party(group.id(), group.leader(), members);
for (var member : members) membership.put(member, updated);
invitations.values().removeIf(i -> i.invitee().equals(player));
return true;
}
public boolean kick(UUID leader, UUID party, UUID member) {
var group = managedBy(leader, party);
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
removeMember(group, member);
return true;
}
public boolean disband(UUID leader, UUID party) {
var group = managedBy(leader, party);
if (group == null) return false;
remove(group);
return true;
}
private Party managedBy(UUID leader, UUID party) {
var group = membership.get(leader);
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
}
public boolean leave(UUID player, UUID party) {
var group = membership.get(player);
if (group == null || !group.id().equals(party) || !connected(player)) return false;
if (group.leader().equals(player)) remove(group);
else removeMember(group, player);
return true;
}
private void removeMember(Party group, UUID player) {
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
membership.remove(player);
departed.remove(player);
var updated = new Party(group.id(), group.leader(), remaining);
for (var member : remaining) membership.put(member, updated);
}
public void arrive(UUID player) { departed.remove(player); }
public void disconnect(UUID player) {
var group = membership.get(player);
if (group == null) return;
departed.add(player);
if (group.members().stream().noneMatch(this::connected)) remove(group);
}
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
private void expire() {
var now = clock.instant();
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
}
private void remove(Party group) {
for (var member : group.members()) membership.remove(member);
departed.removeAll(group.members());
invitations.values().removeIf(i -> i.party().equals(group.id()));
}
@Override public void close() {
closed = true;
membership.clear();
invitations.clear();
departed.clear();
}
}
@@ -0,0 +1,132 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
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.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
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;
/** Worker-owned config writes; publish a new-session policy only after durable file acknowledgment. */
public final class AdventureSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-configuration-persistence"));
private volatile boolean enabled;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public AdventureSettings(Path directory, boolean initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
AdventureSettings(Path directory, boolean initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
enabled = initial;
}
public static boolean validate(Object configured) {
if (configured == null) return true;
if (configured instanceof Boolean value) return value;
throw new IllegalArgumentException("adventure-mode must be true or false");
}
public boolean enabled() { return enabled; }
public synchronized CompletionStage<Boolean> set(boolean value) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
try {
return CompletableFuture.supplyAsync(() -> {
try { save(value); enabled = value; return value; }
catch (IOException | RuntimeException failure) {
// No parser contents, filesystem paths or configuration values in diagnostics.
throw new java.util.concurrent.CompletionException(new IOException(
"Adventure configuration save refused or failed; effective setting unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private void save(boolean value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
values.put(key, entry.getValue());
}
}
}
values.put("adventure-mode", value);
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
/** Drain accepted writes without waiting on the server thread. */
@Override public synchronized void close() {
closed = true;
worker.shutdown();
if (pending == 0) drained.complete(null);
}
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -16,7 +17,16 @@ public record CharacterRecord(
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
cooldowns, Map.of(), dead, deathId, diedAt);
}
public CharacterRecord {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
@@ -28,6 +38,7 @@ public record CharacterRecord(
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
cooldowns = Map.copyOf(cooldowns);
restrictedKills = Map.copyOf(restrictedKills);
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
@@ -35,6 +46,8 @@ public record CharacterRecord(
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
}
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.io.IOException;
import java.math.BigInteger;
import java.time.Clock;
@@ -204,12 +206,237 @@ public final class CharacterStorage implements AutoCloseable {
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
}
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist configured transferable XP/level/point progression as one expected revision. */
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules rules) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward);
Objects.requireNonNull(rules);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
}
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
}
var experience = old.experience().add(reward).min(cap);
if (experience.equals(old.experience())) {
var status = old.level() >= rules.maximumLevel()
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
return new ExperienceAward(status, Optional.of(old), 0);
}
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist one combat share and its restricted-kill reference in the same character revision. */
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules progression,
games.dmg.fantasystick.progression.CombatRewardRules rewards,
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
}
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
var history = new java.util.HashMap<>(old.restrictedKills());
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
return new CombatAward(status, Optional.of(old), 0);
}
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
}
public enum AbilityMutationStatus {
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
}
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
/** Unlock one validated registered ID and spend its points in the same expected revision. */
public CompletableFuture<AbilityMutation> unlockAbility(UUID characterId, BigInteger expectedRevision, String ability,
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
throw new IllegalArgumentException("Invalid ability unlock");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.ALREADY_LEARNED, Optional.of(old));
if (requirements.entrySet().stream().anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
String ability, int slot) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
if (ability.isBlank() || slot < 0 || slot >= 7) throw new IllegalArgumentException("Invalid ability assignment");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
var loadout = new java.util.ArrayList<>(old.loadout());
for (int index = 0; index < loadout.size(); index++) if (loadout.get(index).equals(ability)) loadout.set(index, "");
loadout.set(slot, ability);
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Commit a real-time character/ability deadline before exposing the cast's native effect. */
public CompletableFuture<AbilityMutation> commitAbilityCooldown(UUID characterId, BigInteger expectedRevision,
String ability, Instant readyAt) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(readyAt);
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability cooldown");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
Instant previous = old.cooldowns().get(ability);
if (previous != null && previous.isAfter(clock.instant())) return new AbilityMutation(AbilityMutationStatus.COOLDOWN, Optional.of(old));
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
/** Persist one active-session stat purchase and its current native health as one revision. */
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
java.math.BigDecimal currentHealth,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(stat);
Objects.requireNonNull(currentHealth);
Objects.requireNonNull(nativeLimit);
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
throw new IllegalArgumentException("Invalid stat-purchase health bound");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
if (old.unspentPoints().signum() == 0) {
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
}
var oldMaximum = old.maximumHealth().min(nativeLimit);
if (currentHealth.compareTo(oldMaximum) > 0) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
stats.putAll(old.stats());
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
}
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
}
/**
* 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
@@ -235,7 +462,7 @@ public final class CharacterStorage implements AutoCloseable {
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, deathId, diedAt);
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -255,7 +482,7 @@ public final class CharacterStorage implements AutoCloseable {
}
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -46,6 +46,13 @@ final class CharacterYaml {
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
root.put("cooldowns", cooldowns);
List<Map<String, String>> restrictedKills = new ArrayList<>();
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
var position = entry.getValue();
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
"y", position.y().toString(), "z", position.z().toString()));
});
root.put("restricted-kills", restrictedKills);
root.put("dead", record.dead());
if (record.deathId() != null) {
root.put("death-id", record.deathId().toString());
@@ -74,11 +81,19 @@ final class CharacterYaml {
throw new IllegalArgumentException("Duplicate cooldown");
}
}
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
Map<?, ?> row = object(value);
UUID world = UUID.fromString(text(row.get("world")));
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
}
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
number(root.get("experience")), number(root.get("unspent-points")),
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
}
@@ -48,11 +48,18 @@ public final class EquipmentSessionStore implements AutoCloseable {
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-equipment-session-persistence"));
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt) {
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode, OrdinaryVitals ordinaryVitals) {
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null, null); }
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
this(sessionId, characterId, preparedAt, previousGameMode, null);
}
public Receipt {
Objects.requireNonNull(sessionId);
Objects.requireNonNull(characterId);
timestamp(preparedAt);
if (previousGameMode != null && !Set.of("SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR").contains(previousGameMode)) {
throw new IllegalArgumentException("Invalid previous game mode");
}
}
}
@@ -106,16 +113,26 @@ public final class EquipmentSessionStore implements AutoCloseable {
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) {
return prepare(player, session, character, null);
}
/** A receipt acknowledgment precedes mode/gear mutation; it is not proof either happened. */
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode) {
return prepare(player, session, character, previousGameMode, null);
}
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode, OrdinaryVitals ordinaryVitals) {
Objects.requireNonNull(player);
var receipt = new Receipt(session, character, clock.instant());
var receipt = new Receipt(session, character, clock.instant(), previousGameMode, ordinaryVitals);
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())) {
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())
&& Objects.equals(previousGameMode, old.pending().previousGameMode())
&& Objects.equals(ordinaryVitals, old.pending().ordinaryVitals())) {
return old;
}
throw new IOException("Conflicting pending equipment session");
@@ -282,8 +299,13 @@ public final class EquipmentSessionStore implements AutoCloseable {
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()));
var pending = new LinkedHashMap<String, Object>();
pending.put("session-id", receipt.sessionId().toString());
pending.put("character-id", receipt.characterId().toString());
pending.put("prepared-at", receipt.preparedAt().toString());
if (receipt.previousGameMode() != null) pending.put("previous-game-mode", receipt.previousGameMode());
if (receipt.ordinaryVitals() != null) pending.put("ordinary-vitals", receipt.ordinaryVitals().encode());
root.put("pending", pending);
}
if (state.lastExit() != null) {
var exit = state.lastExit();
@@ -304,8 +326,14 @@ public final class EquipmentSessionStore implements AutoCloseable {
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")));
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode", "ordinary-vitals"));
String previousMode = null;
if (row.containsKey("previous-game-mode")) {
if (!(row.get("previous-game-mode") instanceof String value)) throw new IllegalArgumentException("Invalid previous mode");
previousMode = value;
}
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode,
row.containsKey("ordinary-vitals") ? OrdinaryVitals.decode(text(row.get("ordinary-vitals"))) : null);
}
if (root.containsKey("last-exit")) {
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
@@ -0,0 +1,27 @@
package games.dmg.fantasystick.persistence;
/** Holder-owned ordinary state, never transferable character progress or an attribute snapshot. */
public record OrdinaryVitals(double health, int food, float saturation, float exhaustion,
boolean scaled, double scale) {
public OrdinaryVitals {
if (!Double.isFinite(health) || health < 0 || food < 0 || food > 20
|| !Float.isFinite(saturation) || saturation < 0 || !Float.isFinite(exhaustion) || exhaustion < 0
|| !Double.isFinite(scale) || scale <= 0) throw new IllegalArgumentException("Invalid ordinary vitals");
}
public String encode() {
return health + ";" + food + ";" + saturation + ";" + exhaustion + ";" + scaled + ";" + scale;
}
public static OrdinaryVitals decode(String encoded) {
if (encoded.length() > 256) throw new IllegalArgumentException("Oversized ordinary vitals");
String[] fields = encoded.split(";", -1);
if (fields.length != 6 || !fields[4].equals("true") && !fields[4].equals("false")) {
throw new IllegalArgumentException("Invalid ordinary vitals fields");
}
var value = new OrdinaryVitals(Double.parseDouble(fields[0]), Integer.parseInt(fields[1]),
Float.parseFloat(fields[2]), Float.parseFloat(fields[3]), Boolean.parseBoolean(fields[4]), Double.parseDouble(fields[5]));
if (!encoded.equals(value.encode())) throw new IllegalArgumentException("Noncanonical ordinary vitals");
return value;
}
}
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.progression;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Server-thread bounded contribution ledger keyed by target and originating character. */
public final class CombatContributions implements AutoCloseable {
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
public void accept(UUID target, UUID character, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
if (closed) return;
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
(previous, next) -> previous.isAfter(next) ? previous : next);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
var contributions = targets.remove(target);
if (contributions == null) return Set.of();
var eligible = new LinkedHashSet<UUID>();
contributions.forEach((character, acceptedAt) -> {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
return Set.copyOf(eligible);
}
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
public void sweep(Instant now) {
Objects.requireNonNull(now);
if (closed) return;
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
targets.values().removeIf(Map::isEmpty);
}
@Override public void close() { closed = true; targets.clear(); }
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick.progression;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
public KillPosition {
Objects.requireNonNull(world);
x = coordinate(x); y = coordinate(y); z = coordinate(z);
}
private static BigDecimal coordinate(BigDecimal value) {
Objects.requireNonNull(value);
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
return value;
}
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public BigInteger pool(Target target, int defeatedLevel) {
Objects.requireNonNull(target);
if (target == Target.FANTASY_CHARACTER) {
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
}
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
}
public BigInteger share(BigInteger pool, int contributors) {
Objects.requireNonNull(pool);
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
var count = BigInteger.valueOf(contributors);
return pool.add(count).subtract(BigInteger.ONE).divide(count);
}
public boolean contributes(Instant acceptedAt, Instant deathAt) {
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
}
public boolean spaced(KillPosition previous, KillPosition current) {
Objects.requireNonNull(previous); Objects.requireNonNull(current);
if (!previous.world().equals(current.world())) return true;
BigDecimal dx = current.x().subtract(previous.x());
BigDecimal dy = current.y().subtract(previous.y());
BigDecimal dz = current.z().subtract(previous.z());
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
}
}
@@ -0,0 +1,90 @@
package games.dmg.fantasystick.session;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.persistence.PersistentDataType;
/** Main-thread holder/session proof for optional Adventure mode; the receipt must already be saved. */
public final class AdventureModeLease implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
public void changed(org.bukkit.event.player.PlayerGameModeChangeEvent event) {
if (event.isCancelled()) return;
var player = event.getPlayer();
var mark = read(player);
// Events precede the write. A currently non-Adventure mode proves a previous
// external change already happened, even when this new request returns to Adventure.
// A lone external change is also detected directly by restore(). Never infer success
// from this event's requested value, which a later listener may still cancel.
if (mark != null && mark.phase() == Phase.APPLIED && player.getGameMode() != GameMode.ADVENTURE) {
write(player, new Mark(mark.session(), mark.previous(), Phase.RELEASED));
}
}
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "adventure_session");
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
private record Mark(UUID session, String previous, Phase phase) {
boolean matches(UUID expected, String original) { return session.equals(expected) && previous.equals(original); }
}
public boolean available(Player player) {
var mark = read(player);
return !player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED;
}
public boolean enter(Player player, UUID session, String previous) {
if (previous == null) return true;
var existing = read(player);
if (player.getPersistentDataContainer().has(KEY) && (existing == null || existing.phase() != Phase.RELEASED)) {
return existing != null && existing.matches(session, previous) && existing.phase() == Phase.APPLIED
&& player.getGameMode() == GameMode.ADVENTURE;
}
if (player.getGameMode() != GameMode.valueOf(previous)) return false;
write(player, new Mark(session, previous, Phase.APPLYING));
player.setGameMode(GameMode.ADVENTURE);
if (player.getGameMode() != GameMode.ADVENTURE) {
write(player, new Mark(session, previous, Phase.RELEASED));
return false;
}
write(player, new Mark(session, previous, Phase.APPLIED));
return true;
}
public boolean restore(Player player, UUID session, String previous) {
if (previous == null) return true;
var mark = read(player);
if (mark == null || !mark.matches(session, previous)) return false;
if (mark.phase() == Phase.RELEASED) return true;
if (mark.phase() != Phase.APPLIED) return false; // Interrupted setter is not restoration proof.
if (player.getGameMode() != GameMode.ADVENTURE) {
write(player, new Mark(session, previous, Phase.RELEASED));
return true; // A later external/admin change is not ours to undo.
}
write(player, new Mark(session, previous, Phase.RESTORING));
player.setGameMode(GameMode.valueOf(previous));
if (player.getGameMode() == GameMode.ADVENTURE && !previous.equals("ADVENTURE")) {
write(player, new Mark(session, previous, Phase.APPLIED));
return false; // Native veto: unchanged and still owned, not successful restoration.
}
write(player, new Mark(session, previous, Phase.RELEASED));
return true;
}
private static Mark read(Player player) {
var data = player.getPersistentDataContainer();
if (!data.has(KEY, PersistentDataType.STRING)) return null;
try {
String[] fields = data.get(KEY, PersistentDataType.STRING).split("\\|", -1);
if (fields.length != 4 || !fields[0].equals("1")) return null;
var session = UUID.fromString(fields[1]);
if (!session.toString().equals(fields[1])) return null;
GameMode.valueOf(fields[2]);
return new Mark(session, fields[2], Phase.valueOf(fields[3]));
} catch (IllegalArgumentException invalid) { return null; }
}
private static void write(Player player, Mark mark) {
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
"1|" + mark.session() + "|" + mark.previous() + "|" + mark.phase());
}
}
@@ -0,0 +1,31 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import java.util.function.BooleanSupplier;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/**
* Internal server-thread input port for book/combat/ability adapters, not an add-on API.
* Containment of vanilla item behavior is separate from executing an ability. Recipients
* must revalidate after deferred work; this notification is never durable cast authority.
*/
public record ControlInput(Player player, EquipmentSessions.Active session, int slot, String ability,
Kind kind, Gesture gesture, Entity target, int heldTicks, float attackStrength,
BooleanSupplier validity) {
public enum Kind { BOOK, BASIC_ATTACK, ABILITY }
public enum Gesture { USE, ATTACK, DRAW_RELEASE }
public ControlInput {
Objects.requireNonNull(player);
Objects.requireNonNull(session);
Objects.requireNonNull(ability);
Objects.requireNonNull(kind);
Objects.requireNonNull(gesture);
Objects.requireNonNull(validity);
if (slot < 0 || slot > 8 || heldTicks < 0 || !Float.isFinite(attackStrength)
|| attackStrength < 0 || attackStrength > 1) throw new IllegalArgumentException("Invalid control input");
}
public boolean stillCurrent() { return validity.getAsBoolean(); }
}
@@ -20,6 +20,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -30,10 +31,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.diagnostics = Objects.requireNonNull(diagnostics);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings) {
this(receipts, sessions, server, nextTick, diagnostics);
this.settings = Objects.requireNonNull(settings);
}
@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.");
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
return true;
}
if (args.length > 0 && args[0].equals("config")) {
configuration(sender, args);
return true;
}
UUID owner;
@@ -96,6 +108,32 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void configuration(CommandSender sender, String[] args) {
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (args.length == 2) {
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
return;
}
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-adventure-mode", failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
: "Adventure configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void report(UUID owner, String operation, Throwable cause) {
try { diagnostics.accept(new Failure(owner, operation, cause)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
@@ -40,7 +40,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private final SessionControls controls = new SessionControls();
private 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<>();
@@ -70,6 +70,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
}
}
private boolean closed;
private Consumer<ControlInput> inputs = ignored -> { };
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
private final Map<UUID, Draw> draws = new HashMap<>();
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick) {
this(sessions, nextTick, failure -> System.getLogger(EquipmentSessionEvents.class.getName())
@@ -82,7 +85,20 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.diagnostics = Objects.requireNonNull(diagnostics);
sessions.attachCursorCustody(this::cancelMove);
sessions.attachCursorCustody(this::cancelCustody);
}
/** Install before registration so extensible controls keep the same structural containment catalogue. */
public void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (closed || !draws.isEmpty()) throw new IllegalStateException("Input handling already started");
controls = new SessionControls(Objects.requireNonNull(registry));
}
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
this(sessions, nextTick, diagnostics);
this.inputs = Objects.requireNonNull(inputs);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -205,6 +221,31 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (blockedClick(player, event)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void recipeBook(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event) {
var player = event.getPlayer();
// Native recipe-book placement can pull directly from the hotbar without a click
// event. Exact recipe ingredients also bypass vanilla's custom-name exclusion.
if (Arrays.stream(player.getOpenInventory().getTopInventory().getContents()).anyMatch(this::special)) {
event.setCancelled(true); return;
}
var recipe = player.getServer().getRecipe(event.getRecipe());
java.util.Collection<org.bukkit.inventory.RecipeChoice> choices;
if (recipe instanceof org.bukkit.inventory.ShapedRecipe shaped) choices = shaped.getChoiceMap().values();
else if (recipe instanceof org.bukkit.inventory.ShapelessRecipe shapeless) choices = shapeless.getChoiceList();
else if (recipe instanceof org.bukkit.inventory.CookingRecipe<?> cooking) choices = java.util.List.of(cooking.getInputChoice());
else return; // Other recipe types do not use these native auto-fill routes.
for (var item : player.getInventory().getStorageContents()) {
if (!special(item)) continue;
boolean ordinaryEligible = net.minecraft.world.entity.player.Inventory.isUsableForCrafting(
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item));
if (choices.stream().filter(Objects::nonNull).anyMatch(choice -> choice.test(item)
&& (ordinaryEligible || choice instanceof org.bukkit.inventory.RecipeChoice.ExactChoice))) {
event.setCancelled(true); return;
}
}
}
private static boolean cursorIndependent(InventoryClickEvent event) {
return switch (event.getAction()) {
case DROP_ONE_SLOT, DROP_ALL_SLOT, MOVE_TO_OTHER_INVENTORY, HOTBAR_SWAP -> true;
@@ -253,7 +294,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
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()));
return !(cosmetic(current) && !forbiddenEquip(current) && empty(player.getInventory().getHelmet()));
}
if (!ownInventory(player, inventory) && mainCapacity(player, current) < current.getAmount()) return true;
}
@@ -294,15 +335,124 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
// 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) {
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
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);
}
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
ControlInput.Gesture.USE, null, 0, 0);
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
}
}
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
UUID owner = player.getUniqueId();
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
var draw = new Draw(player, item.clone(), selected);
draws.put(owner, draw);
try {
// Keep the native bow animation/timing, without BowItem.use's ammunition lookup.
player.startUsingItem(EquipmentSlot.HAND);
if (closed || draws.get(owner) != draw || !sessions.selectedControl(player, draw.item()).filter(selected::equals).isPresent()
|| !draw.item().equals(player.getActiveItem())) cancelDraw(draw);
} catch (RuntimeException failure) {
cancelDraw(draw);
report(owner, selected.session().session(), failure);
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void release(io.papermc.paper.event.player.PlayerStopUsingItemEvent event) {
var player = event.getPlayer();
UUID owner = player.getUniqueId();
var draw = draws.get(owner);
if (draw != null && draw.player() != player) draw = null;
if (draw != null) draws.remove(owner, draw); // One-use, before a native setter or recipient can reenter.
try {
boolean matching = draw != null && draw.item().equals(event.getItem())
&& draw.item().equals(player.getActiveItem()) && event.getTicksHeldFor() >= 0;
// Purpur 2618 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
// Clear it first: cancelling EntityShootBowEvent alone is too late to own ammo draw.
if (special(event.getItem()) && event.getItem().equals(player.getActiveItem())) player.clearActiveItem();
if (matching) emit(player, draw.item(), draw.selected(), ControlInput.Gesture.DRAW_RELEASE,
null, event.getTicksHeldFor(), 0);
} catch (RuntimeException failure) {
// Unknown native state is not a completed cast or authority to replay an input.
report(owner, draw == null ? null : draw.selected().session().session(), failure);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void held(org.bukkit.event.player.PlayerItemHeldEvent event) {
var draw = draws.get(event.getPlayer().getUniqueId());
if (draw != null && draw.player() == event.getPlayer() && event.getPreviousSlot() != event.getNewSlot()) cancelDraw(draw);
}
private void cancelCustody(UUID owner) {
try { cancelMove(owner); }
finally {
var draw = draws.get(owner);
if (draw != null) cancelDraw(draw);
}
}
private void cancelDraw(Draw draw) {
draws.remove(draw.player().getUniqueId(), draw);
try {
if (draw.item().equals(draw.player().getActiveItem())) draw.player().clearActiveItem();
} catch (RuntimeException failure) {
report(draw.player().getUniqueId(), draw.selected().session().session(), failure);
}
}
private static boolean useGesture(EquipmentSessions.SelectedControl selected) {
return selected.slot() == 0 || selected.slot() >= 2
&& !selected.ability().equals("great_sword") && !selected.ability().equals("longbow");
}
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
var captured = item.clone();
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
gesture, target, heldTicks, strength,
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
if (!input.stillCurrent()) return;
try { inputs.accept(input); }
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
}
@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);
var player = event.getPlayer();
var item = player.getInventory().getItem(event.getHand());
if (special(item)) event.setCancelled(true);
if (event.getHand() == EquipmentSlot.HAND) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && useGesture(selected)) emit(player, item, selected,
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void attack(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event) {
// The ability/basic-attack adapter owns intentional damage. Never also execute
// vanilla Player.attack with the icon's material, enchantments or attributes.
var player = event.getPlayer();
var item = player.getInventory().getItemInMainHand();
boolean willAttack = event.willAttack();
if (special(item)) event.setCancelled(true);
if (willAttack) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -571,7 +721,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR)
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
UUID owner = event.getPlayer().getUniqueId();
cancelMove(owner);
cancelCustody(owner);
swaps.remove(owner);
sessions.invalidateTail(owner);
}
@@ -695,6 +845,9 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private void reconcile(Player player) {
UUID owner = player.getUniqueId();
var draw = draws.get(owner);
if (draw != null && draw.player() == player
&& !sessions.selectedControl(player, draw.item()).filter(draw.selected()::equals).isPresent()) cancelDraw(draw);
if (closed || writing.contains(owner) || !player.isOnline()) return;
var marker = EquipmentSessionMarker.read(player).orElse(null);
if (marker != null && (marker.phase() == EquipmentSessionMarker.Phase.PACKING
@@ -735,6 +888,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
for (var owner : java.util.List.copyOf(moves.keySet())) cancelMove(owner);
for (var draw : java.util.List.copyOf(draws.values())) cancelDraw(draw);
queued.clear();
swaps.clear();
observed.clear();
@@ -6,6 +6,7 @@ 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.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -24,8 +25,8 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
/**
* Server-thread equipment custody and minimal defeat handling. Fantasy health attributes,
* nutrition and rewards are not applied here. Presentation is an independently cleaned output port. No inventory/file transaction,
* Server-thread equipment custody, optional native vitals overlay and intercepted defeat.
* Rewards and active food overrides remain separate. Presentation is independently cleaned. 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.
*/
@@ -33,6 +34,7 @@ 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) { }
public record SelectedControl(Active session, int slot, String ability) { }
private final CharacterStorage characters;
private final EquipmentSessionStore receipts;
@@ -44,7 +46,7 @@ public final class EquipmentSessions implements AutoCloseable {
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private final SessionControls controls = new SessionControls();
private 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<>();
@@ -54,6 +56,9 @@ public final class EquipmentSessions implements AutoCloseable {
private Consumer<UUID> cursorCustody = ignored -> { };
private boolean cursorCustodyAttached;
private boolean closed;
private java.util.function.BooleanSupplier adventureMode = () -> false;
private AdventureModeLease gameModes = new AdventureModeLease();
private SessionVitals vitals;
private boolean closeSweepFinished;
private int persistenceOperations;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
@@ -62,6 +67,7 @@ public final class EquipmentSessions implements AutoCloseable {
final Player player;
final UUID owner;
UUID character;
CharacterRecord record;
SessionClaims.Claim claim;
UUID session;
ItemStack[] snapshot;
@@ -71,6 +77,14 @@ public final class EquipmentSessions implements AutoCloseable {
boolean acknowledged;
boolean saving;
boolean effectsStarted;
String previousGameMode;
boolean modeStarted;
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
boolean vitalsStarted;
boolean healthSaveRequested;
boolean healthAcknowledged = true;
boolean nativeRestored;
boolean gearRestoredForModeRecovery;
boolean presentationInstalled;
boolean cleaning;
boolean cleanupAttempted;
@@ -78,6 +92,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean nativeUncertain;
boolean exitRequested;
boolean arrival;
Attempt recoveryOwner;
org.bukkit.Location entryLocation;
boolean defeated;
boolean deathResolved;
@@ -118,6 +133,27 @@ public final class EquipmentSessions implements AutoCloseable {
this.presentation = Objects.requireNonNull(presentation);
}
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
games.dmg.fantasystick.presentation.SessionPresentation presentation,
java.util.function.BooleanSupplier adventureMode, AdventureModeLease gameModes) {
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics, presentation);
this.adventureMode = Objects.requireNonNull(adventureMode);
this.gameModes = Objects.requireNonNull(gameModes);
}
/** Install before exposing entry so registered definitions drive delivered control metadata. */
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
controls = new SessionControls(Objects.requireNonNull(registry));
}
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
public synchronized void attachVitals(SessionVitals value) {
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
vitals = Objects.requireNonNull(value);
}
/** 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");
@@ -164,6 +200,70 @@ public final class EquipmentSessions implements AutoCloseable {
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
}
/** Server-thread effect authorization: registry identity alone is not live custody. */
public synchronized boolean current(Active session) {
var attempt = active.get(session.player());
return !closed && attempt != null && attempt.session.equals(session.session())
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
}
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
public synchronized Optional<Active> activeCharacter(UUID character) {
Objects.requireNonNull(character);
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null && attempt.record != null
? Optional.of(attempt.record) : Optional.empty();
}
/** Apply a persisted progression revision to the still-current active session. */
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
var attempt = active.get(session.player());
if (closed || attempt == null || attempt.session == null || attempt.record == null
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if (!previous.loadout().equals(saved.loadout())) {
var refreshed = controls.create(saved, attempt.owner, attempt.session);
for (int slot = 2; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
}
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
}
return true;
}
/** Server-thread authorization for the currently held, issued control and saved loadout. */
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
return Optional.empty();
}
int slot = player.getInventory().getHeldItemSlot();
var actual = player.getInventory().getItem(slot);
var identity = controls.identify(actual).orElse(null);
if (identity == null || identity.slot() != slot || !identity.owner().equals(attempt.owner)
|| !identity.session().equals(attempt.session) || !Objects.equals(actual, reported)) return Optional.empty();
String ability = "";
if (slot >= 2) {
ability = controls.ability(actual).orElse("");
if (ability.isEmpty() || !ability.equals(attempt.record.loadout().get(slot - 2))) return Optional.empty();
}
return Optional.of(new SelectedControl(new Active(attempt.owner, attempt.session, attempt.character), slot, ability));
}
public synchronized void enter(Player player) {
UUID owner = player.getUniqueId();
if (closed) return;
@@ -182,6 +282,9 @@ public final class EquipmentSessions implements AutoCloseable {
return;
}
var attempt = new Attempt(player, owner, character.orElseThrow());
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
attempt.previousGameMode = player.getGameMode().name();
}
attempt.snapshot = snapshot(player);
attempt.cursor = copy(player.getItemOnCursor());
pending.put(owner, attempt);
@@ -192,7 +295,8 @@ public final class EquipmentSessions implements AutoCloseable {
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
if (hasRemnants(attempt.player)) return false;
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)
|| vitals != null && !vitals.available(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.
@@ -256,8 +360,9 @@ public final class EquipmentSessions implements AutoCloseable {
attempt.session = attempt.claim.sessionId();
owned.put(attempt.owner, attempt);
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
if (vitals != null) attempt.ordinaryVitals = vitals.capture(attempt.player);
attempt.saving = true;
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character), (saved, error) -> {
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode, attempt.ordinaryVitals), (saved, error) -> {
attempt.saving = false;
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
attempt.acknowledged = true;
@@ -281,6 +386,12 @@ public final class EquipmentSessions implements AutoCloseable {
try {
var items = controls.create(record, attempt.owner, attempt.session);
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
attempt.modeStarted = attempt.previousGameMode != null;
if (!gameModes.enter(attempt.player, attempt.session, attempt.previousGameMode)) {
attempt.modeStarted = false; // Returned refusal is not an uncertain setter exception.
refuse(attempt, "GAME_MODE_REFUSED", "Adventure mode was refused; entry did not pack equipment."); return;
}
if (!entryCurrent(attempt) || attempt.exitRequested) return;
attempt.effectsStarted = true;
mark(attempt, EquipmentSessionMarker.Phase.PACKING);
var result = equipment.pack(attempt.player.getInventory(), attempt.owner, attempt.session);
@@ -296,8 +407,15 @@ public final class EquipmentSessions implements AutoCloseable {
if (interrupted(attempt)) return;
}
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
if (vitals != null) {
attempt.vitalsStarted = true;
attempt.healthAcknowledged = false;
vitals.enter(attempt.player, attempt.session, attempt.ordinaryVitals, record);
if (attempt.exitRequested || !current(attempt)) return;
}
pending.remove(attempt.owner, attempt);
if (!attempt.exitRequested) {
attempt.record = record;
active.put(attempt.owner, attempt);
attempt.presentationInstalled = true;
try { presentation.activate(attempt.player, attempt.session, record); }
@@ -308,6 +426,11 @@ public final class EquipmentSessions implements AutoCloseable {
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 {
if (!active.containsKey(attempt.owner) && attempt.modeStarted) {
restoreGameMode(attempt);
if (!attempt.effectsStarted && attempt.exitRequested) pending.remove(attempt.owner, attempt);
if (!attempt.effectsStarted && !current(attempt)) abandon(attempt);
}
transitioning.remove(attempt.owner, attempt);
// The deferred exit must reserve persistence before drain can be signalled.
try {
@@ -384,8 +507,8 @@ public final class EquipmentSessions implements AutoCloseable {
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.
// Read the current maximum after owned overlay cleanup, including legitimate
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
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.
@@ -471,22 +594,42 @@ public final class EquipmentSessions implements AutoCloseable {
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
}
private boolean restoreGameMode(Attempt attempt) {
if (attempt.previousGameMode == null) return true;
try {
if (gameModes.restore(attempt.player, attempt.session, attempt.previousGameMode)) {
attempt.modeStarted = false;
return true;
}
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
notice(attempt.owner, "GAME_MODE_RECOVERY", "Game-mode restoration was refused or ambiguous; retained receipt requires inspection.");
return false;
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
try {
invalidateCursor(attempt.owner);
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
EquipmentStorage.RestoreResult result;
try {
result = restoreMarked(attempt);
} catch (RuntimeException failure) {
attempt.nativeUncertain = true;
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
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);
boolean vitalsRestored = restoreVitals(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
attempt.gearRestoredForModeRecovery = true;
}
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
if (result != EquipmentStorage.RestoreResult.RESTORED) {
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
} else sync(attempt);
@@ -504,6 +647,13 @@ public final class EquipmentSessions implements AutoCloseable {
private EquipmentStorage.RestoreResult restoreMarked(Attempt attempt) {
invalidateCursor(attempt.owner);
var marker = EquipmentSessionMarker.read(attempt.player);
// Only a successful restoration observed by this process for this exact Player
// permits finishing a mode-only retry. Serialized RESTORED alone proves nothing.
if (attempt.gearRestoredForModeRecovery && !hasRemnants(attempt.player)
&& marker.filter(token -> token.matches(attempt.session, attempt.character)
&& token.phase() == EquipmentSessionMarker.Phase.RESTORED).isPresent()) {
return EquipmentStorage.RestoreResult.RESTORED;
}
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; "
@@ -527,14 +677,42 @@ public final class EquipmentSessions implements AutoCloseable {
catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private void saveRemainingHealth(Attempt attempt) {
if (!attempt.vitalsStarted || attempt.defeated || attempt.healthSaveRequested) return;
attempt.healthSaveRequested = true; // Failed/uncertain writes are not authority for an automatic replay.
try {
var remaining = vitals.remaining(attempt.player, attempt.session, attempt.ordinaryVitals);
var limit = NativeHealthLimit.read();
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
if (failure != null || saved.isEmpty()) {
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
return;
}
attempt.healthAcknowledged = true;
if (attempt.nativeRestored) persistExit(attempt, true);
});
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
}
private boolean restoreVitals(Attempt attempt) {
if (vitals == null || attempt.ordinaryVitals == null) return true;
try {
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
return false;
}
private void persistExit(Attempt attempt, boolean restored) {
attempt.nativeRestored = restored;
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
attempt.saving = true;
persist(() -> restored
persist(() -> complete
? 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); }
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
});
}
@@ -544,7 +722,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
private void abandon(Attempt attempt) {
if (attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
if (attempt.modeStarted || attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
if (attempt.acknowledged) {
attempt.cleaning = true;
attempt.cleanupAttempted = true;
@@ -564,6 +742,9 @@ public final class EquipmentSessions implements AutoCloseable {
private boolean entryCurrent(Attempt attempt) {
if (!current(attempt)) { abandon(attempt); return false; }
if (!attempt.player.isOnline()
|| attempt.previousGameMode != null && !attempt.modeStarted
&& !attempt.player.getGameMode().name().equals(attempt.previousGameMode)
|| attempt.ordinaryVitals != null && !attempt.vitalsStarted && !vitals.unchanged(attempt.player, attempt.ordinaryVitals)
|| 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))
@@ -584,6 +765,12 @@ public final class EquipmentSessions implements AutoCloseable {
pending.remove(attempt.owner, attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
if (attempt.vitalsStarted) {
// Only native callbacks can reach this phase. Preserve observed character damage
// before independent vitals teardown; the uncertain equipment receipt stays owned.
saveRemainingHealth(attempt);
restoreVitals(attempt);
}
abandon(attempt);
diagnostic(attempt, failure);
}
@@ -640,6 +827,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
cancelEntry(owner);
var attempt = new Attempt(player, owner, null);
attempt.recoveryOwner = previous;
// A recovered receipt has no process-local never-packed proof.
attempt.effectsStarted = true;
attempt.arrival = arrival;
@@ -652,6 +840,10 @@ public final class EquipmentSessions implements AutoCloseable {
if (!current(attempt)) return;
pending.remove(attempt.owner, attempt);
if (!attempt.player.isOnline()) return;
if (owned.get(attempt.owner) != attempt.recoveryOwner) {
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
return;
}
if (failure != null) { diagnostic(attempt, failure); return; }
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
if (receipt == null) {
@@ -666,11 +858,16 @@ public final class EquipmentSessions implements AutoCloseable {
}
attempt.character = receipt.characterId();
attempt.session = receipt.sessionId();
attempt.previousGameMode = receipt.previousGameMode();
attempt.ordinaryVitals = receipt.ordinaryVitals();
attempt.vitalsStarted = attempt.ordinaryVitals != null;
attempt.healthAcknowledged = !attempt.vitalsStarted;
attempt.modeStarted = attempt.previousGameMode != null;
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) {
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
// 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.
@@ -678,13 +875,20 @@ public final class EquipmentSessions implements AutoCloseable {
restoreExit(previous);
return;
}
if (previous != null) attempt.claim = previous.claim;
if (previous != null) {
attempt.claim = previous.claim;
attempt.gearRestoredForModeRecovery = previous.player == attempt.player && previous.gearRestoredForModeRecovery;
}
owned.put(attempt.owner, attempt);
transitioning.put(attempt.owner, attempt);
try {
saveRemainingHealth(attempt);
boolean modeRestored = restoreGameMode(attempt);
var result = restoreMarked(attempt);
boolean vitalsRestored = restoreVitals(attempt);
if (result == EquipmentStorage.RestoreResult.RESTORED) {
persistExit(attempt, true);
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
persistExit(attempt, modeRestored && vitalsRestored);
sync(attempt);
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
} catch (RuntimeException error) {
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.session;
import java.lang.reflect.Field;
import java.util.Objects;
import java.util.function.Consumer;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
/**
* Purpur 2618 instance-local effective-value lease. Base/modifiers remain in the native
* instance, so additions, removals, potion expiry and serialization keep their normal owners.
* The pinned dirty callback is synchronous: no polling interval can expose an external maximum.
* No global attribute/range is changed. Native compatibility tests must gate runtime upgrades.
*/
public final class NativeMaximumHealth implements AutoCloseable {
private static final Field DIRTY = field("dirty");
private static final Field VALUE = field("cachedValue");
private static final Field CALLBACK = field("onDirty");
private final AttributeInstance attribute;
private final Consumer<AttributeInstance> previous;
private final Consumer<AttributeInstance> callback = this::changed;
private final double maximum;
private boolean closed;
/** Exercise the pinned hook on a detached instance before exposing any player entry. */
public static void prepare() {
var probe = new AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, ignored -> {});
double bound = Math.min(10, NativeHealthLimit.read().doubleValue());
try (var lease = new NativeMaximumHealth(probe, bound)) {
Objects.requireNonNull(lease);
if (probe.getValue() != bound) throw new IllegalStateException("Native maximum hook did not apply");
probe.setBaseValue(probe.getBaseValue() + 1);
if (probe.getValue() != bound) throw new IllegalStateException("Native dirty hook did not retain the maximum");
}
if (probe.getValue() != probe.getAttribute().value().sanitizeValue(probe.getBaseValue())) {
throw new IllegalStateException("Native maximum hook did not restore ordinary calculation");
}
}
@SuppressWarnings("unchecked")
public NativeMaximumHealth(AttributeInstance attribute, double maximum) {
this.attribute = Objects.requireNonNull(attribute);
if (!Double.isFinite(maximum) || maximum <= 0) throw new IllegalArgumentException("Invalid fantasy maximum");
this.maximum = maximum;
try { previous = (Consumer<AttributeInstance>) CALLBACK.get(attribute); }
catch (ReflectiveOperationException failure) { throw incompatible(failure); }
try {
CALLBACK.set(attribute, callback);
changed(attribute);
} catch (ReflectiveOperationException | RuntimeException failure) {
try { close(); }
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
throw incompatible(failure);
}
}
private void changed(AttributeInstance changed) {
if (!closed) {
try {
VALUE.setDouble(attribute, maximum);
DIRTY.setBoolean(attribute, false);
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
previous.accept(changed);
}
@Override public void close() {
if (closed) return;
closed = true;
try {
// Never overwrite a later callback installed by someone else. If it wraps ours,
// our now-closed callback simply passes through and no longer overrides values.
if (CALLBACK.get(attribute) == callback) CALLBACK.set(attribute, previous);
DIRTY.setBoolean(attribute, true);
previous.accept(attribute);
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
private static Field field(String name) {
try {
var field = AttributeInstance.class.getDeclaredField(name);
field.setAccessible(true);
return field;
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
}
private static IllegalStateException incompatible(Exception failure) {
return new IllegalStateException("Unsupported native maximum-health internals; no fallback health policy", failure);
}
}
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this.sessions = Objects.requireNonNull(sessions);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
}
private boolean active(org.bukkit.entity.Entity entity) {
return !closed && entity instanceof Player player
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
}
@@ -0,0 +1,41 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Color;
import org.bukkit.Particle;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
public final class SessionVisuals implements AutoCloseable {
private final EquipmentSessions sessions;
private final Consumer<Throwable> failures;
private BukkitTask task;
private boolean closed;
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
}
public void install(Plugin plugin) {
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (closed) return;
for (var player : plugin.getServer().getOnlinePlayers()) {
try {
var active = sessions.active(player.getUniqueId());
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
} catch (RuntimeException failure) { failures.accept(failure); }
}
}, 1L, 10L);
}
public static void burst(Player player, Color color, int count) {
player.getWorld().spawnParticle(Particle.DUST, player.getLocation().add(0, 1, 0), count,
0.3, 0.4, 0.3, 0, new Particle.DustOptions(color, 1));
}
@Override public void close() {
closed = true;
if (task != null) { task.cancel(); task = null; }
}
}
@@ -0,0 +1,189 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OrdinaryVitals;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.NamespacedKey;
import org.bukkit.attribute.Attribute;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.entity.Player;
import org.bukkit.persistence.PersistentDataType;
/** Server-thread native overlay; native attributes themselves are never snapshotted/replayed. */
public final class SessionVitals implements AutoCloseable {
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "vitals_session");
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
private record Mark(UUID session, Phase phase, double maximum, OrdinaryVitals ordinary) { }
private record Lease(UUID session, NativeMaximumHealth maximum) { }
private final Map<UUID, Lease> leases = new HashMap<>();
private boolean closed;
public SessionVitals() { NativeMaximumHealth.prepare(); }
public OrdinaryVitals capture(Player player) {
return new OrdinaryVitals(player.getHealth(), player.getFoodLevel(), player.getSaturation(), player.getExhaustion(),
player.isHealthScaled(), player.getHealthScale());
}
public boolean available(Player player) {
var mark = read(player);
return !closed && (!player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED);
}
public boolean unchanged(Player player, OrdinaryVitals ordinary) {
return ordinary != null && ordinary.health() > 0 && ordinary.equals(capture(player));
}
/** Caller already acknowledged this ordinary snapshot in its player/session receipt. */
public void enter(Player player, UUID session, OrdinaryVitals ordinary, CharacterRecord record) {
if (!available(player) || !unchanged(player, ordinary) || leases.containsKey(player.getUniqueId())) {
throw new IllegalStateException("Stale native vitals entry");
}
double maximum = record.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
write(player, new Mark(session, Phase.APPLYING, maximum, ordinary));
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
try {
var lease = new NativeMaximumHealth(nativeAttribute, maximum);
leases.put(player.getUniqueId(), new Lease(session, lease));
// Equal to the actual native maximum, not virtual health. Native attribute packets
// use CraftPlayer's scaled snapshot, without exposing ordinary modifier arithmetic.
// Initialize once BEFORE display delivery can reenter damage/healing handlers.
player.setHealth(Math.min(record.health().doubleValue(), maximum));
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setFoodLevel(20);
player.setExhaustion(0);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
} catch (RuntimeException | LinkageError failure) {
try { releaseMaximum(player, session); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreNutrition(player, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
// is not permission to heal from a stale snapshot or replay the character save.
throw failure;
}
}
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
Objects.requireNonNull(previous);
Objects.requireNonNull(saved);
var mark = read(player);
var lease = leases.get(player.getUniqueId());
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
throw new IllegalStateException("No matching active fantasy vitals revision");
}
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
double gain = Math.max(0, nextMaximum - mark.maximum());
if (gain == 0 && nextMaximum == mark.maximum()) return;
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
lease.maximum().close();
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
leases.put(player.getUniqueId(), new Lease(session, replacement));
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
player.setHealthScale(nextMaximum);
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
}
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
throw new IllegalStateException("No matching applied fantasy vitals proof");
}
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
}
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary)
|| mark.phase() != Phase.APPLIED) {
// Process-local hook ownership is sufficient to retire our maximum override,
// never to replay ordinary health/nutrition when native setter proof is missing.
releaseMaximum(player, session);
return mark != null && mark.session().equals(session) && mark.ordinary().equals(ordinary) && mark.phase() == Phase.RELEASED;
}
write(player, new Mark(session, Phase.RESTORING, mark.maximum(), ordinary));
// Reconcile restored equipment's native modifiers before clamping ordinary health.
try { ((CraftPlayer) player).getHandle().detectEquipmentUpdates(); }
finally { releaseMaximum(player, session); }
// On restart there is no hook: native attribute serialization retained ordinary
// base/permanent modifiers, and the native effect lifecycle owns potion expiry/removal.
// CraftPlayer scale setters send health packets immediately (or queue one for damage
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
restoreNutrition(player, ordinary);
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
}
private void releaseMaximum(Player player, UUID session) {
var lease = leases.get(player.getUniqueId());
if (lease != null && lease.session().equals(session)) {
leases.remove(player.getUniqueId(), lease);
lease.maximum().close();
}
}
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
player.setFoodLevel(ordinary.food());
player.setSaturation(ordinary.saturation());
player.setExhaustion(ordinary.exhaustion());
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
player.setHealthScaled(ordinary.scaled());
}
}
/** Only removes this process's effective-value hooks; never replays unknown ordinary snapshots. */
@Override public void close() {
closed = true;
RuntimeException errors = null;
for (var lease : java.util.List.copyOf(leases.values())) {
try { lease.maximum().close(); }
catch (RuntimeException | LinkageError failure) {
if (errors == null) errors = new IllegalStateException("Native maximum cleanup failed");
errors.addSuppressed(failure);
}
}
leases.clear();
if (errors != null) throw errors;
}
private static Mark read(Player player) {
var data = player.getPersistentDataContainer();
if (!data.has(KEY, PersistentDataType.STRING)) return null;
try {
String encoded = data.get(KEY, PersistentDataType.STRING);
if (encoded.length() > 400) return null;
String[] fields = encoded.split("\\|", -1);
if (fields.length != 5 || !fields[0].equals("1")) return null;
UUID session = UUID.fromString(fields[1]);
double maximum = Double.parseDouble(fields[3]);
if (!session.toString().equals(fields[1]) || !Double.isFinite(maximum) || maximum <= 0) return null;
return new Mark(session, Phase.valueOf(fields[2]), maximum, OrdinaryVitals.decode(fields[4]));
} catch (IllegalArgumentException invalid) { return null; }
}
private static void write(Player player, Mark mark) {
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
"1|" + mark.session() + "|" + mark.phase() + "|" + mark.maximum() + "|" + mark.ordinary().encode());
}
}
+20
View File
@@ -0,0 +1,20 @@
# Captured when a new fantasy session starts; changing this does not alter active sessions.
# Admin command: /fantasystick config adventure-mode [true|false]
adventure-mode: true
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
maximum-level: 30
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
passive-xp: 1
ordinary-xp: 10
boss-xp: 100
restricted-kill-spacing-blocks: 3
# Fireball defaults from the reviewed starter catalogue.
fireball-base-damage: 6
fireball-cooldown-seconds: 4
fireball-range-blocks: 32
fireball-lifetime-seconds: 5
+4 -4
View File
@@ -3,14 +3,14 @@ version: ${version}
main: games.dmg.fantasystick.FantasyStickPlugin
api-version: "26.2"
depend: [ProtocolLib]
description: Transferable character sheets and recoverable equipment sessions; other RPG gameplay is not activated.
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
author: dmg.games
commands:
fantasystick:
description: Inspect equipment receipts or request validated online recovery.
usage: /fantasystick <inspect|recover> <player-uuid>
description: Inspect/recover equipment sessions or configure fantasy mode.
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Inspect equipment sessions and request proof-based recovery.
description: Inspect/recover equipment sessions and configure fantasy mode.
default: op
@@ -0,0 +1,225 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Material;
import org.bukkit.event.block.Action;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AbilityEngineNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void executionContractsRevalidateTargetsBoundAreasReportOnlyEffectsAndCleanOwnedWork() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = new AbilityRegistry();
var reports = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityReport>();
var delayed = new java.util.ArrayList<Runnable>();
var closedResources = new AtomicInteger();
var delayedEffects = new AtomicInteger();
var selectionEffects = new AtomicInteger();
var allyEffects = new AtomicInteger();
var exceptionalEffects = new AtomicInteger();
var areaRecipients = new java.util.ArrayList<org.bukkit.entity.Entity>();
var areaCandidates = new java.util.concurrent.atomic.AtomicReference<List<? extends org.bukkit.entity.Entity>>(List.of());
registry.register(definition("report_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> {
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
execution.report(type, execution.input().target(), BigDecimal.valueOf(2));
}
}));
registry.register(definition("area_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.AREA, execution -> areaRecipients.addAll(execution.eligible(areaCandidates.get(), 2))));
registry.register(definition("delayed_test", AbilityDefinition.Activation.DELAYED,
AbilityDefinition.TargetPolicy.PARTY, execution -> execution.later(Duration.ofSeconds(1), () -> {
delayedEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("selection_test", AbilityDefinition.Activation.SELECTION,
AbilityDefinition.TargetPolicy.SELECTION, execution -> execution.later(Duration.ofSeconds(1), () -> {
selectionEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("projectile_test", AbilityDefinition.Activation.PROJECTILE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> execution.own(closedResources::incrementAndGet)));
registry.register(definition("ally_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.ALLY, execution -> allyEffects.incrementAndGet()));
registry.register(definition("exception_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT, execution -> exceptionalEffects.incrementAndGet()));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
prepare(data, a, List.of("report_test", "area_test", "delayed_test", "selection_test", "projectile_test", "ally_test", "exception_test"));
f.activate(a); f.activate(b);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
games.dmg.fantasystick.ability.AbilityScheduler scheduler = (delay, action) -> {
delayed.add(action); return closedResources::incrementAndGet;
};
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry,
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(engine, f, a, active, 2, "report_test", hostile);
assertEquals(java.util.Set.copyOf(java.util.Arrays.asList(games.dmg.fantasystick.ability.AbilityReport.Type.values())),
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
areaCandidates.set(List.of(hostile, ordinary, b.player));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 3, "area_test", null);
assertEquals(List.of(hostile, b.player), areaRecipients);
assertEquals(5, reports.size(), "Casting and no-op/recipient discovery do not manufacture reports");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 7, "ally_test", b.player);
assertEquals(1, allyEffects.get());
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 8, "exception_test", ordinary);
assertEquals(1, exceptionalEffects.get(), "Explicit non-combat policy may select an ordinary player");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 4, "delayed_test", b.player);
assertEquals(1, delayed.size());
assertTrue(parties.leave(a.core.id, party.id()));
delayed.removeFirst().run();
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
assertEquals(1, delayed.size());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 6, "projectile_test", hostile);
int beforeExit = closedResources.get();
PartyCombatNativeTest.exit(f, a);
engine.sweep();
assertEquals(beforeExit + 1, closedResources.get(), "Session end cleans the projectile definition's owned resource");
engine.close();
assertEquals(beforeExit + 1, closedResources.get(), "Repeated engine cleanup cannot close owned state twice");
}
}
@Test void registeredTestDefinitionUsesRealBookPersistenceControlAndCastRoutes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String available = PartyBookNativeTest.text(a);
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
assertTrue(compactAvailable.contains("Cost:1"), available);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var learned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(java.util.Set.of("test_focus"), learned.learned());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
var assign = PartyBookNativeTest.button(a, "[3]");
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals("test_focus", assigned.loadout().getFirst());
var control = a.core.inventory.getItem(2);
assertEquals(Material.AMETHYST_SHARD, control.getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, control,
null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
assertEquals(1, effects.get());
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> loadout) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var learned = loadout.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), learned, loadout,
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void cast(games.dmg.fantasystick.ability.AbilityEngine engine,
FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int slot, String id,
org.bukkit.entity.Entity target) throws Exception {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
engine.accept(new ControlInput(actor.player, active, slot, id, ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, target, 0, 0, () -> sessions.current(active)));
PartyBookNativeTest.drain(fixture);
}
}
@@ -0,0 +1,90 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
class AbilityRegistryNativeTest {
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception {
games.dmg.fantasystick.NativeRuntime.bootstrap();
}
@Test void registersCompleteStableDefinitionsAndRejectsDuplicateIds() {
var registry = new AbilityRegistry();
var definition = definition("test_bolt");
registry.register(definition);
assertSame(definition, registry.find("test_bolt").orElseThrow());
assertEquals(List.of(definition), registry.definitions());
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("test_bolt")));
assertTrue(registry.find("unknown_saved_id").isEmpty());
assertTrue(registry.find("basic_attack").isEmpty(), "The built-in stick is not a learned/refundable definition");
}
@Test void rejectsMalformedMetadataRequirementsAndNumericParametersBeforeRegistration() {
var valid = definition("valid");
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@Test void effectReportsRequireActualPositiveFiniteEvidence() {
var character = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID(); var target = java.util.UUID.randomUUID();
var now = java.time.Instant.parse("2026-09-19T20:00:00Z");
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
assertDoesNotThrow(() -> new games.dmg.fantasystick.ability.AbilityReport(type, character, session, target,
BigDecimal.ONE, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, BigDecimal.ZERO, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, new BigDecimal("1E1000000"), now));
}
}
@Test void definitionsSupportEveryBoundedExecutionStyleAndTargetBoundary() {
for (var activation : AbilityDefinition.Activation.values()) {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
}
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
}
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -0,0 +1,111 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.session.AdventureModeLease;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class AdventureModeLeaseNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void changesAndRestoresOnlyTheAcknowledgedHoldersOriginalMode() {
for (var original : new GameMode[]{GameMode.SURVIVAL, GameMode.CREATIVE, GameMode.SPECTATOR}) {
var actor = new Actor(original);
var lease = new AdventureModeLease();
var session = UUID.randomUUID();
assertTrue(lease.enter(actor.player, session, original.name()));
assertEquals(GameMode.ADVENTURE, actor.mode.get());
assertTrue(lease.restore(actor.player, session, original.name()));
assertEquals(original, actor.mode.get());
assertTrue(lease.restore(actor.player, session, original.name()));
assertEquals(original, actor.mode.get());
}
}
@Test void adminChangeAwayAndBackToAdventureRelinquishesOriginalRestoration() {
var actor = new Actor(GameMode.CREATIVE);
var lease = new AdventureModeLease(); var session = UUID.randomUUID();
assertTrue(lease.enter(actor.player, session, "CREATIVE"));
actor.mode.set(GameMode.SURVIVAL);
// Native event is dispatched before the second external mode change takes effect.
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent(actor.player, GameMode.ADVENTURE);
lease.changed(event);
actor.mode.set(GameMode.ADVENTURE);
assertTrue(lease.restore(actor.player, session, "CREATIVE"));
assertEquals(GameMode.ADVENTURE, actor.mode.get(), "Do not override the administrator's final mode");
}
@Test void nativeVetoesLeaveEntryUntouchedAndRestorationRetryableWithoutClaimingSuccess() {
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
actor.change = ignored -> {};
assertFalse(lease.enter(actor.player, session, "SURVIVAL"));
assertEquals(GameMode.SURVIVAL, actor.mode.get()); assertTrue(lease.available(actor.player));
actor.change = actor.mode::set;
assertTrue(lease.enter(actor.player, session, "SURVIVAL"));
actor.change = ignored -> {};
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
assertEquals(GameMode.ADVENTURE, actor.mode.get()); assertFalse(lease.available(actor.player));
actor.change = actor.mode::set;
assertTrue(lease.restore(actor.player, session, "SURVIVAL"));
assertEquals(GameMode.SURVIVAL, actor.mode.get());
}
@Test void serializedMatchingProofRecoversButMissingWrongOrInterruptedProofNeverGuesses() throws Exception {
var actor = new Actor(GameMode.CREATIVE); var session = UUID.randomUUID();
assertTrue(new AdventureModeLease().enter(actor.player, session, "CREATIVE"));
var reloaded = new Actor(GameMode.ADVENTURE);
reloaded.pdc.readFromBytes(actor.pdc.serializeToBytes(), true);
var lease = new AdventureModeLease();
assertFalse(lease.restore(reloaded.player, UUID.randomUUID(), "CREATIVE"));
assertFalse(lease.restore(reloaded.player, session, "SURVIVAL"));
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
assertTrue(lease.restore(reloaded.player, session, "CREATIVE"));
assertEquals(GameMode.CREATIVE, reloaded.mode.get());
var key = new org.bukkit.NamespacedKey("fantasystick", "adventure_session");
for (String phase : new String[]{"APPLYING", "RESTORING", "unknown"}) {
reloaded.mode.set(GameMode.ADVENTURE);
reloaded.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "1|" + session + "|CREATIVE|" + phase);
var before = reloaded.pdc.serializeToBytes();
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
assertArrayEquals(before, reloaded.pdc.serializeToBytes());
assertEquals(GameMode.ADVENTURE, reloaded.mode.get());
}
reloaded.pdc.remove(key);
assertFalse(lease.restore(reloaded.player, session, "CREATIVE"));
}
@Test void setterExceptionsRetainAmbiguousProofWithoutAutomaticRetryAndDisabledNeverTouchesMode() {
var actor = new Actor(GameMode.SURVIVAL); var lease = new AdventureModeLease(); var session = UUID.randomUUID();
actor.change = ignored -> { throw new IllegalStateException("uncertain"); };
assertTrue(lease.enter(actor.player, session, null)); assertTrue(lease.restore(actor.player, session, null));
assertTrue(lease.available(actor.player));
assertThrows(IllegalStateException.class, () -> lease.enter(actor.player, session, "SURVIVAL"));
assertFalse(lease.restore(actor.player, session, "SURVIVAL"));
assertFalse(lease.available(actor.player));
}
static final class Actor {
final UUID id = UUID.randomUUID();
final AtomicReference<GameMode> mode;
java.util.function.Consumer<GameMode> change;
final org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer pdc =
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry());
final Player player;
Actor(GameMode original) {
mode = new AtomicReference<>(original);
change = mode::set;
player = FantasyStickLifecycleNativeTest.port(Player.class, (name, args) -> switch (name) {
case "getUniqueId" -> id;
case "getGameMode" -> mode.get();
case "setGameMode" -> { change.accept((GameMode) args[0]); yield null; }
case "getPersistentDataContainer" -> pdc;
default -> throw new AssertionError(name);
});
}
}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class BasicAttackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() 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 sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
}
}
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
events.add(event); f.dispatcher.accept(event); return null;
}).when(b.player).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertTrue(parties.leave(a.core.id, party.id()));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() 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);
a.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
verify(target).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
"Exit restores the ordinary item that occupied the basic-stick slot");
}
}
private static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
}
@@ -101,7 +101,8 @@ class CharacterAnvilNamesNativeTest {
assertEquals("Tail of Mira of Dawn — 雪", book.getTitle());
var text = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText();
assertTrue(text.serialize(book.customName()).contains("Mira of Dawn — 雪"));
assertTrue(book.pages().stream().map(text::serialize).anyMatch(page -> page.contains("Mira of Dawn — 雪")));
assertTrue(book.pages().stream().map(text::serialize).collect(java.util.stream.Collectors.joining())
.replace("\n", "").contains("Mira of Dawn — 雪"), "Readable wrapping must retain the full acknowledged name");
assertTrue(problems.isEmpty());
// Actor rejects all XP/cursor/inventory mutation calls and all worker-side player access.
}
@@ -0,0 +1,348 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.block.Action;
import org.bukkit.block.BlockFace;
import org.bukkit.event.Event;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual packaged input/book assembly with native pages/items and explicit client/scheduler ports. */
class CharacterBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void queuedInspectionCannotOpenForAMovedSheetChangedSlotQuitOrDisabledLifetime() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
f.players.put(a.core.id, a.player); f.activate(a);
click(f, a); drainReads(f);
assertEquals(1, f.tasks.size());
switch (route) {
case 0 -> { a.core.inventory.setItemInOffHand(null); f.changed(a); }
case 1 -> a.core.inventory.getInventory().setSelectedSlot(1);
case 2 -> { a.core.online = false; f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty())); }
case 3 -> { f.plugin.setEnabled(false); f.awaitWorkers(); }
default -> throw new AssertionError(route);
}
runQueued(f);
assertTrue(a.books.isEmpty(), "Stale book callback opened a UI on route " + route);
}
}
@Test void latestRequestWinsAndReloadsNamesWithoutRetainingAnOlderRead() 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);
click(f, a); drainReads(f);
var characters = characters(f);
characters.renameCharacter(a.core.record.id(), "Latest book name").get(5, TimeUnit.SECONDS);
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size());
assertTrue(text(a.books.getFirst()).contains("Latest book name"));
}
}
@Test void cappedProgressAndSavedAbilitiesRenderWithoutCappingStatsOrRewritingProgress() 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 old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats());
var strength = new java.math.BigInteger("123456789012345678901234567890");
stats.put(games.dmg.fantasystick.progression.Stat.STR, strength);
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, 30, java.math.BigInteger.valueOf(2900), java.math.BigInteger.valueOf(9), old.health(),
java.util.Set.of("revive", "unavailable_saved_id"), old.loadout(), java.util.Map.of(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() 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);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
assertEquals(11, a.maximumHealth.getValue());
assertEquals(6, a.player.getHealth());
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f);
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() 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);
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addTransientModifier(before);
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(during);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
}
}
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
PartyBookNativeTest.click(f, a, rejected);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() 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 old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats());
String digits = "1234567890".repeat(400);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
String name = "雪🐈".repeat(80);
var learned = java.util.stream.IntStream.range(0, 50).mapToObj(i -> "unknown_ability_" + i).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), name, old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), learned, old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
var before = a.core.snapshot();
click(f, a); drainReads(f); runQueued(f);
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
for (var page : content.pages()) {
String text = page.raw().getString();
assertTrue(text.split("\\n", -1).length <= 13, "Native page exceeds visible line count");
for (String line : text.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
"Native line exceeds conservative visible width: " + line.length());
}
String complete = text(a.books.getLast()).replace("\n", "");
assertTrue(complete.contains(name)); assertTrue(complete.contains(digits));
for (String ability : learned) assertTrue(complete.contains(ability), ability);
assertArrayEquals(before, a.core.snapshot());
assertEquals(saved, characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() 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);
String digits = "1234567890".repeat(4000);
var old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size(), "Large but supported records still open a native book");
StringBuilder all = new StringBuilder();
int volumes = 0;
while (true) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content); assertTrue(content.pages().size() <= 100, "Keep the chosen per-view payload bound (not a signed-book codec page limit)");
for (var page : content.pages()) if (!hasClick(page.raw())) all.append(page.raw().getString().replace("\n", ""));
if (!text(a.books.getLast()).contains("[Next pages]")) break;
assertTrue(++volumes < 10, "Navigation must terminate, not repeat a truncated volume");
var next = PartyBookNativeTest.button(a, "[Next pages]");
if (volumes == 1) {
var stranger = new FantasyStickLifecycleNativeTest.Actor(data);
PartyBookNativeTest.click(f, stranger, next);
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
PartyBookNativeTest.click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(next.id(), java.util.Optional.of(payload)));
}
assertTrue(stranger.books.isEmpty());
}
PartyBookNativeTest.click(f, a, next); PartyBookNativeTest.click(f, a, next);
drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
}
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
a.core.inventory.getInventory().setSelectedSlot(1);
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.awaitCharacterAndReceipt(a);
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
runQueued(f); assertTrue(a.books.isEmpty());
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
f.activate(b); click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
assertTrue(a.books.isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true);
f.players.put(b.core.id, b.player); f.reenter(b);
var before = b.core.snapshot();
var path = data.resolve("characters").resolve(renamed.id() + ".yml");
byte[] saved = Files.readAllBytes(path);
click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
var meta = (org.bukkit.inventory.meta.BookMeta) b.books.getLast().getItemMeta();
assertEquals("Character", meta.getTitle()); assertEquals("Fantasy Stick", meta.getAuthor());
assertArrayEquals(saved, Files.readAllBytes(path)); assertArrayEquals(before, b.core.snapshot());
}
}
private static boolean hasClick(net.minecraft.network.chat.Component component) {
return component.getStyle().getClickEvent() != null || component.getSiblings().stream().anyMatch(CharacterBookNativeTest::hasClick);
}
@Test void malformedRecordReadLeavesDataAndInventoryUntouchedAndCanBeRetriedAfterRepair() 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 path = data.resolve("characters").resolve(a.core.record.id() + ".yml");
byte[] valid = Files.readAllBytes(path);
byte[] invalid = "schema-version: malformed\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var before = a.core.snapshot();
Files.write(path, invalid); click(f, a); drainReads(f); runQueued(f);
assertTrue(a.books.isEmpty());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be loaded")));
assertArrayEquals(invalid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
Files.write(path, valid); click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size());
assertArrayEquals(valid, Files.readAllBytes(path)); assertArrayEquals(before, a.core.snapshot());
}
}
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() 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 vetoed = interaction(a); vetoed.setUseItemInHand(Event.Result.DENY); f.dispatcher.accept(vetoed);
a.core.inventory.getInventory().setSelectedSlot(1); click(f, a);
a.core.inventory.getInventory().setSelectedSlot(2); click(f, a);
drainReads(f); runQueued(f);
assertTrue(a.books.isEmpty());
assertTrue(a.messages.isEmpty(), "Inert controls do not manufacture book errors or mutate items");
}
}
private static String text(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
}
private static CharacterStorage characters(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static void drainReads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
characters(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
}
private static PlayerInteractEvent interaction(FantasyStickLifecycleNativeTest.Actor a) {
return new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(), null,
BlockFace.SELF, EquipmentSlot.HAND);
}
private static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) {
var event = interaction(a); f.dispatcher.accept(event);
assertEquals(Event.Result.DENY, event.useItemInHand());
}
private static void runQueued(FantasyStickLifecycleNativeTest.Fixture f) {
Runnable task;
while ((task = f.tasks.poll()) != null) task.run();
}
}
@@ -84,6 +84,8 @@ class CharacterSheetsNativeTest {
assertEquals(1, preview.getAmount());
assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), preview.getItemMeta().lore());
}
}
@@ -104,7 +106,8 @@ class CharacterSheetsNativeTest {
assertEquals(Material.PAPER, sheet.getType());
assertEquals(1, sheet.getAmount());
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
@@ -0,0 +1,263 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.EntityType;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CombatExperienceNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
assertTrue(f.order.contains("CombatExperience"));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new EntityDamageByEntityEvent(a.player, target, DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0));
f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled());
var source = mock(org.bukkit.damage.DamageSource.class);
var death = new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17);
f.dispatcher.accept(death);
PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
PartyBookNativeTest.drain(f); // Deliver the post-commit main-thread acknowledgment and feedback.
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
assertEquals(2, saved.level());
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
invocation.getMethod().getName().equals("spawnParticle")
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
assertFalse(a.sounds.isEmpty());
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Level: 2")); assertTrue(book.contains("XP: 0/100"));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
PartyBookNativeTest.drain(f);
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
}
}
@Test void sharingProjectileExitPassiveExemptionAndPerCharacterRestrictedSpacingUseNativeEvents() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
var c = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.players.put(c.core.id, c.player);
f.activate(a); f.activate(b); f.activate(c);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, first, false);
die(f, first); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
hit(f, a.player, near, false);
var arrow = PartyCombatNativeTest.projectile(f, b.player);
hit(f, arrow, near, false);
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
die(f, near); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
hit(f, a.player, passive, false);
hit(f, c.player, passive, false);
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
hit(f, b.player, passive, false);
die(f, passive); settle(f);
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
"A one-XP passive pool rounds up to one for every fantasy contributor");
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
hit(f, a.player, exact, false);
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
die(f, exact); settle(f);
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
}
}
@Test void failedRewardSaveRetainsOneRetryableOperationWithoutReplayingTheTarget() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var progression = games.dmg.fantasystick.progression.ProgressionRules.defaults();
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
var attacker = mock(org.bukkit.entity.Player.class);
when(attacker.getUniqueId()).thenReturn(active.player());
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
var success = java.util.concurrent.CompletableFuture.completedFuture(
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
ignored -> null, failures::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.close();
}
@Test void validatedConfiguredPoolAndZeroSpacingReachTheRegisteredRewardPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
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 world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
for (int index = 0; index < 2; index++) {
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, target, false); die(f, target); settle(f);
}
assertEquals(java.math.BigInteger.valueOf(50), record(f, a).experience());
assertEquals(6, record(f, a).level());
assertEquals(java.math.BigInteger.valueOf(45), record(f, a).unspentPoints());
assertEquals(5, a.sounds.size(), "Every gained level has its own feedback sound");
for (int level = 2; level <= 6; level++) {
int expected = level;
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
}
}
}
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, true);
var source = mock(org.bukkit.damage.DamageSource.class);
var cancelled = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(cancelled); settle(f);
assertTrue(cancelled.isCancelled());
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
}
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(second);
var b = new FantasyStickLifecycleNativeTest.Actor(second);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, false);
var source = mock(org.bukkit.damage.DamageSource.class);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat); settle(f); settle(f);
assertTrue(defeat.isCancelled());
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
assertTrue(record(f, b).dead());
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
org.bukkit.entity.Entity target, boolean cancelled) {
var damage = mock(org.bukkit.damage.DamageSource.class);
when(damage.getCausingEntity()).thenReturn(source);
when(damage.getDirectEntity()).thenReturn(source);
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
}
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static LivingEntity mob(EntityType type, Location location) {
var entity = mock(LivingEntity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getType()).thenReturn(type);
when(entity.getLocation()).thenAnswer(ignored -> location.clone());
when(entity.getWorld()).thenReturn(location.getWorld());
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return entity;
}
}
@@ -0,0 +1,86 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetHealthPacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DefeatHealthDeliveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void zeroHealthDefeatNeverDeliversADeathScreenHealthUpdateAndTailArrivesWithoutReconnect() throws Exception {
for (boolean queued : List.of(false, true)) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("queued-" + queued))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("queued-" + queued));
f.players.put(a.core.id, a.player);
var sent = nativeHealthDelivery(a);
var entry = a.player.getLocation();
f.activate(a);
a.core.location.add(20, 0, 20);
a.core.health = 0; // The real death callback begins after native health reaches zero.
sent.clear();
a.nativePlayer.queueHealthUpdatePacket = queued;
var death = death(a);
f.dispatcher.accept(death);
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
a.nativePlayer.queueHealthUpdatePacket = false;
PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(entry, a.player.getLocation());
assertFalse(a.player.isHealthScaled());
var updates = sent.stream().filter(ClientboundSetHealthPacket.class::isInstance)
.map(ClientboundSetHealthPacket.class::cast).toList();
assertFalse(updates.isEmpty(), "Exercise actual CraftPlayer scale/health delivery, not a mocked setter");
assertTrue(updates.stream().allMatch(p -> p.getHealth() > 0),
() -> "Cancelled fantasy defeat must never deliver zero health: " + updates.stream().map(ClientboundSetHealthPacket::getHealth).toList());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertEquals(tail, a.core.inventory.getItemInOffHand());
// A subsequent ordinary death is not protected by a blanket zero-health packet filter.
a.core.health = 0;
var ordinaryDeath = death(a); f.dispatcher.accept(ordinaryDeath);
assertFalse(ordinaryDeath.isCancelled());
a.player.sendHealthUpdate();
assertEquals(0, ((ClientboundSetHealthPacket) sent.getLast()).getHealth());
}
}
private static org.bukkit.event.entity.PlayerDeathEvent death(FantasyStickLifecycleNativeTest.Actor a) {
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
return new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
}
private static List<Packet<?>> nativeHealthDelivery(FantasyStickLifecycleNativeTest.Actor a) {
var sent = new ArrayList<Packet<?>>();
var connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
doAnswer(call -> { sent.add(call.getArgument(0)); return null; }).when(connection).send(any(Packet.class));
a.nativePlayer.connection = connection;
when(a.nativePlayer.getFoodData()).thenReturn(a.food);
// Transport and tracked entity storage are explicit ports; scaling and packet creation run natively.
when(a.nativePlayer.getEntityData()).thenReturn(mock(net.minecraft.network.syncher.SynchedEntityData.class));
doCallRealMethod().when(a.player).setHealthScale(anyDouble());
doCallRealMethod().when(a.player).setHealthScaled(anyBoolean());
doCallRealMethod().when(a.player).getHealthScale();
doCallRealMethod().when(a.player).isHealthScaled();
doCallRealMethod().when(a.player).getScaledHealth();
doCallRealMethod().when(a.player).getScaledMaxHealth();
doCallRealMethod().when(a.player).updateScaledHealth();
doCallRealMethod().when(a.player).updateScaledHealth(anyBoolean());
doCallRealMethod().when(a.player).sendHealthUpdate();
a.player.setHealthScale(20); a.player.setHealthScaled(false);
sent.clear(); return sent;
}
}
@@ -17,6 +17,62 @@ class EquipmentAdminCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
assertTrue(sender.messages.getLast().contains("adventure-mode=true"));
sender.messages.clear();
for (String value : List.of("yes", "1", "TRUE", "")) {
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", value});
assertTrue(sender.messages.getLast().contains("true|false"));
}
assertTrue(f.tasks.isEmpty());
assertFalse(Files.exists(data.resolve("config.yml")));
sender.allowed = false;
f.command.getExecutor().onCommand(sender.port, f.command, "fantasystick", new String[]{"config", "adventure-mode", "false"});
assertFalse(Files.exists(data.resolve("config.yml")));
sender.allowed = true; sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
assertTrue(sender.messages.isEmpty(), "Do not report success before acknowledgment");
var reply = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(reply); reply.run();
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
assertTrue(sender.messages.getLast().contains("new sessions"));
assertEquals(0, f.playerLookups);
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
}
}
@Test void reenableWaitsOffThreadForAcceptedConfigurationWritesBeforeReadingNewPolicy() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var old = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adventureSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(old, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sender = new Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"), "Do not activate from a stale config snapshot");
release.countDown();
f.tick();
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode"});
assertTrue(sender.messages.getLast().contains("adventure-mode=false"));
} finally { release.countDown(); }
}
}
@Test void permissionAndStrictShapeRejectBeforePersistenceOrNativeLookup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -1236,6 +1236,285 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void bookInputIsDeliveredAfterVanillaContainmentAndExpiresWithItsSession() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.onInput = input -> assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand(),
"Vanilla must already be contained before a callback can reenter");
f.dispatcher.accept(event);
assertEquals(1, f.inputs.size(), "The custody guard must not swallow the intentional book action");
var input = f.inputs.getFirst();
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.BOOK, input.kind());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, input.gesture());
assertEquals(f.core.sessions.active(a.id).orElseThrow(), input.session());
assertEquals(0, input.slot()); assertEquals("", input.ability());
assertTrue(input.stillCurrent());
f.core.sessions.exit(a.player);
assertFalse(input.stillCurrent(), "Queued handlers cannot reuse input after an exit");
}
}
@Test void basicAndSwordAttackInputsPreserveNativeChargeWithoutAnOrdinarySecondHit() throws Exception {
try (var f = new Fixture()) {
f.loadout("great_sword");
var a = f.actor; f.core.activate(a);
a.attackStrength = 0.375f;
var target = port(org.bukkit.entity.Zombie.class);
for (int slot : new int[]{1, 2}) {
a.inventory.getInventory().setSelectedSlot(slot);
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.onInput = input -> assertTrue(event.isCancelled());
int before = f.inputs.size(); f.dispatcher.accept(event);
assertEquals(before + 1, f.inputs.size(), "Native melee intent must reach the owned action adapter");
var input = f.inputs.getLast();
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, input.gesture());
assertEquals(slot == 1 ? "" : "great_sword", input.ability());
assertEquals(slot == 1 ? games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK
: games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, input.kind());
assertSame(target, input.target()); assertEquals(0.375f, input.attackStrength());
assertTrue(input.stillCurrent());
f.dispatcher.accept(event); assertEquals(before + 1, f.inputs.size(), "Contained event replay is not another input");
}
a.inventory.getInventory().setSelectedSlot(0);
var bookHit = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(bookHit); assertTrue(bookHit.isCancelled()); assertEquals(2, f.inputs.size());
}
}
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
try (var f = new Fixture()) {
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
f.loadout(abilities);
var a = f.actor; f.core.activate(a);
var target = port(org.bukkit.entity.Player.class);
for (int index = 0; index < abilities.length; index++) {
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
var item = a.inventory.getItem(slot);
var air = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
item, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
int before = f.inputs.size(); f.dispatcher.accept(air);
assertEquals(before + 1, f.inputs.size(), abilities[index]);
assertEquals(org.bukkit.event.Event.Result.DENY, air.useItemInHand());
assertEquals(abilities[index], f.inputs.getLast().ability());
assertEquals(games.dmg.fantasystick.session.ControlInput.Kind.ABILITY, f.inputs.getLast().kind());
assertTrue(f.inputs.getLast().stillCurrent());
var entity = new org.bukkit.event.player.PlayerInteractEntityEvent(a.player, target, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(entity);
assertTrue(entity.isCancelled()); assertEquals(before + 2, f.inputs.size());
assertSame(target, f.inputs.getLast().target());
assertEquals(abilities[index], f.inputs.getLast().ability());
}
}
}
@Test void inputRoutingRejectsVetoesWrongHandsForgedSlotsAndAbilitiesAndEndedSessions() throws Exception {
try (var f = new Fixture()) {
f.loadout("revive", "", "removed_spell");
var a = f.actor; f.core.activate(a);
var book = a.inventory.getItem(0).clone();
var use = new java.util.function.BiFunction<ItemStack, org.bukkit.inventory.EquipmentSlot, org.bukkit.event.player.PlayerInteractEvent>() {
@Override public org.bukkit.event.player.PlayerInteractEvent apply(ItemStack item, org.bukkit.inventory.EquipmentSlot hand) {
return new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
item, null, org.bukkit.block.BlockFace.SELF, hand);
}
};
var vetoed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
vetoed.setUseItemInHand(org.bukkit.event.Event.Result.DENY); f.dispatcher.accept(vetoed);
f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.OFF_HAND));
var wrongReport = book.clone(); wrongReport.editMeta(meta -> meta.displayName(net.kyori.adventure.text.Component.text("stale")));
f.dispatcher.accept(use.apply(wrongReport, org.bukkit.inventory.EquipmentSlot.HAND));
for (String key : new String[]{"control_owner_id", "control_session_id"}) {
var forged = book.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
new org.bukkit.NamespacedKey("fantasystick", key), org.bukkit.persistence.PersistentDataType.STRING,
java.util.UUID.randomUUID().toString()));
a.inventory.setItem(0, forged); f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
}
a.inventory.setItem(0, book);
a.inventory.getInventory().setSelectedSlot(2);
var equipped = a.inventory.getItem(2).clone();
// Keep valid owner/session/slot but substitute another structurally valid echo-shard spell reference.
var forged = equipped.clone(); forged.editMeta(meta -> meta.getPersistentDataContainer().set(
new org.bukkit.NamespacedKey("fantasystick", "control_ability_id"), org.bukkit.persistence.PersistentDataType.STRING, "fireball"));
forged.setType(Material.SNOWBALL); a.inventory.setItem(2, forged);
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(forged).isPresent(),
"This must exercise authoritative loadout matching, not only malformed item rejection");
f.dispatcher.accept(use.apply(forged, org.bukkit.inventory.EquipmentSlot.HAND));
a.inventory.setItem(2, equipped);
for (int slot : new int[]{3, 4}) {
a.inventory.getInventory().setSelectedSlot(slot);
f.dispatcher.accept(use.apply(a.inventory.getItem(slot), org.bukkit.inventory.EquipmentSlot.HAND));
}
assertTrue(f.inputs.isEmpty(), "No ability/cast authority from invalid or inert inputs");
a.inventory.getInventory().setSelectedSlot(0);
var allowed = use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND);
allowed.setUseInteractedBlock(org.bukkit.event.Event.Result.DENY);
allowed.setUseItemInHand(org.bukkit.event.Event.Result.ALLOW);
f.dispatcher.accept(allowed);
assertEquals(1, f.inputs.size(), "An independent native block denial does not forbid an explicitly allowed item action");
assertEquals(org.bukkit.event.Event.Result.DENY, allowed.useInteractedBlock());
f.dispatcher.accept(allowed); assertEquals(1, f.inputs.size());
var input = f.inputs.getFirst();
a.inventory.getInventory().setSelectedSlot(1); assertFalse(input.stillCurrent());
a.inventory.getInventory().setSelectedSlot(0);
a.inventory.setItemInOffHand(null); assertFalse(input.stillCurrent());
f.core.sessions.exit(a.player);
a.inventory.setItem(0, book); f.dispatcher.accept(use.apply(book, org.bukkit.inventory.EquipmentSlot.HAND));
assertEquals(1, f.inputs.size());
}
}
@Test void longbowUsesNativeDrawingAndReportsReleaseWithoutVanillaAmmoOrWeaponUse() throws Exception {
try (var f = new Fixture()) {
f.loadout("longbow");
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
var bow = a.inventory.getItem(2).clone();
a.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
var before = a.snapshot();
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(start);
assertEquals(org.bukkit.event.Event.Result.DENY, start.useItemInHand());
assertEquals(1, a.useStarts, "Begin native drawing directly, not vanilla BowItem.use's ammunition path");
assertEquals(bow, a.activeItem); assertTrue(f.inputs.isEmpty(), "The spell consumes a release, not each right-click");
assertEquals(net.minecraft.world.item.ItemUseAnimation.BOW,
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).getUseAnimation());
f.onInput = input -> assertTrue(a.activeItem.isEmpty(), "Stop native release before BowItem can draw ordinary ammunition");
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 23);
f.dispatcher.accept(release);
assertEquals(1, f.inputs.size()); assertEquals(1, a.useClears);
var input = f.inputs.getFirst();
assertEquals("longbow", input.ability());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, input.gesture());
assertEquals(23, input.heldTicks()); assertTrue(input.stillCurrent());
// Execute the actual post-event ItemStack continuation on the cleared native use item.
// Null world/entity ports deliberately fail if it tries to run a weapon/effect instead of AIR.
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.activeItem).releaseUsing(null, null, 0);
assertArrayEquals(before, a.snapshot(), "No arrows, durability, controls or ordinary stacks consumed");
f.dispatcher.accept(release); assertEquals(1, f.inputs.size());
}
}
@Test void drawingIsRevokedOnExitHeldChangeSlotReplacementAndLifecycleWithoutClearingUnrelatedUse() throws Exception {
for (int route = 0; route < 9; route++) try (var f = new Fixture()) {
f.loadout("longbow");
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
var bow = a.inventory.getItem(2).clone();
var start = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
if (route == 8) a.startUse = () -> f.core.sessions.exit(a.player);
f.dispatcher.accept(start); assertEquals(1, a.useStarts);
switch (route) {
case 0 -> f.core.sessions.exit(a.player);
case 1 -> f.bridge.close();
case 2 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
case 3 -> { f.dispatcher.accept(f.death(org.bukkit.damage.DamageType.GENERIC)); f.core.tick(); }
case 4 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3));
case 5 -> { a.inventory.setItem(2, new ItemStack(Material.DIAMOND)); f.changed(); }
case 6 -> { a.activeItem = new ItemStack(Material.APPLE); f.core.sessions.exit(a.player); }
case 7 -> f.dispatcher.accept(new org.bukkit.event.player.PlayerRespawnEvent(a.player, a.location, false));
case 8 -> { /* Exit ran inside the native start callback. */ }
default -> throw new AssertionError(route);
}
if (route == 6) {
assertEquals(new ItemStack(Material.APPLE), a.activeItem); assertEquals(0, a.useClears);
} else assertTrue(a.activeItem.isEmpty(), "Own drawing survived lifecycle route " + route);
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20));
assertTrue(f.inputs.isEmpty(), "Stale draw release must never trigger a spell");
if (route == 6) assertEquals(new ItemStack(Material.APPLE), a.activeItem);
}
}
@Test void releaseSetterFailureIsReportedWithoutEmittingOrReplayingTheOwnedInput() throws Exception {
try (var f = new Fixture()) {
f.loadout("longbow");
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
var bow = a.inventory.getItem(2).clone();
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
a.clearUse = () -> { throw new IllegalStateException("Native clear failed after changing state"); };
var release = new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 20);
assertDoesNotThrow(() -> f.bridge.release(release), "The native handler must contain and report setter failure");
assertEquals(1, f.bridgeFailures.size(), "Native uncertainty must reach transition diagnostics");
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
assertTrue(f.inputs.isEmpty()); assertTrue(a.activeItem.isEmpty());
a.clearUse = () -> {};
f.dispatcher.accept(release); assertTrue(f.inputs.isEmpty());
f.bridgeFailures.clear();
}
}
@Test void inputCallbacksMayExitOrFailWithoutReleasingVanillaContainmentOrReplaying() throws Exception {
for (boolean fail : new boolean[]{false, true}) try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var book = a.inventory.getItem(0).clone();
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
book, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.onInput = input -> {
assertTrue(input.stillCurrent());
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
if (fail) throw new IllegalStateException("Action adapter failed");
f.core.sessions.exit(a.player);
assertFalse(input.stillCurrent());
};
f.dispatcher.accept(event); f.dispatcher.accept(event);
assertEquals(1, f.inputs.size());
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
if (fail) {
assertEquals(1, f.bridgeFailures.size());
assertInstanceOf(IllegalStateException.class, f.bridgeFailures.getFirst().cause());
f.bridgeFailures.clear();
} else assertEquals(Material.DIAMOND_SWORD, a.inventory.getItem(0).getType());
}
}
@Test void drawVetoesDuplicateStartsAndUnrelatedNativeUseArePreserved() throws Exception {
try (var f = new Fixture()) {
f.loadout("longbow");
var a = f.actor; f.core.activate(a); a.inventory.getInventory().setSelectedSlot(2);
var bow = a.inventory.getItem(2).clone();
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
f.dispatcher.accept(event); assertEquals(0, a.useStarts);
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT);
a.activeItem = new ItemStack(Material.APPLE);
f.dispatcher.accept(event); assertEquals(0, a.useStarts); assertEquals(0, a.useClears);
a.activeItem = new ItemStack(Material.AIR);
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
event.setUseItemInHand(org.bukkit.event.Event.Result.DEFAULT); f.dispatcher.accept(event);
assertEquals(1, a.useStarts);
var held = new org.bukkit.event.player.PlayerItemHeldEvent(a.player, 2, 3);
held.setCancelled(true); f.dispatcher.accept(held);
assertEquals(bow, a.activeItem); assertEquals(0, a.useClears);
f.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(a.player, bow, 0));
assertEquals(1, f.inputs.size()); assertEquals(0, f.inputs.getFirst().heldTicks(),
"Input routing must not invent partial-draw balance or charge thresholds");
}
}
@Test void protectedIconsCannotAttackAsVanillaWeaponsButOrdinaryAttacksStayUntouched() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var target = port(org.bukkit.entity.Zombie.class);
var before = a.snapshot();
for (int slot = 0; slot < 9; slot++) {
a.inventory.getInventory().setSelectedSlot(slot);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "Control slot " + slot + " must not deal a second ordinary weapon hit");
}
assertArrayEquals(before, a.snapshot());
f.core.sessions.exit(a.player);
a.inventory.getInventory().setSelectedSlot(0);
var ordinary = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
var vetoed = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
}
}
@Test void nativeUseDropTransferCraftAndDispenseCannotConsumeOrExportProtectedItems() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
@@ -1690,6 +1969,30 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void shiftClickCannotEquipAHeadWhoseNativeComponentTargetsChestLegsOrFeet() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
for (var slot : new org.bukkit.inventory.EquipmentSlot[]{org.bukkit.inventory.EquipmentSlot.CHEST,
org.bukkit.inventory.EquipmentSlot.LEGS, org.bukkit.inventory.EquipmentSlot.FEET}) {
var head = new ItemStack(Material.PLAYER_HEAD);
head.setData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE,
io.papermc.paper.datacomponent.item.Equippable.equippable(slot));
a.inventory.setItem(20, head);
var before = a.snapshot();
var event = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
f.dispatcher.accept(event);
assertTrue(event.isCancelled(), "Native quick-move uses the component's " + slot + " destination, not the head material");
assertArrayEquals(before, a.snapshot());
}
a.inventory.setItem(20, new ItemStack(Material.PLAYER_HEAD));
var allowed = f.click(20, org.bukkit.event.inventory.ClickType.SHIFT_LEFT,
org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
f.dispatcher.accept(allowed);
assertFalse(allowed.isCancelled(), "A genuine unprotected head may still equip in HEAD");
}
}
@Test void cosmeticAttributesAndNonHeadEquippableComponentsCannotBypassArmorGuard() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
@@ -1779,6 +2082,78 @@ class EquipmentSessionEventsNativeTest {
}
}
@Test void recipeBookAutoFillCannotPullAnExactBoundControlFromTheHotbar() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var control = a.inventory.getItem(1).clone();
var key = new org.bukkit.NamespacedKey("test", "exact_control_recipe");
var recipe = new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND))
.addIngredient(new org.bukkit.inventory.RecipeChoice.ExactChoice(control));
f.recipes.put(key, recipe);
var nativeControl = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(control);
assertFalse(net.minecraft.world.entity.player.Inventory.isUsableForCrafting(nativeControl),
"Ordinary auto-fill ignores named controls, but exact ingredients bypass that check");
assertEquals(1, a.inventory.getInventory().findSlotMatchingCraftingIngredient(
new io.papermc.paper.inventory.recipe.ItemOrExact.Exact(nativeControl), net.minecraft.world.item.ItemStack.EMPTY));
var before = a.snapshot();
for (boolean all : new boolean[]{false, true}) {
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, all);
f.dispatcher.accept(event);
assertTrue(event.isCancelled(), "Auto-fill has no ordinary InventoryClickEvent custody check");
assertArrayEquals(before, a.snapshot());
assertEquals(key, event.getRecipe()); assertEquals(all, event.isMakeAll());
}
var ordinaryKey = new org.bukkit.NamespacedKey("test", "ordinary_recipe");
f.recipes.put(ordinaryKey, new org.bukkit.inventory.ShapelessRecipe(ordinaryKey, new ItemStack(Material.DIAMOND))
.addIngredient(Material.STONE));
a.inventory.setItem(20, new ItemStack(Material.STONE));
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, ordinaryKey, false);
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
}
}
@Test void autoFillGuardsShapedCookingUnnamedAndGridControlsWithoutBlockingOrdinaryRecipes() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
var key = new org.bukkit.NamespacedKey("test", "autofill");
var control = a.inventory.getItem(1).clone();
for (var recipe : new org.bukkit.inventory.Recipe[]{
new org.bukkit.inventory.ShapedRecipe(key, new ItemStack(Material.DIAMOND)).shape("S")
.setIngredient('S', new org.bukkit.inventory.RecipeChoice.ExactChoice(control)),
new org.bukkit.inventory.FurnaceRecipe(key, new ItemStack(Material.DIAMOND),
new org.bukkit.inventory.RecipeChoice.ExactChoice(control), 0, 100)}) {
f.recipes.put(key, recipe);
var event = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
var before = a.snapshot(); f.dispatcher.accept(event);
assertTrue(event.isCancelled()); assertArrayEquals(before, a.snapshot());
}
f.recipes.put(key, new org.bukkit.inventory.ShapelessRecipe(key, new ItemStack(Material.DIAMOND)).addIngredient(Material.STICK));
var named = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
f.dispatcher.accept(named); assertFalse(named.isCancelled(), "Native name exclusion already prevents choosing the icon");
var meta = control.getItemMeta(); meta.displayName(null); control.setItemMeta(meta);
a.inventory.setItem(1, control);
assertTrue(new games.dmg.fantasystick.items.SessionControls().identify(control).isPresent());
var unnamed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, true);
f.dispatcher.accept(unnamed); assertTrue(unnamed.isCancelled(), "Custody cannot depend on a display name");
var grid = (org.bukkit.inventory.CraftingInventory) a.view.getTopInventory();
grid.setMatrix(new ItemStack[]{control, null, null, null});
var stale = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer,
new org.bukkit.NamespacedKey("test", "missing_recipe"), false);
f.dispatcher.accept(stale); assertTrue(stale.isCancelled(), "Do not return existing protected grid contents through vanilla auto-fill");
grid.setMatrix(new ItemStack[4]);
var vetoed = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
vetoed.setCancelled(true); f.dispatcher.accept(vetoed); assertTrue(vetoed.isCancelled());
f.core.sessions.exit(a.player);
// A recovered/remnant control outside an active session is still not a crafting ingredient.
a.inventory.setItem(20, control);
var remnant = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
f.dispatcher.accept(remnant); assertTrue(remnant.isCancelled());
a.inventory.setItem(20, new ItemStack(Material.STICK));
var ordinary = new com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent(f.eventPlayer, key, false);
f.dispatcher.accept(ordinary); assertFalse(ordinary.isCancelled());
}
}
@Test void genericTransformationResultsAndArmorStandConsumptionProtectHiddenControlInputs() throws Exception {
try (var f = new Fixture()) {
var a = f.actor; f.core.activate(a);
@@ -1880,8 +2255,11 @@ class EquipmentSessionEventsNativeTest {
boolean throwingDiagnostics;
final EquipmentSessionEvents bridge;
final java.util.ArrayList<games.dmg.fantasystick.session.EquipmentSessions.Failure> bridgeFailures = new java.util.ArrayList<>();
final java.util.List<games.dmg.fantasystick.session.ControlInput> inputs = new java.util.ArrayList<>();
java.util.function.Consumer<games.dmg.fantasystick.session.ControlInput> onInput = ignored -> { };
final PlayerSheetDropsNativeTest.WorldFixture world = new PlayerSheetDropsNativeTest.WorldFixture();
org.bukkit.entity.Player eventPlayer;
final java.util.Map<org.bukkit.NamespacedKey, org.bukkit.inventory.Recipe> recipes = new java.util.HashMap<>();
final Plugin plugin = FantasyStickLifecycleNativeTest.port(Plugin.class, (name, args) -> switch (name) {
case "isEnabled" -> true;
case "getPluginMeta", "getDescription" -> new org.bukkit.plugin.PluginDescriptionFile("EventBridgeTest", "1", "test.Test");
@@ -1906,6 +2284,7 @@ class EquipmentSessionEventsNativeTest {
});
var server = FantasyStickLifecycleNativeTest.port(Server.class, (name, args) -> {
if (name.equals("getPluginManager")) return manager;
if (name.equals("getRecipe")) return recipes.get(args[0]);
throw new AssertionError(name);
});
eventPlayer = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(org.bukkit.entity.Player.class.getClassLoader(),
@@ -1934,13 +2313,24 @@ class EquipmentSessionEventsNativeTest {
}, failure -> {
bridgeFailures.add(failure);
if (throwingDiagnostics) throw new IllegalStateException("Broken diagnostics");
});
}, input -> { inputs.add(input); onInput.accept(input); });
dispatcher.register(bridge, plugin);
}
org.bukkit.event.inventory.InventoryClickEvent click(int raw, org.bukkit.event.inventory.ClickType click,
org.bukkit.event.inventory.InventoryAction action, int button) {
return new org.bukkit.event.inventory.InventoryClickEvent(actor.view, actor.view.getSlotType(raw), raw, click, action, button);
}
void loadout(String... assigned) throws Exception {
var original = actor.record;
var slots = new java.util.ArrayList<>(java.util.Collections.nCopies(7, ""));
for (int i = 0; i < assigned.length; i++) slots.set(i, assigned[i]);
var learned = slots.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
var record = new games.dmg.fantasystick.persistence.CharacterRecord(java.util.UUID.randomUUID(), original.name(),
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
original.health(), learned, slots, java.util.Map.of(), false, null, null);
core.characters.create(record).get(5, java.util.concurrent.TimeUnit.SECONDS);
actor.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(record));
}
void pickupBox() {
var event = click(9, org.bukkit.event.inventory.ClickType.LEFT,
org.bukkit.event.inventory.InventoryAction.PICKUP_ALL, -1);
@@ -62,6 +62,167 @@ class EquipmentSessionsNativeTest {
}
}
@Test void uncertainControlMutationWithReentrantExitStillRestoresOwnedGameMode() throws Exception {
try (var f = new Fixture()) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
var writes = new java.util.concurrent.atomic.AtomicInteger();
a.mutation = () -> {
if (writes.incrementAndGet() == 15) {
a.mutation = () -> {};
f.sessions.exit(a.player);
throw new IllegalStateException("Uncertain control write after packing");
}
};
f.sessions.enter(a.player); f.tick(); f.tick();
assertTrue(f.sessions.active(a.id).isEmpty());
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
assertNotNull(f.state(a).pending(), "Uncertain gear remains retained independently of safe mode cleanup");
assertFailures(f, IllegalStateException.class);
}
}
@Test void failedReceiptSaveNeverChangesModeOrPacksEquipment() throws Exception {
try (var f = new Fixture((source, target) -> { throw new java.io.IOException("Injected receipt failure"); })) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
a.beforeMode = () -> fail("Mode setter must not run before acknowledged receipt persistence");
f.sessions.enter(a.player); f.tick();
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
assertTrue(f.sessions.active(a.id).isEmpty());
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
assertFailures(f, java.io.IOException.class);
}
}
@Test void transferredCharacterNeverCarriesPreviousHoldersGameModeAndAlreadyAdventureNeedsNoChange() throws Exception {
try (var f = new Fixture()) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var first = f.actor(); first.gameMode = org.bukkit.GameMode.CREATIVE;
f.activate(first); f.sessions.exit(first.player); assertNull(f.state(first).pending());
assertEquals(org.bukkit.GameMode.CREATIVE, first.gameMode);
var second = f.actor(first.record); f.activate(second);
assertEquals("SURVIVAL", f.state(second).pending().previousGameMode());
f.sessions.exit(second.player); assertNull(f.state(second).pending());
assertEquals(org.bukkit.GameMode.SURVIVAL, second.gameMode);
var third = f.actor(first.record); third.gameMode = org.bukkit.GameMode.ADVENTURE;
third.beforeMode = () -> fail("Already-Adventure sessions must not claim an unnecessary mode change");
f.activate(third); assertNull(f.state(third).pending().previousGameMode());
f.sessions.exit(third.player); assertNull(f.state(third).pending());
assertEquals(org.bukkit.GameMode.ADVENTURE, third.gameMode);
}
}
@Test void recoverySynchronizesRestoredEquipmentButRetainsMissingGameModeProof() throws Exception {
try (var f = new Fixture()) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); var original = a.snapshot(); f.activate(a);
var reloaded = a.reload();
reloaded.pdc.remove(new org.bukkit.NamespacedKey("fantasystick", "adventure_session"));
var syncs = new java.util.concurrent.atomic.AtomicInteger(); reloaded.sync = syncs::incrementAndGet;
f.sessions = f.coordinator(Duration.ofSeconds(900));
f.sessions.recover(reloaded.player); f.tick();
assertArrayEquals(original, reloaded.snapshot());
assertEquals(org.bukkit.GameMode.ADVENTURE, reloaded.gameMode, "Missing proof cannot authorize a game-mode change");
assertNotNull(f.state(reloaded).pending());
assertEquals(1, syncs.get(), "Verified equipment restoration must still synchronize the client");
assertNotNull(f.state(reloaded).lastExit());
assertTrue(f.notices.stream().noneMatch(notice -> notice.message().contains("Equipment recovery refused: RESTORED")));
}
}
@Test void restartRecoveryRestoresSavedGameModeEvenWhenNewSessionPolicyIsDisabled() throws Exception {
try (var f = new Fixture()) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE; var original = a.snapshot();
f.activate(a);
var reloaded = a.reload();
// Fresh coordinator models loss of process-local claims at a restart; no orderly exit.
f.sessions = f.coordinator(Duration.ofSeconds(900));
f.sessions.recover(reloaded.player); f.tick();
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
assertArrayEquals(original, reloaded.snapshot());
assertNull(f.state(reloaded).pending());
f.sessions.recover(reloaded.player); f.tick();
assertEquals(org.bukkit.GameMode.CREATIVE, reloaded.gameMode);
assertArrayEquals(original, reloaded.snapshot());
}
}
@Test void reentrantExitDuringAdventureApplicationRestoresModeWithoutPackingOrStartingDeadline() throws Exception {
try (var f = new Fixture()) {
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, () -> true,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); var before = a.snapshot();
a.beforeMode = () -> { a.beforeMode = () -> {}; f.sessions.exit(a.player); };
f.sessions.enter(a.player); f.tick(); f.tick();
assertArrayEquals(before, a.snapshot());
assertEquals(org.bukkit.GameMode.SURVIVAL, a.gameMode);
assertTrue(f.sessions.active(a.id).isEmpty());
assertNull(f.state(a).pending()); assertNull(f.state(a).lastExit());
}
}
@Test void orphanAdventureOwnershipCannotBeOverwrittenEvenWhenNewModePolicyIsDisabled() throws Exception {
try (var f = new Fixture()) {
var a = f.actor();
new games.dmg.fantasystick.session.AdventureModeLease().enter(a.player, UUID.randomUUID(), "SURVIVAL");
var original = a.snapshot(); var proof = a.pdc.serializeToBytes();
f.sessions.enter(a.player); f.tick();
assertTrue(f.receipts.find(a.id).toCompletableFuture().get(5, TimeUnit.SECONDS).isEmpty());
assertTrue(f.sessions.active(a.id).isEmpty());
assertArrayEquals(original, a.snapshot()); assertArrayEquals(proof, a.pdc.serializeToBytes());
}
}
@Test void adventurePolicyIsCapturedPerSessionAndSavedBeforeNativeModeChangeThenRestored() throws Exception {
try (var f = new Fixture()) {
var enabled = new java.util.concurrent.atomic.AtomicBoolean(true);
f.sessions = new EquipmentSessions(f.characters, f.receipts, Duration.ofSeconds(900), f.clock,
f.tasks::add, f.notices::add, f.failures::add,
games.dmg.fantasystick.presentation.SessionPresentation.NONE, enabled::get,
new games.dmg.fantasystick.session.AdventureModeLease());
var a = f.actor(); a.gameMode = org.bukkit.GameMode.CREATIVE;
a.beforeMode = () -> {
try { assertEquals("CREATIVE", f.state(a).pending().previousGameMode()); }
catch (Exception error) { throw new AssertionError(error); }
};
f.sessions.enter(a.player);
enabled.set(false); // The entered request already captured its policy.
f.tick(); f.tick();
assertTrue(f.sessions.active(a.id).isPresent());
assertEquals(org.bukkit.GameMode.ADVENTURE, a.gameMode);
assertEquals(EquipmentStorage.RestoreResult.RESTORED, f.sessions.exit(a.player));
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
assertNull(f.state(a).pending());
a.beforeMode = () -> { throw new AssertionError("Disabled sessions must not change mode"); };
f.activate(a);
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
assertNull(f.state(a).pending().previousGameMode());
f.sessions.exit(a.player);
assertEquals(org.bukkit.GameMode.CREATIVE, a.gameMode);
}
}
@Test void nativePlayerMarkerBracketsPackingAndRestorationAndSurvivesSerialization() throws Exception {
try (var f = new Fixture()) {
var actor = f.actor();
@@ -1521,7 +1682,17 @@ class EquipmentSessionsNativeTest {
org.bukkit.Location teleported;
boolean teleportAccepted = true;
Runnable teleport = () -> {};
org.bukkit.GameMode gameMode = org.bukkit.GameMode.SURVIVAL;
Runnable beforeMode = () -> {};
double health = 20;
Runnable beforeHealth = () -> {};
Runnable healthMutation = () -> {};
float attackStrength = 1;
ItemStack activeItem = new ItemStack(Material.AIR);
int useStarts;
int useClears;
Runnable startUse = () -> {};
Runnable clearUse = () -> {};
double normalMaximum = 28;
int teleports;
final org.bukkit.attribute.AttributeInstance maximum = FantasyStickLifecycleNativeTest.port(
@@ -1548,8 +1719,19 @@ class EquipmentSessionsNativeTest {
}
case "getOpenInventory" -> view;
case "getLocation" -> location.clone();
case "getGameMode" -> gameMode;
case "setGameMode" -> { beforeMode.run(); gameMode = (org.bukkit.GameMode) args[0]; yield null; }
case "getHealth" -> health;
case "setHealth" -> { health = (Double) args[0]; yield null; }
case "getAttackCooldown" -> attackStrength;
case "getActiveItem" -> activeItem.clone();
case "hasActiveItem" -> !activeItem.isEmpty();
case "getActiveItemHand" -> org.bukkit.inventory.EquipmentSlot.HAND;
case "startUsingItem" -> {
assertEquals(org.bukkit.inventory.EquipmentSlot.HAND, args[0]);
useStarts++; activeItem = inventory.getItemInMainHand().clone(); startUse.run(); yield null;
}
case "clearActiveItem" -> { useClears++; activeItem = new ItemStack(Material.AIR); clearUse.run(); yield null; }
case "setHealth" -> { beforeHealth.run(); health = (Double) args[0]; healthMutation.run(); yield null; }
case "getAttribute" -> { assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]); yield maximum; }
case "teleport" -> {
teleports++; teleported = ((org.bukkit.Location) args[0]).clone(); teleport.run();
@@ -1576,6 +1758,7 @@ class EquipmentSessionsNativeTest {
Actor reload() throws Exception {
var reloaded = new Actor(record, id);
reloaded.pdc.readFromBytes(pdc.serializeToBytes(), true);
reloaded.gameMode = gameMode;
var ops = net.minecraft.resources.RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE,
org.bukkit.craftbukkit.CraftRegistry.getMinecraftRegistry());
for (int slot = 0; slot < inventory.getSize(); slot++) {
@@ -0,0 +1,384 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.bukkit.attribute.Attribute;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FantasyHealthNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void nativeMaximumIgnoresBonusesPenaltiesAndHealthBoostWhileTheirOrdinaryOwnershipAndExpiryRemainIntact() 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);
var bonus = modifier("bonus", 5, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
var penalty = modifier("penalty", -0.5, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL);
a.maximumHealth.addTransientModifier(bonus); a.maximumHealth.addTransientModifier(penalty);
var boost = net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value();
boost.addAttributeModifiers(a.healthAttributes, 1);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(6);
a.maximumHealth.setBaseValue(30); // A later legitimate ordinary base change must survive exit.
var later = modifier("later", 4, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(later);
boost.addAttributeModifiers(a.healthAttributes, 2);
assertEquals(10, a.maximumHealth.getValue(), "New native potion modifiers cannot change fantasy maximum");
boost.removeAttributeModifiers(a.healthAttributes);
a.maximumHealth.removeModifier(bonus); a.maximumHealth.removeModifier(penalty);
var zero = modifier("zero", -1, net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL);
a.maximumHealth.addTransientModifier(zero);
assertEquals(10, a.maximumHealth.getValue(), "Even a zero multiplier cannot bypass the native effective-value lease");
a.maximumHealth.removeModifier(zero);
assertEquals(6, a.player.getHealth());
var serialized = a.maximumHealth.pack();
assertEquals(30, serialized.baseValue()); assertEquals(java.util.List.of(later), serialized.modifiers());
var loaded = new net.minecraft.world.entity.ai.attributes.AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, ignored -> {});
loaded.apply(serialized); assertEquals(34, loaded.getValue(), "Player saves retain ordinary attribute ownership, not the fantasy maximum");
a.player.setFoodLevel(2); a.player.setSaturation(0); a.player.setExhaustion(1);
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertEquals(34, a.maximumHealth.getValue()); assertEquals(12, a.player.getHealth());
assertEquals(java.util.Set.of(later), a.maximumHealth.getModifiers(), "Expired/removed modifiers are not replayed from a snapshot");
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertFalse(a.player.isHealthScaled()); assertEquals(20, a.player.getHealthScale());
}
}
@Test void activeCharacterBookShowsCurrentNativeDamageRatherThanTheLastSavedHealth() 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);
a.player.setHealth(4);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Health: 4/10"));
}
}
@Test void activeBlueMistAndOneRedDefeatBurstUseWorldLocationsAndStopWithTheSession() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
var entry = a.player.getLocation(); f.activate(a);
f.pulses.forEach(Runnable::run);
assertTrue(dust(a, org.bukkit.Color.BLUE) > 0, "Active fantasy mode must have visible public blue particles");
assertEquals(0, dust(ordinary, org.bukkit.Color.BLUE));
a.core.location.add(20, 0, 20);
var defeatLocation = a.player.getLocation().add(0, 1, 0);
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 java.util.ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); assertTrue(death.isCancelled());
PartyBookNativeTest.drain(f);
assertEquals(1, dust(a, org.bukkit.Color.RED));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().anyMatch(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.asList(call.getArguments()).contains(defeatLocation)));
assertEquals(entry, a.player.getLocation()); assertEquals(28, a.player.getHealth());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
long blue = dust(a, org.bukkit.Color.BLUE);
f.pulses.forEach(Runnable::run); f.dispatcher.accept(death);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE)); assertEquals(1, dust(a, org.bukkit.Color.RED));
assertEquals(tail, a.core.inventory.getItemInOffHand());
f.plugin.setEnabled(false); f.awaitWorkers();
f.pulses.forEach(Runnable::run);
assertEquals(blue, dust(a, org.bukkit.Color.BLUE), "Retired timers cannot emit fantasy effects");
}
}
@Test void uncappedConstitutionEntersAtTheNativeCeilingAndPersistsOnlyBoundedRemainingHealth() 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);
var old = a.core.record;
var con = java.math.BigInteger.TEN.pow(1000);
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.CON, con);
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), new java.math.BigDecimal(con.add(java.math.BigInteger.TEN)),
old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
var ceiling = games.dmg.fantasystick.session.NativeHealthLimit.read();
f.activate(a);
assertEquals(ceiling.doubleValue(), a.maximumHealth.getValue()); assertEquals(ceiling.doubleValue(), a.player.getHealth());
a.player.setHealth(3.25);
a.core.inventory.setItemInOffHand(null); f.changed(a);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var suspended = characters.find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(con, suspended.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, suspended.health().compareTo(new java.math.BigDecimal("3.25")));
assertEquals(ceiling, games.dmg.fantasystick.session.NativeHealthLimit.read(), "Never mutate global attribute limits");
}
}
@Test void serializedInterruptedSessionRecoversOrdinaryVitalsAndRemainingHealthWithoutReplayingExpiredModifiers() throws Exception {
FantasyStickLifecycleNativeTest.Actor a;
byte[] nativeProof, characterFile, receiptFile;
byte[][] items;
net.minecraft.world.entity.ai.attributes.AttributeInstance.Packed ordinaryAttributes;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value().addAttributeModifiers(a.healthAttributes, 1);
f.activate(a); a.player.setHealth(4); a.player.setFoodLevel(2);
nativeProof = a.core.pdc.serializeToBytes();
items = java.util.Arrays.stream(a.core.snapshot()).map(item -> item == null ? null : item.serializeAsBytes()).toArray(byte[][]::new);
ordinaryAttributes = a.maximumHealth.pack();
characterFile = java.nio.file.Files.readAllBytes(data.resolve("characters").resolve(a.core.record.id() + ".yml"));
receiptFile = java.nio.file.Files.readAllBytes(data.resolve("sessions").resolve(a.core.id + ".yml"));
}
// Recreate one concrete crash window from native item/PDC/attribute and YAML saves.
// Orderly fixture disposal above is not mistaken for an actual server crash.
java.nio.file.Files.write(data.resolve("characters").resolve(a.core.record.id() + ".yml"), characterFile);
java.nio.file.Files.write(data.resolve("sessions").resolve(a.core.id + ".yml"), receiptFile);
a.core.inventory.setContents(java.util.Arrays.stream(items).map(bytes -> bytes == null ? null : org.bukkit.inventory.ItemStack.deserializeBytes(bytes))
.toArray(org.bukkit.inventory.ItemStack[]::new));
a.core.pdc.readFromBytes(nativeProof, true);
a.maximumHealth.removeModifiers(); a.maximumHealth.apply(ordinaryAttributes);
// Native potion attributes use permanent storage. Exercise the real expiry/removal
// hook after load; plugin recovery must not reintroduce that removed modifier.
net.minecraft.world.effect.MobEffects.HEALTH_BOOST.value().removeAttributeModifiers(a.healthAttributes);
a.core.health = 4; a.food.setFoodLevel(2); a.healthScaled = true; a.healthScale = 10;
a.core.gameMode = org.bukkit.GameMode.ADVENTURE;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.players.put(a.core.id, a.player); f.plugin.setEnabled(true); f.tick();
PartyBookNativeTest.drain(f);
var receipt = f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow();
assertNull(receipt.pending());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(28, a.maximumHealth.getValue()); assertTrue(a.maximumHealth.getModifiers().isEmpty());
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertFalse(a.player.isHealthScaled()); assertEquals(0, a.core.teleports);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
// An unrelated later ordinary change cannot be overwritten by a repeated recovery.
a.player.setHealth(5); a.player.setFoodLevel(9);
((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).recover(a.player);
f.tick(); assertEquals(5, a.player.getHealth()); assertEquals(9, a.player.getFoodLevel());
}
}
@Test void failedEntrySettersAndMissingExitProofNeverLeaveTheNativeMaximumOverridden() throws Exception {
for (int route = 0; route < 6; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("failure-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("failure-" + route));
f.players.put(a.core.id, a.player);
Runnable fail = () -> { a.core.beforeHealth = () -> {}; a.core.healthMutation = () -> {}; throw new IllegalStateException("Native health setter failed"); };
if (route == 0) a.core.beforeHealth = fail;
if (route == 1) a.core.healthMutation = fail;
if (route == 5) a.core.sync = () -> {
a.core.sync = () -> {}; a.player.setHealth(3);
throw new IllegalStateException("Activation client synchronization failed after native damage");
};
f.activate(a);
if (route >= 2 && route < 5) {
a.player.setHealth(4); a.player.setFoodLevel(2);
if (route == 2) a.core.pdc.remove(new org.bukkit.NamespacedKey("fantasystick", "vitals_session"));
else if (route == 3) org.mockito.Mockito.doThrow(new IllegalStateException("Native equipment update failed")).when(a.nativePlayer).detectEquipmentUpdates();
else a.core.beforeMutation = () -> { throw new IllegalStateException("Equipment restoration write failed"); };
a.core.inventory.setItemInOffHand(null); f.changed(a);
if (route < 4) {
assertEquals(4, a.player.getHealth(), "Missing/uncertain proof cannot authorize a stale ordinary-health replay");
assertEquals(2, a.player.getFoodLevel());
}
}
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(28, a.maximumHealth.getValue(), "Only the owned maximum hook is safe to remove despite an uncertain setter/proof");
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
if (route < 2) {
assertEquals(route == 0 ? 20 : 10, a.player.getHealth(), "Do not blindly heal an uncertain native write");
assertFalse(a.player.isHealthScaled()); assertEquals(20, a.player.getHealthScale());
}
if (route >= 4) {
assertEquals(20, a.player.getHealth(), "Proven vitals cleanup is independent of uncertain equipment/client state");
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health()
.compareTo(java.math.BigDecimal.valueOf(route == 4 ? 4 : 3)));
}
}
}
@Test void failedHealthWriteRestoresOrdinaryGearButRetainsTheReceiptAndCannotResumeStaleHealth() 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 sheet = a.core.inventory.getItemInOffHand().clone(); a.player.setHealth(4);
var path = data.resolve("characters").resolve(a.core.record.id() + ".yml");
byte[] acknowledged = java.nio.file.Files.readAllBytes(path);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f);
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
assertArrayEquals(acknowledged, java.nio.file.Files.readAllBytes(path));
}
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertEquals(28, a.maximumHealth.getValue()); assertEquals(20, a.player.getHealth());
a.core.inventory.setItemInOffHand(sheet); f.changed(a); f.tick();
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty(), "An unacknowledged health save cannot become a free heal on reentry");
assertArrayEquals(acknowledged, java.nio.file.Files.readAllBytes(path));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("persistence or recovery is pending")));
}
}
@Test void delayedRecoveryReadCannotRecreateOwnershipAfterTheHealthSaveAndExitAlreadyCompleted() 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 sheet = a.core.inventory.getItemInOffHand().clone(); a.player.setHealth(4);
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.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, TimeUnit.SECONDS)); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
});
try {
assertTrue(entered.await(5, TimeUnit.SECONDS));
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
sessions.recover(a.player);
var stale = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(stale);
release.countDown(); gate.get(5, TimeUnit.SECONDS);
characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS);
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow().pending());
stale.run();
a.core.inventory.setItemInOffHand(sheet); f.changed(a); f.tick();
assertFalse(a.messages.stream().anyMatch(message -> message.contains("persistence or recovery is pending")),
"A delayed old receipt must not manufacture a new ownership blocker after acknowledged exit");
f.tick(); f.tick(); assertTrue(sessions.active(a.core.id).isPresent()); assertEquals(4, a.player.getHealth());
} finally { release.countDown(); }
}
}
@Test void nativeHealthChangesDuringClientScaleDeliveryAreNotOverwrittenByInitialActivation() 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);
a.scaleMutation = () -> {
a.scaleMutation = () -> {};
a.player.setHealth(Math.min(a.player.getHealth(), a.maximumHealth.getValue()) - 1);
};
f.activate(a);
assertEquals(9, a.player.getHealth(), "Native damage during a reentrant client update must not be replaced with another full-health initialization");
}
}
@Test void quitAndDisablePersistDamageAndRestoreOnlyOwnedOrdinaryVitalsAndDisplay() throws Exception {
for (boolean quit : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("lifecycle-" + quit))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("lifecycle-" + quit)); f.players.put(a.core.id, a.player);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a); a.player.setHealth(4); a.player.setFoodLevel(1);
a.maximumHealth.setBaseValue(8); // A legitimate later ordinary maximum, below the pre-entry health.
a.player.setHealthScale(44); // A later display owner's value is not ours to undo.
if (quit) {
a.core.online = false;
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
PartyBookNativeTest.drain(f);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
} else { f.plugin.setEnabled(false); f.awaitWorkers(); }
assertEquals(8, a.maximumHealth.getValue()); assertEquals(8, a.player.getHealth());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation()); assertEquals(3, a.player.getExhaustion());
assertEquals(44, a.player.getHealthScale()); assertTrue(a.player.isHealthScaled());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data.resolve("lifecycle-" + quit)).load(a.core.record.id()).orElseThrow();
assertFalse(saved.dead()); assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(4))); assertEquals(0, a.core.teleports);
}
}
@Test void aDamagedTransferredCharacterNeverTransfersItsFormerHoldersOrdinaryVitals() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
a.player.setHealth(12); a.player.setFoodLevel(7);
b.player.setHealth(19); b.player.setFoodLevel(3);
var sheet = a.core.inventory.getItemInOffHand().clone(); f.activate(a); a.player.setHealth(3);
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
assertEquals(3, b.player.getHealth()); assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
b.player.setHealth(2); b.core.inventory.setItemInOffHand(null); f.changed(b); PartyBookNativeTest.drain(f);
assertEquals(19, b.player.getHealth()); assertEquals(3, b.player.getFoodLevel());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(2)));
assertEquals(b.core.record, characters.find(b.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void shuttingDownMaximumHooksContinuesAfterAnOriginalNativeObserverThrows() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var throwNow = new java.util.concurrent.atomic.AtomicBoolean();
var attributes = new java.util.ArrayList<net.minecraft.world.entity.ai.attributes.AttributeInstance>();
for (int i = 0; i < 2; i++) {
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player);
var attribute = new net.minecraft.world.entity.ai.attributes.AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH,
ignored -> { if (throwNow.get()) throw new IllegalStateException("Native dirty observer failed during cleanup"); });
attributes.add(attribute);
org.mockito.Mockito.when(a.nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH)).thenReturn(attribute);
f.activate(a); assertEquals(10, attribute.getValue());
}
throwNow.set(true);
var vitals = (games.dmg.fantasystick.session.SessionVitals) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "vitals");
var failure = assertThrows(IllegalStateException.class, vitals::close);
assertEquals(2, failure.getSuppressed().length);
for (var attribute : attributes) assertEquals(20, attribute.getValue(), "Every player's hook must be retired despite another cleanup failure");
}
}
private static long dust(FantasyStickLifecycleNativeTest.Actor a, org.bukkit.Color color) {
return org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream().filter(call ->
call.getMethod().getName().equals("spawnParticle") && java.util.Arrays.stream(call.getArguments())
.anyMatch(argument -> argument instanceof org.bukkit.Particle.DustOptions dust && dust.getColor().equals(color))).count();
}
private static net.minecraft.world.entity.ai.attributes.AttributeModifier modifier(String name, double amount,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation operation) {
return new net.minecraft.world.entity.ai.attributes.AttributeModifier(net.minecraft.resources.Identifier.fromNamespaceAndPath("test", name), amount, operation);
}
@Test void actualOffhandEntryAppliesFantasyHealthAndExitRestoresOrdinaryHealthWithoutHealingTheCharacter() 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);
double ordinaryHealth = a.player.getHealth();
double ordinaryMaximum = a.player.getAttribute(Attribute.MAX_HEALTH).getValue();
var sheet = a.core.inventory.getItemInOffHand().clone();
f.activate(a);
assertEquals(10, a.player.getAttribute(Attribute.MAX_HEALTH).getValue());
assertEquals(10, a.player.getHealth(), "A fresh character starts full, independently of ordinary health");
a.player.setHealth(4);
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertEquals(ordinaryMaximum, a.player.getAttribute(Attribute.MAX_HEALTH).getValue());
assertEquals(ordinaryHealth, a.player.getHealth());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = characters.find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(4)));
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Resuming does not replay a first-activation heal");
}
}
}
@@ -60,13 +60,56 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
assertNotNull(fixture.packetListener);
assertEquals(1, fixture.timers.size());
assertEquals(5, fixture.timers.size(), "Presentation, ability cleanup, provenance, reward retry and session particles");
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
assertTrue(fixture.order.indexOf("CombatExperience") < recipe);
assertTrue(fixture.order.indexOf("Fireball") >= 0);
assertTrue(fixture.order.indexOf("Fireball") < recipe);
assertNotNull(Fixture.field(fixture.plugin, "abilityEngine"));
assertNotNull(Fixture.field(fixture.plugin, "abilityRegistry"));
assertTrue(fixture.order.indexOf("BasicAttack") >= 0);
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
}
}
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() 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); f.activate(a);
var original = a.core.snapshot();
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var saved = characters.renameCharacter(a.core.record.id(), "Current saved name").get(5, java.util.concurrent.TimeUnit.SECONDS);
assertTrue(saved.isPresent());
var event = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(0), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(event);
assertEquals(org.bukkit.event.Event.Result.DENY, event.useItemInHand());
// Drain reads before checking the scheduled action; production never waits on the tick thread.
characters.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
f.tick();
assertEquals(1, a.books.size(), "Registered book input must reach a real book consumer");
var item = a.books.getFirst();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, item.getType());
var nativeItem = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item);
var content = nativeItem.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
String text = content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
for (String expected : List.of("Current saved name", "STR: 0", "DEX: 0", "CON: 0", "INT: 0", "WIS: 0", "CHR: 0",
"Level: 1", "XP: 0/100", "Unspent points: 40", "Health: 10/10", "Abilities: none")) {
assertTrue(text.contains(expected), expected + " missing from " + text);
}
assertArrayEquals(original, a.core.snapshot(), "Opening a virtual book never replaces or grants an inventory item");
assertEquals(saved, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS));
}
}
@Test
void disableRemovesOnlyItsRecipeAndListenersAndIsSafeToRepeat() throws Exception {
try (var fixture = new Fixture(data)) {
@@ -95,7 +138,7 @@ class FantasyStickLifecycleNativeTest {
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "presentation-timer")) {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
@@ -145,6 +188,9 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(List.of(net.kyori.adventure.text.Component.text("Character sheet:"),
net.kyori.adventure.text.Component.text("Place in off hand"),
net.kyori.adventure.text.Component.text("to enter fantasy world.")), actor.fixture.view.getCursor().getItemMeta().lore());
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory).load(id).orElseThrow();
assertEquals(new java.math.BigInteger(points), saved.unspentPoints());
@@ -170,6 +216,21 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void invalidExperienceThresholdAndMaximumLevelFailBeforeInstallingGameplay() throws Exception {
for (String configuration : List.of("xp-per-level: 0\n", "xp-per-level: -1\n",
"maximum-level: 0\n", "maximum-level: 2147483648\n", "maximum-level: not-a-number\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var fixture = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty());
assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test
void installedGuardRejectsOnlyTheCharacterRecipeThroughTheNativeDispatcher() throws Exception {
try (var fixture = new Fixture(data)) {
@@ -400,38 +461,61 @@ class FantasyStickLifecycleNativeTest {
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
var receipts = f.receipts();
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
var requested = java.time.Instant.now();
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(); }
assertRestored(f, a, viewer);
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())) {
var state = reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending());
var exit = state.lastExit();
assertEquals(session.sessionId(), exit.sessionId()); assertEquals(a.core.record.id(), exit.characterId());
assertFalse(exit.exitedAt().isBefore(requested)); assertFalse(exit.exitedAt().isAfter(java.time.Instant.now()));
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(state, reopened.completeExit(a.core.id, session.sessionId(), java.time.Duration.ofHours(1))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS), "Repeated lifecycle acknowledgment must not extend the delay");
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
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);
if (!route.equals("disable")) {
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
}
try {
if (!route.equals("disable")) assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var requested = java.time.Instant.now();
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(); }
assertRestored(f, a, viewer);
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")) {
assertNotNull(receipts.find(a.core.id).toCompletableFuture()
.get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Native exit must not release its receipt ahead of blocked character-health persistence");
release.countDown();
// The real character worker completes its save callback before this queued read.
// That callback schedules receipt completion on the separate receipt worker.
characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
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())) {
var state = reopened.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending());
var exit = state.lastExit();
assertEquals(session.sessionId(), exit.sessionId()); assertEquals(a.core.record.id(), exit.characterId());
assertFalse(exit.exitedAt().isBefore(requested)); assertFalse(exit.exitedAt().isAfter(java.time.Instant.now()));
assertEquals(java.time.Duration.ofSeconds(900), java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(state, reopened.completeExit(a.core.id, session.sessionId(), java.time.Duration.ofHours(1))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS), "Repeated lifecycle acknowledgment must not extend the delay");
}
} finally { release.countDown(); }
}
}
}
@@ -638,33 +722,185 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void nativeModeCancellationRefusesEntryAndRetainsRestorationForExplicitRetry() throws Exception {
for (String route : List.of("entry", "retry", "restart")) {
boolean entering = route.equals("entry");
Path directory = data.resolve("veto-" + route); java.nio.file.Files.createDirectories(directory);
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true);
var a = new Actor(directory); f.players.put(a.core.id, a.player);
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
var original = a.core.snapshot();
var listener = new org.bukkit.event.Listener() {};
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().register(new org.bukkit.plugin.RegisteredListener(
listener, (ignored, raw) -> {
var event = (org.bukkit.event.player.PlayerGameModeChangeEvent) raw;
if (event.getNewGameMode() == (entering ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE)) event.setCancelled(true);
}, org.bukkit.event.EventPriority.HIGHEST, f.plugin, false));
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
if (entering) {
f.changed(a); f.tick(); f.tick(); f.tick();
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
assertNull(saved.pending()); assertNull(saved.lastExit());
} else {
f.activate(a); sessions.exit(a.player);
f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
org.bukkit.event.player.PlayerGameModeChangeEvent.getHandlerList().unregister(listener);
if (route.equals("restart")) {
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
while (a.player.getGameMode() == org.bukkit.GameMode.ADVENTURE) f.tick();
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending(),
"A serialized RESTORED marker cannot replace lost process-local gear proof");
} else {
sessions.recover(a.player); f.tick();
assertNull(f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending());
}
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
}
assertArrayEquals(original, a.core.snapshot());
}
}
}
@Test void packagedAdventureRestoresOnQuitDefeatDisableAndPreservesLaterAdminModeChanges() throws Exception {
for (String route : List.of("quit", "defeat", "disable", "admin")) {
Path directory = data.resolve(route); java.nio.file.Files.createDirectories(directory);
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true);
var a = new Actor(directory); f.players.put(a.core.id, a.player);
a.core.gameMode = org.bukkit.GameMode.CREATIVE; a.modeEvents = f.dispatcher::accept;
f.activate(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
switch (route) {
case "quit" -> f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, net.kyori.adventure.text.Component.empty()));
case "disable" -> f.plugin.setEnabled(false);
case "admin" -> {
a.player.setGameMode(org.bukkit.GameMode.SURVIVAL);
a.player.setGameMode(org.bukkit.GameMode.ADVENTURE);
sessions.exit(a.player);
}
case "defeat" -> {
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); assertTrue(death.isCancelled());
}
default -> throw new AssertionError(route);
}
assertEquals(route.equals("admin") ? org.bukkit.GameMode.ADVENTURE : org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
}
}
}
@Test void packagedAdventureDefaultAndAdminChangesApplyOnlyToNewSessions() 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);
a.core.gameMode = org.bukkit.GameMode.CREATIVE;
a.modeEvents = f.dispatcher::accept;
f.activate(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
assertEquals("CREATIVE", f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "false"});
while (sender.messages.isEmpty()) f.tick();
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
sessions.exit(a.player);
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
f.awaitCharacterAndReceipt(a);
var b = new Actor(data); f.players.put(b.core.id, b.player);
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
f.activate(b);
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
assertNull(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().pending().previousGameMode());
sessions.exit(b.player);
assertEquals(org.bukkit.GameMode.SURVIVAL, b.player.getGameMode());
}
}
@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()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
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);
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Quit restores native state but must retain ownership until health persistence completes");
release.countDown();
f.awaitCharacterAndReceipt(a);
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()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
} finally { release.countDown(); }
}
}
}
@Test void invalidFireballConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("fireball-base-damage: -1", "fireball-cooldown-seconds: -1",
"fireball-range-blocks: 0", "fireball-range-blocks: 1e1000000",
"fireball-lifetime-seconds: 0", "fireball-lifetime-seconds: 61")) {
var directory = data.resolve("invalid-fireball-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
@Test void invalidCombatRewardConfigurationFailsBeforeNativeRegistrationWithoutRewritingConfig() throws Exception {
int scenario = 0;
for (String row : List.of("passive-xp: -1", "ordinary-xp: 1.5", "boss-xp: nope",
"restricted-kill-spacing-blocks: -0.1", "restricted-kill-spacing-blocks: 1e1000000")) {
var directory = data.resolve("invalid-reward-" + scenario++); java.nio.file.Files.createDirectories(directory);
String config = row + "\n"; java.nio.file.Files.writeString(directory.resolve("config.yml"), config);
try (var f = new Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true), row);
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty()); assertTrue(f.tasks.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertEquals(config, java.nio.file.Files.readString(directory.resolve("config.yml")));
}
}
}
@@ -737,6 +973,14 @@ class FantasyStickLifecycleNativeTest {
var oldSessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(oldCharacters, "worker");
var receiptWorker = (java.util.concurrent.ExecutorService) Fixture.field(f.receipts(), "worker");
// Finish owned health/receipt durability before deliberately overlapping ONLY a
// rename with a new plugin lifetime. A pending health save now correctly retains
// the character claim; it is not safe authority to start a replacement session.
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
oldCharacters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS);
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
a.core.inventory.setItemInOffHand(transferredSheet);
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> {
entered.countDown();
@@ -775,7 +1019,9 @@ class FantasyStickLifecycleNativeTest {
private static void assertNativeName(Fixture f, Actor a, Actor viewer, String name) {
var tabs = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class);
assertFalse(tabs.isEmpty(), "Assembled session must send native tab output, not merely catch a CraftPlayer failure");
assertFalse(tabs.isEmpty(), () -> "Assembled session must send native tab output, not merely catch a CraftPlayer failure: "
+ f.presentationFailures.stream().map(record -> record.getMessage() + ": " + record.getThrown()).toList()
+ "; player notices=" + a.messages);
var tab = tabs.getLast();
assertEquals(java.util.EnumSet.of(net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME), tab.actions());
assertEquals(a.core.id, tab.entries().getFirst().profileId());
@@ -855,6 +1101,7 @@ class FantasyStickLifecycleNativeTest {
};
final List<BukkitTask> timers = new ArrayList<>();
final List<Runnable> pulses = new ArrayList<>();
final List<Runnable> delayed = new ArrayList<>();
com.comphenix.protocol.events.PacketListener packetListener;
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
final Thread main = Thread.currentThread();
@@ -923,6 +1170,12 @@ class FantasyStickLifecycleNativeTest {
tasks.add((Runnable) args[1]);
yield port(BukkitTask.class, (method, values) -> { throw new AssertionError("Unexpected task call " + method); });
}
case "runTaskLater" -> {
schedulerCalls.incrementAndGet();
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
delayed.add((Runnable) args[1]);
var task = mock(BukkitTask.class); timers.add(task); yield task;
}
default -> throw new AssertionError("Unexpected scheduler call " + name);
});
server = port(Server.class, (name, args) -> switch (name) {
@@ -971,6 +1224,12 @@ class FantasyStickLifecycleNativeTest {
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
}
void awaitCharacterAndReceipt(Actor actor) throws Exception {
// A receipt read alone cannot await work submitted later by the character-save callback.
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
.get(5, java.util.concurrent.TimeUnit.SECONDS);
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
void tick() throws Exception {
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
@@ -1003,7 +1262,7 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts")) {
for (String name : List.of("characters", "receipts", "adventureSettings")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -1011,7 +1270,7 @@ class FantasyStickLifecycleNativeTest {
}
}
}
private static Object field(Object object, String name) {
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); }
@@ -1034,8 +1293,22 @@ class FantasyStickLifecycleNativeTest {
/** Real native inventory/PDC from the coordinator fixture; message/particle/network ports only. */
static final class Actor {
final EquipmentSessionsNativeTest.Actor core;
// Location holds a WeakReference: the fixture must model the server's world ownership.
final org.bukkit.World world = mock(org.bukkit.World.class);
final List<String> messages = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<Integer> particles = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
healthAttributes.getInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
final net.minecraft.world.food.FoodData food = new net.minecraft.world.food.FoodData();
boolean healthScaled;
double healthScale = 20;
Runnable scaleMutation = () -> {};
Runnable foodMutation = () -> {};
java.util.function.Consumer<org.bukkit.event.player.PlayerGameModeChangeEvent> modeEvents = ignored -> { };
final org.bukkit.craftbukkit.entity.CraftPlayer player;
final net.minecraft.server.level.ServerPlayer nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
final net.minecraft.world.entity.ai.attributes.AttributeInstance distance = new net.minecraft.world.entity.ai.attributes.AttributeInstance(
@@ -1062,18 +1335,44 @@ class FantasyStickLifecycleNativeTest {
case "getHeight" -> 1.8;
case "getTrackedBy" -> tracked;
case "canSee" -> true;
case "getGameMode" -> org.bukkit.GameMode.SURVIVAL;
case "getAttribute" -> {
assertEquals(org.bukkit.attribute.Attribute.MAX_HEALTH, args[0]);
yield new org.bukkit.craftbukkit.attribute.CraftAttributeInstance(maximumHealth, org.bukkit.attribute.Attribute.MAX_HEALTH);
}
case "getMaxHealth" -> maximumHealth.getValue();
case "getFoodLevel" -> food.getFoodLevel();
case "setFoodLevel" -> { food.setFoodLevel((Integer) args[0]); foodMutation.run(); yield null; }
case "getSaturation" -> food.getSaturationLevel();
case "setSaturation" -> { food.setSaturation((Float) args[0]); yield null; }
case "getExhaustion" -> food.exhaustionLevel;
case "setExhaustion" -> { food.exhaustionLevel = (Float) args[0]; yield null; }
case "isHealthScaled" -> healthScaled;
case "getHealthScale" -> healthScale;
case "setHealthScale" -> { healthScale = (Double) args[0]; healthScaled = true; scaleMutation.run(); yield null; }
case "setHealthScaled" -> { healthScaled = (Boolean) args[0]; yield null; }
case "getGameMode" -> core.gameMode;
case "setGameMode" -> {
var event = new org.bukkit.event.player.PlayerGameModeChangeEvent((org.bukkit.entity.Player) call.getMock(), (org.bukkit.GameMode) args[0]);
modeEvents.accept(event);
if (!event.isCancelled()) { core.beforeMode.run(); core.gameMode = event.getNewGameMode(); }
yield null;
}
case "isSneaking", "isInvisible" -> false;
case "getScoreboard" -> craftBoard;
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
core.location.setWorld(mock(org.bukkit.World.class));
core.location.setWorld(world);
when(nativePlayer.getId()).thenReturn(42);
when(nativePlayer.getUUID()).thenReturn(core.id);
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
maximumHealth.setBaseValue(core.normalMaximum);
when(nativePlayer.getAttributes()).thenReturn(healthAttributes);
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH)).thenReturn(maximumHealth);
distance.setBaseValue(64);
when(nativePlayer.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE)).thenReturn(distance);
var level = mock(net.minecraft.server.level.ServerLevel.class);
@@ -0,0 +1,315 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FireballNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAndDirectHitDealOneAttributedScaledDamageWithCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("Fireball"));
assertTrue(PartyBookNativeTest.text(a).contains("INT 12"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(assigned.learned().contains("fireball")); assertEquals("fireball", assigned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertEquals(org.bukkit.Material.SNOWBALL, a.core.inventory.getItem(2).getType());
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var launches = new java.util.concurrent.atomic.AtomicInteger();
var launchedVelocity = new java.util.concurrent.atomic.AtomicReference<org.bukkit.util.Vector>();
org.mockito.Mockito.doAnswer(call -> {
launches.incrementAndGet(); launchedVelocity.set(call.getArgument(1));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand()); assertEquals(1, launches.get());
org.mockito.Mockito.verify(projectile).setVisualFire(true);
assertEquals(new org.bukkit.util.Vector(1, 0, 0), launchedVelocity.get());
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
org.mockito.Mockito.when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getDamage());
assertFalse(damages.getFirst().isCancelled());
assertSame(projectile, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.FIREBALL, damages.getFirst().getDamageSource().getDamageType());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Native projectile attribution reaches the originating character's combat XP");
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size(), "A projectile can deal custom damage only once");
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "The persisted four-second deadline refuses immediate reuse");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball");
assertTrue(deadline.isAfter(java.time.Instant.now()));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var first = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, first, second);
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
assertTrue(partyHit.isCancelled());
org.mockito.Mockito.verify(b.player, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); var mixedHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(mixedHit);
assertTrue(mixedHit.isCancelled());
org.mockito.Mockito.verify(ordinary, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
prepareLaunches(f, a, outside, boundary, expiring, exiting);
var target = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.never()).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
org.mockito.Mockito.verify(outside, org.mockito.Mockito.atLeastOnce()).remove();
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
org.mockito.Mockito.verify(target).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a); Runnable lifetime = f.delayed.getLast(); lifetime.run();
org.mockito.Mockito.verify(expiring, org.mockito.Mockito.atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target));
org.mockito.Mockito.verify(target, org.mockito.Mockito.times(1)).damage(org.mockito.ArgumentMatchers.eq(7.0),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
cast(f, a);
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-base-damage: 9\n"
+ "fireball-cooldown-seconds: 2\nfireball-range-blocks: 40\nfireball-lifetime-seconds: 7\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("fireball").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(new java.math.BigDecimal("7"), definition.parameters().get("lifetime"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
}
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrFreeEffect() {
var storage = org.mockito.Mockito.mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var fireball = new games.dmg.fantasystick.ability.Fireball(games.dmg.fantasystick.ability.Fireball.Settings.defaults());
registry.register(fireball.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("fireball"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).launchProjectile(
org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
engine.close(); fireball.close(); parties.close();
}
@Test void formulaAndPackagedDefinitionMatchTheReviewedFireballContract() throws Exception {
assertEquals(BigInteger.valueOf(6), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(13)));
assertEquals(BigInteger.valueOf(8), games.dmg.fantasystick.ability.Fireball.damage(BigInteger.valueOf(14)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.Fireball.damage(BigInteger.TEN.pow(500)));
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var fireball = registry.find("fireball").orElseThrow();
assertEquals("Fireball", fireball.name());
assertEquals(org.bukkit.Material.SNOWBALL, fireball.icon());
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)),
fireball.requirements());
assertEquals(BigInteger.ONE, fireball.pointCost());
assertEquals(java.time.Duration.ofSeconds(4), fireball.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, fireball.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.PROJECTILE, fireball.activation());
assertEquals(new java.math.BigDecimal("32"), fireball.parameters().get("range"));
assertEquals(new java.math.BigDecimal("5"), fireball.parameters().get("lifetime"));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
String unavailableText = PartyBookNativeTest.text(unavailable);
assertTrue(unavailableText.replaceAll("\\s+", "").contains("Unavailable:requirements"), unavailableText);
assertThrows(AssertionError.class, () -> PartyBookNativeTest.button(unavailable, "[Learn]"));
}
}
private static void withFireball(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
org.mockito.Mockito.doAnswer(call -> {
var projectile = queue.removeFirst();
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void withInt(Path data, FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Snowball.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
org.mockito.Mockito.when(projectile.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return projectile;
}
}
@@ -0,0 +1,72 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Item;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Supported offhand entry -> guarded delivered controls -> actual book -> ordinary exit. */
class InventoryJourneyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void allNineDeliveredControlsStayContainedWhileTheActualBookWorksAndTheSheetCanLeave() 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);
var ordinary = a.core.snapshot();
var sheet = a.core.inventory.getItemInOffHand().clone();
f.activate(a);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var delivered = a.core.snapshot();
for (int slot = 0; slot < 9; slot++) {
var item = a.core.inventory.getItem(slot);
var identity = new SessionControls().identify(item).orElseThrow();
assertEquals(slot, identity.slot());
assertEquals(active.player(), identity.owner());
assertEquals(active.session(), identity.session());
assertEquals(slot == 0 ? Material.BOOK : slot == 1 ? Material.STICK : Material.GRAY_DYE, item.getType());
var entity = mock(Item.class); when(entity.getItemStack()).thenReturn(item);
var drop = new PlayerDropItemEvent(a.player, entity);
f.dispatcher.accept(drop); assertTrue(drop.isCancelled());
var consume = new PlayerItemConsumeEvent(a.player, item, EquipmentSlot.HAND);
f.dispatcher.accept(consume); assertTrue(consume.isCancelled());
var transfer = new InventoryMoveItemEvent(a.core.inventory, item, a.core.view.getTopInventory(), true);
f.dispatcher.accept(transfer); assertTrue(transfer.isCancelled());
a.core.inventory.getInventory().setSelectedSlot(slot);
var use = new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(use); assertEquals(Event.Result.DENY, use.useItemInHand());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
assertEquals(1, a.books.size(), "Only slot 1 opens the actual native book; other delivered controls are inert");
}
assertArrayEquals(delivered, a.core.snapshot(), "Containment and inspection never consume/export inventory");
assertEquals(sheet, a.core.inventory.getItemInOffHand());
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertTrue(sessions.active(a.core.id).isEmpty(), "Transferable sheet removal exits without a combat lock");
ordinary[40] = null;
assertArrayEquals(ordinary, a.core.snapshot(), "Ordinary gear is restored without replacing the removed sheet");
}
}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.session.NativeMaximumHealth;
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
import net.minecraft.world.entity.ai.attributes.Attributes;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeMaximumHealthNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void startupProbeExercisesPinnedInternalsWithoutChangingTheGlobalRangeOrOrdinaryInstances() {
var maximum = games.dmg.fantasystick.session.NativeHealthLimit.read();
var ordinary = new AttributeInstance(Attributes.MAX_HEALTH, ignored -> {});
double before = ordinary.getValue();
NativeMaximumHealth.prepare();
assertEquals(maximum, games.dmg.fantasystick.session.NativeHealthLimit.read());
assertEquals(before, ordinary.getValue()); assertTrue(ordinary.getModifiers().isEmpty());
}
@Test void nativePlayerHealthClampingAndClientAttributeSnapshotsUseTheLeasedMaximum() {
var attributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(Attributes.MAX_HEALTH).build());
var maximum = java.util.Objects.requireNonNull(attributes.getInstance(Attributes.MAX_HEALTH));
var player = org.mockito.Mockito.mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, org.mockito.Mockito.CALLS_REAL_METHODS);
var nativePlayer = org.mockito.Mockito.mock(net.minecraft.server.level.ServerPlayer.class, org.mockito.Mockito.CALLS_REAL_METHODS);
org.mockito.Mockito.doReturn(attributes).when(nativePlayer).getAttributes();
org.mockito.Mockito.doReturn(player).when(nativePlayer).getBukkitEntity();
org.mockito.Mockito.doReturn(nativePlayer).when(player).getHandle();
// Network/entity-data delivery is the boundary; run the actual native health methods.
org.mockito.Mockito.doNothing().when(player).updateScaledHealth();
org.mockito.Mockito.doNothing().when(player).updateScaledHealth(org.mockito.ArgumentMatchers.anyBoolean());
try (var lease = new NativeMaximumHealth(maximum, 10)) {
assertNotNull(lease);
player.setHealthScale(10);
nativePlayer.setHealth(100);
assertEquals(10, nativePlayer.getMaxHealth()); assertEquals(10, nativePlayer.getHealth());
nativePlayer.setHealth(6.25f); assertEquals(6.25, player.getHealth());
var packet = new net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket(42, java.util.List.of(player.getScaledMaxHealth()));
assertEquals(10, packet.getValues().getFirst().base()); assertTrue(packet.getValues().getFirst().modifiers().isEmpty());
assertEquals(20, maximum.getBaseValue(), "Ordinary attribute data is not replaced by the client presentation snapshot");
}
assertEquals(20, nativePlayer.getMaxHealth());
assertEquals(6.25, nativePlayer.getHealth(), "Removing the maximum lease does not itself heal");
}
@Test void failingOriginalNativeDirtyCallbackCannotLeaveAnUnownedMaximumOverride() {
var attribute = new AttributeInstance(Attributes.MAX_HEALTH, ignored -> { throw new IllegalStateException("Native observer failed"); });
assertEquals(20, attribute.getValue());
assertThrows(IllegalStateException.class, () -> new NativeMaximumHealth(attribute, 10));
assertEquals(20, attribute.getValue(), "Failed construction must not leave an unreachable effective-value lease");
assertThrows(IllegalStateException.class, () -> attribute.setBaseValue(24));
assertEquals(24, attribute.getValue(), "Subsequent legitimate native writes must not call a leaked lease");
}
}
@@ -0,0 +1,121 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class NutritionNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void exitEatingAndReentryCannotHealTheSavedCharacterOrLoseTheNewOrdinaryNutrition() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
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);
a.player.setHealth(12); a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a); a.player.setHealth(4);
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(12, a.player.getHealth()); assertEquals(7, a.player.getFoodLevel());
a.player.setHealth(a.maximumHealth.getValue());
a.player.setFoodLevel(20); a.player.setSaturation(12); a.player.setExhaustion(0.5f);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertEquals(4, a.player.getHealth(), "Ordinary eating/healing cannot refill transferable fantasy health");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(20, a.player.getFoodLevel()); assertEquals(12, a.player.getSaturation());
assertEquals(0.5f, a.player.getExhaustion());
var characters = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, characters.find(a.core.record.id()).get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().health().compareTo(java.math.BigDecimal.valueOf(4)));
}
}
@Test void failedFantasyFoodSetterRestoresTheCapturedOrdinaryNutritionAndRetainsRecoveryProof() 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);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
a.foodMutation = () -> {
a.foodMutation = () -> {};
throw new IllegalStateException("Injected food setter failure");
};
f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
assertTrue(sessions.active(a.core.id).isEmpty());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)
.orElseThrow().pending());
}
}
@Test void fantasyDefeatRestoresOrdinaryNutritionInsteadOfLeavingTheFullFantasyState() 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);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
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 java.util.ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
@Test void activeFantasyModeKeepsFoodFullBlocksFoodHealingAndRestoresOrdinaryNutrition() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
ordinary.core.inventory.setItemInOffHand(null);
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
a.player.setFoodLevel(7); a.player.setSaturation(2); a.player.setExhaustion(3);
ordinary.player.setFoodLevel(8); ordinary.player.setExhaustion(4);
f.activate(a);
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var drain = new org.bukkit.event.entity.FoodLevelChangeEvent(a.player, 19);
var exhaustion = new org.bukkit.event.entity.EntityExhaustionEvent(a.player,
org.bukkit.event.entity.EntityExhaustionEvent.ExhaustionReason.SPRINT, 0.1f);
var foodHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
var potionHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.MAGIC);
var abilityHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(a.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.CUSTOM);
f.dispatcher.accept(drain); f.dispatcher.accept(exhaustion);
f.dispatcher.accept(foodHealing); f.dispatcher.accept(potionHealing); f.dispatcher.accept(abilityHealing);
assertTrue(drain.isCancelled()); assertTrue(exhaustion.isCancelled()); assertTrue(foodHealing.isCancelled());
assertFalse(potionHealing.isCancelled(), "Explicit native magic healing remains available");
assertFalse(abilityHealing.isCancelled(), "Explicit ability healing remains available");
assertEquals(20, a.player.getFoodLevel()); assertEquals(0, a.player.getExhaustion());
var ordinaryDrain = new org.bukkit.event.entity.FoodLevelChangeEvent(ordinary.player, 7);
var ordinaryHealing = new org.bukkit.event.entity.EntityRegainHealthEvent(ordinary.player, 1,
org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED);
f.dispatcher.accept(ordinaryDrain); f.dispatcher.accept(ordinaryHealing);
assertFalse(ordinaryDrain.isCancelled()); assertFalse(ordinaryHealing.isCancelled());
assertEquals(8, ordinary.player.getFoodLevel()); assertEquals(4, ordinary.player.getExhaustion());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(7, a.player.getFoodLevel()); assertEquals(2, a.player.getSaturation());
assertEquals(3, a.player.getExhaustion());
}
}
}
@@ -0,0 +1,294 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.block.BlockFace;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Native book payloads feed real Paper custom-click events through packaged startup registration. */
class PartyBookNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookCreatesPlayerOwnedPartyThroughNativeCustomClickWithoutInventoryChanges() 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 before = a.core.snapshot();
open(f, a);
var create = button(a, "[Create party]");
click(f, a, create);
var party = PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow();
assertEquals(a.core.id, party.leader());
assertEquals(java.util.List.of(a.core.id), party.members());
click(f, a, create); // The old page cannot perform the action twice.
assertEquals(party, PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).orElseThrow());
assertArrayEquals(before, a.core.snapshot());
}
}
@Test void memberBookLeavePreservesPartyButLeaderBookLeaveDisbandsWithoutSuccession() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
open(f, member); click(f, member, button(member, "[Leave party]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
open(f, leader); click(f, leader, button(leader, "[Leave party]"));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
assertTrue(parties.partyOf(member.core.id).isEmpty());
}
}
@Test void nativeBookInvitesRequireAcceptanceDisplayCharacterNamesAndAllowLeaderKick() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
open(f, leader); click(f, leader, button(leader, "[Create party]")); drain(f);
click(f, leader, button(leader, "[Invite players]"));
assertTrue(text(leader).contains(member.core.record.name()));
click(f, leader, button(leader, "[Invite]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(member.messages.stream().anyMatch(message -> message.contains("invitation")));
open(f, member); click(f, member, button(member, "[Invitations]"));
assertTrue(text(member).contains(leader.core.record.name()));
click(f, member, button(member, "[Accept]")); drain(f);
assertEquals(parties.partyOf(leader.core.id), parties.partyOf(member.core.id));
assertTrue(text(member).contains(leader.core.record.name()));
assertTrue(text(member).contains(member.core.record.name()));
open(f, leader); click(f, leader, button(leader, "[Kick]"));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(java.util.List.of(leader.core.id), parties.partyOf(leader.core.id).orElseThrow().members());
drain(f); click(f, leader, button(leader, "[Disband party]"));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
}
}
@Test void copiedReplacedMalformedAndWrongNamespaceActionsCannotCreateParties() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
open(f, a); var old = button(a, "[Create party]");
open(f, b); click(f, b, old);
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(b.core.id).isEmpty());
open(f, a); var current = button(a, "[Create party]");
click(f, a, old);
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(current.id(), java.util.Optional.of(payload)));
}
click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(
net.minecraft.resources.Identifier.fromNamespaceAndPath("other", "party_book"), current.payload()));
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
click(f, a, current);
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isPresent(), "Invalid packets must not consume the legitimate view");
}
}
@Test void changedSlotOrEndedCharacterCannotUseOldPartyControlsEvenAfterReturningToBookSlot() throws Exception {
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("route-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("route-" + route));
f.players.put(a.core.id, a.player); f.activate(a);
var parties = PartyLifecycleNativeTest.parties(f);
open(f, a); var action = button(a, "[Create party]");
if (route == 0) a.core.inventory.getInventory().setSelectedSlot(1);
else if (route == 1) { a.core.inventory.setItemInOffHand(null); f.changed(a); }
else { f.plugin.setEnabled(false); f.awaitWorkers(); }
click(f, a, action);
a.core.inventory.getInventory().setSelectedSlot(0);
click(f, a, action);
assertTrue(parties.partyOf(a.core.id).isEmpty());
}
}
@Test void managementRechecksPartyIdentityRatherThanDisbandingAReplacementFromAnOldPage() 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 parties = PartyLifecycleNativeTest.parties(f);
var previous = parties.create(a.core.id).orElseThrow();
open(f, a); var old = button(a, "[Disband party]");
assertTrue(parties.leave(a.core.id, previous.id()));
var replacement = parties.create(a.core.id).orElseThrow();
click(f, a, old);
assertEquals(replacement, parties.partyOf(a.core.id).orElseThrow());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Party action unavailable")));
}
}
@Test void inviteChoicesArePagedAndMovingPagesRevokesTheOldPagesActions() 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 candidates = new java.util.ArrayList<UUID>();
for (int i = 0; i < 101; i++) {
var id = UUID.randomUUID(); candidates.add(id);
var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
when(player.getName()).thenReturn("Candidate " + i); f.players.put(id, player);
}
var parties = PartyLifecycleNativeTest.parties(f);
parties.create(a.core.id).orElseThrow();
open(f, a); click(f, a, button(a, "[Invite players]"));
var old = button(a, "[Invite]");
assertTrue(((org.bukkit.inventory.meta.BookMeta) a.books.getLast().getItemMeta()).getPageCount() <= 100);
click(f, a, button(a, "[Next]")); click(f, a, old);
assertTrue(candidates.stream().allMatch(id -> parties.invitationsFor(id).isEmpty()));
click(f, a, button(a, "[Invite]"));
assertEquals(1, candidates.stream().filter(id -> !parties.invitationsFor(id).isEmpty()).count());
}
}
@Test void anInvitationAlreadyRenderedInTheBookCannotBeAcceptedAtItsRealTimeDeadline() throws Exception {
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.EPOCH);
var clock = new java.time.Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
try (var clocks = mockStatic(java.time.Clock.class, CALLS_REAL_METHODS)) {
clocks.when(java.time.Clock::systemUTC).thenReturn(clock);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var group = parties.create(leader.core.id).orElseThrow();
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
open(f, member); click(f, member, button(member, "[Invitations]"));
var accept = button(member, "[Accept]");
now.set(java.time.Instant.EPOCH.plusSeconds(60));
click(f, member, accept);
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(parties.invitationsFor(member.core.id).isEmpty());
}
}
}
@Test void anOpenAcceptancePageCannotOverfillAPartyThatFilledAfterRendering() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
f.activate(leader); f.activate(member);
var parties = PartyLifecycleNativeTest.parties(f);
var group = parties.create(leader.core.id).orElseThrow();
parties.invite(leader.core.id, group.id(), member.core.id).orElseThrow();
open(f, member); click(f, member, button(member, "[Invitations]"));
var accept = button(member, "[Accept]");
for (int i = 0; i < 5; i++) {
UUID id = UUID.randomUUID();
var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(id); when(player.isOnline()).thenReturn(true);
f.players.put(id, player);
assertTrue(parties.accept(id, parties.invite(leader.core.id, group.id(), id).orElseThrow().id()));
}
click(f, member, accept);
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertEquals(6, parties.partyOf(leader.core.id).orElseThrow().members().size());
}
}
@Test void sheetTransferAndCharacterSwitchKeepMembershipOnThePlayerAndInvalidateOldBookActions() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
var otherSheet = b.core.inventory.getItemInOffHand().clone();
f.activate(a);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
open(f, a); var oldDisband = button(a, "[Disband party]");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
open(f, b);
assertTrue(text(b).contains(a.core.record.name()));
assertTrue(parties.partyOf(b.core.id).isEmpty(), "A traded sheet must not transfer party membership");
click(f, b, oldDisband);
a.core.inventory.setItemInOffHand(otherSheet); f.activate(a);
open(f, a);
assertTrue(text(a).contains(b.core.record.name()));
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
click(f, a, oldDisband);
assertEquals(party.id(), parties.partyOf(a.core.id).orElseThrow().id());
}
}
static String text(FantasyStickLifecycleNativeTest.Actor a) {
return org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT).pages().stream()
.map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
}
static void open(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
f.dispatcher.accept(new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, a.core.inventory.getItemInMainHand(),
null, BlockFace.SELF, EquipmentSlot.HAND));
drain(f);
}
static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task;
while ((task = f.tasks.poll()) != null) task.run();
}
static net.minecraft.network.chat.ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a, String label) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(book);
for (var page : book.pages()) {
var click = find(page.raw(), label);
if (click != null) return click;
}
throw new AssertionError("Missing native book action " + label);
}
static net.minecraft.network.chat.ClickEvent.Custom find(net.minecraft.network.chat.Component component, String label) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom) return custom;
for (var child : component.getSiblings()) {
var found = find(child, label);
if (found != null) return found;
}
return null;
}
static void click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a,
net.minecraft.network.chat.ClickEvent.Custom click) {
var connection = mock(io.papermc.paper.connection.PlayerGameConnection.class);
when(connection.getPlayer()).thenReturn(a.player);
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key(click.id().toString()), connection, click.payload().orElse(null)));
}
}
@@ -0,0 +1,431 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Actual packaged listeners and book clicks; native damage events have explicit world/source ports. */
class PartyCombatNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static final java.util.Map<org.bukkit.entity.Player, java.util.Map<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>> effects = new java.util.WeakHashMap<>();
private static final java.util.Map<org.bukkit.entity.Player, Integer> fires = new java.util.WeakHashMap<>();
@Test void bookConsentProtectsCombatAndBookLeaveKickDisbandImmediatelyRemoveProtection() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f);
f.activate(a); f.activate(b);
assertFalse(hit(f, a.player, b.player).isCancelled(), "Non-party fantasy combat remains available");
createAndInvite(f, a, b);
assertFalse(hit(f, a.player, b.player).isCancelled(), "An invitation is not membership");
accept(f, b);
assertTrue(hit(f, a.player, b.player).isCancelled(), "Book acceptance must protect native combat");
assertTrue(hit(f, b.player, a.player).isCancelled());
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
assertFalse(hit(f, a.player, b.player).isCancelled());
invite(f, a); accept(f, b);
PartyBookNativeTest.open(f, a); action(f, a, "Kick");
assertFalse(hit(f, a.player, b.player).isCancelled());
invite(f, a); accept(f, b);
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
assertFalse(hit(f, a.player, b.player).isCancelled());
}
}
@Test void mixedModesAreProtectedBothWaysButOrdinaryCombatMobsSelfDamageAndPriorVetoesAreUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f);
assertFalse(hit(f, a.player, b.player).isCancelled());
f.activate(a);
assertTrue(hit(f, a.player, b.player).isCancelled());
assertTrue(hit(f, b.player, a.player).isCancelled());
assertFalse(hit(f, entity(org.bukkit.entity.Zombie.class), a.player).isCancelled());
assertFalse(hit(f, a.player, entity(org.bukkit.entity.Zombie.class)).isCancelled());
assertFalse(hit(f, a.player, a.player).isCancelled());
var environmental = new org.bukkit.event.entity.EntityDamageEvent(a.player, DamageCause.FALL, mock(DamageSource.class), 4);
f.dispatcher.accept(environmental); assertFalse(environmental.isCancelled());
f.activate(b);
var damage = mock(DamageSource.class);
when(damage.getCausingEntity()).thenReturn(a.player);
var veto = new EntityDamageByEntityEvent(a.player, b.player, DamageCause.ENTITY_ATTACK, damage, 4);
veto.setCancelled(true); f.dispatcher.accept(veto);
assertTrue(veto.isCancelled()); assertEquals(4, veto.getDamage());
var group = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
PartyLifecycleNativeTest.parties(f).invite(a.core.id, group.id(), b.core.id).orElseThrow().id()));
exit(f, a); exit(f, b);
assertFalse(hit(f, a.player, b.player).isCancelled(), "Retained membership is not ordinary PvP immunity");
}
}
@Test void projectilesRecheckPartyAtImpactAndCannotBorrowANewSessionAfterExitOrSwitch() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f);
f.activate(a); f.activate(b);
var arrow = projectile(f, a.player);
assertFalse(hit(f, arrow, b.player).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertTrue(hit(f, arrow, b.player).isCancelled(), "Native indirect attribution must use current party");
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
assertFalse(hit(f, arrow, b.player).isCancelled());
var sheet = a.core.inventory.getItemInOffHand().clone();
exit(f, a);
assertTrue(hit(f, arrow, b.player).isCancelled(), "Fantasy projectile loses authority on exit");
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
assertTrue(hit(f, arrow, b.player).isCancelled(), "Same character reentry is still a different session");
assertFalse(hit(f, projectile(f, a.player), b.player).isCancelled());
var stranger = actor(f);
var ordinaryArrow = projectile(f, stranger.player);
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled());
f.activate(stranger);
assertTrue(hit(f, ordinaryArrow, b.player).isCancelled(), "Old ordinary projectile must not become fantasy damage");
}
}
@Test void explosiveFangsCloudPetAndFireworkSourcesRespectPartiesAndCapturedSessions() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
createAndInvite(f, a, b); accept(f, b);
var tnt = entity(org.bukkit.entity.TNTPrimed.class); when(tnt.getSource()).thenReturn(a.player);
var fangs = entity(org.bukkit.entity.EvokerFangs.class); when(fangs.getOwner()).thenReturn(a.player);
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
var pet = entity(org.bukkit.entity.Wolf.class); when(pet.getOwnerUniqueId()).thenReturn(a.core.id);
var firework = entity(org.bukkit.entity.Firework.class); when(firework.getSpawningEntity()).thenReturn(a.core.id);
for (var source : java.util.List.of(tnt, fangs, cloud, firework)) f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(source));
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) {
assertTrue(hit(f, source, b.player).isCancelled(), source.getClass().toString());
}
PartyBookNativeTest.open(f, b); action(f, b, "Leave party");
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertFalse(hit(f, source, b.player).isCancelled());
exit(f, a);
for (var source : java.util.List.of(tnt, fangs, cloud, pet, firework)) assertTrue(hit(f, source, b.player).isCancelled());
}
}
@Test void lingeringCloudInheritsTheOriginalThrownPotionSessionRatherThanItsOwnersReplacement() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
var cloud = entity(org.bukkit.entity.AreaEffectCloud.class); when(cloud.getSource()).thenReturn(a.player);
var splash = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
f.dispatcher.accept(new org.bukkit.event.entity.EntitySpawnEvent(cloud));
assertTrue(hit(f, cloud, b.player).isCancelled());
}
}
@Test void poisonAndWitherRevalidateEachTickAndClearOnRemovalReplacementWithoutTouchingHealing() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
for (var type : java.util.List.of(org.bukkit.potion.PotionEffectType.POISON, org.bukkit.potion.PotionEffectType.WITHER)) {
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
var cause = type.equals(org.bukkit.potion.PotionEffectType.POISON) ? DamageCause.POISON : DamageCause.WITHER;
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
assertFalse(tickDamage(f, b, cause).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertTrue(tickDamage(f, b, cause).isCancelled(), "Tick loses native attacker but not our application provenance");
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 200, 0);
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
assertTrue(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), a.player).isCancelled());
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
f.dispatcher.accept(remove); effects.get(b.player).remove(type);
assertFalse(tickDamage(f, b, cause).isCancelled(), "Removed attribution must not protect unrelated later damage");
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
assertFalse(apply(f, b, null, effect, a.player).isCancelled());
// An unrelated mob/environmental replacement clears only this effect's provenance.
assertFalse(apply(f, b, effect, new org.bukkit.potion.PotionEffect(type, 300, 1), null).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertFalse(tickDamage(f, b, cause).isCancelled());
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
}
}
}
@Test void ongoingDamageCannotMoveToANewSourceOrTargetSessionAndCancelledChangesDoNotEraseProvenance() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
apply(f, b, null, effect, a.player);
var replacement = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, effect, null, null,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
replacement.setCancelled(true); f.dispatcher.accept(replacement);
var sheet = b.core.inventory.getItemInOffHand().clone(); exit(f, b);
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "An old poison cannot attack a replacement target session");
apply(f, b, effect, new org.bukkit.potion.PotionEffect(effect.getType(), 300, 1), a.player);
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
exit(f, a);
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
}
}
@Test void burningChecksPartyEveryTickAndCancelledOrEnvironmentalIgnitionCannotStealAttribution() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var arrow = projectile(f, a.player);
var fire = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 10.0f);
f.dispatcher.accept(fire); assertFalse(fire.isCancelled()); fires.put(b.player, 200);
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
createAndInvite(f, a, b); accept(f, b);
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
var refused = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, b.player, 15.0f);
f.dispatcher.accept(refused); assertTrue(refused.isCancelled());
var cancelledEnvironmental = new org.bukkit.event.entity.EntityCombustEvent(b.player, 5.0f);
cancelledEnvironmental.setCancelled(true); f.dispatcher.accept(cancelledEnvironmental);
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustEvent(b.player, 15.0f));
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
}
}
@Test void expiredAndHiddenPotionLayersKeepOnlyTheirOwnSourceAndFireExpiryIsCleaned() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var type = org.bukkit.potion.PotionEffectType.POISON;
var poison = new org.bukkit.potion.PotionEffect(type, 200, 0);
apply(f, b, null, poison, a.player);
// A shorter, stronger environmental layer temporarily covers the attributable one.
apply(f, b, poison, new org.bukkit.potion.PotionEffect(type, 40, 1), null);
createAndInvite(f, a, b); accept(f, b);
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Environmental top layer must not inherit old source");
var nativeEffect = effects.get(b.player).get(type);
assertNotNull(nativeEffect.hiddenEffect);
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled(), "Native hidden-layer restoration retains its own source");
effects.get(b.player).clear();
for (var pulse : f.pulses) pulse.run();
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
PartyBookNativeTest.open(f, a); action(f, a, "Disband party");
f.dispatcher.accept(new org.bukkit.event.entity.EntityCombustByEntityEvent(a.player, b.player, 2.0f));
fires.put(b.player, 40);
createAndInvite(f, a, b); accept(f, b);
assertTrue(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
fires.put(b.player, 0);
for (var pulse : f.pulses) pulse.run();
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
fires.put(b.player, 20); // Later unrelated fire must not borrow a stale player source.
assertFalse(tickDamage(f, b, DamageCause.FIRE_TICK).isCancelled());
}
}
@Test void staleNativeSplashIsCancelledBeforeOwnerOnlyEffectAttributionAndValidHealingIsUntouched() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var potion = entity(org.bukkit.entity.ThrownPotion.class); when(potion.getShooter()).thenReturn(a.player);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
f.dispatcher.accept(splash); assertFalse(splash.isCancelled());
assertEquals(1.0, splash.getIntensity(b.player));
var sheet = a.core.inventory.getItemInOffHand().clone(); exit(f, a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
var stale = new org.bukkit.event.entity.PotionSplashEvent(potion, new java.util.HashMap<>(java.util.Map.of(b.player, 1.0)));
f.dispatcher.accept(stale);
assertTrue(stale.isCancelled(), "Native splash passes only owner to addEffect: stop stale continuation before attribution is lost");
var arrow = projectile(f, a.player);
createAndInvite(f, a, b); accept(f, b);
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player);
f.dispatcher.accept(impact); assertTrue(impact.isCancelled());
var heal = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.REGENERATION, 100, 0);
assertFalse(apply(f, b, null, heal, a.player).isCancelled());
}
}
@Test void cancelledApplicationsAndLaunchesRetainUnrelatedDataAndCleanupStopsOnDisable() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var arrow = entity(org.bukkit.entity.Arrow.class); when(arrow.getShooter()).thenReturn(a.player);
var launch = new org.bukkit.event.entity.ProjectileLaunchEvent(arrow); launch.setCancelled(true);
var unrelated = new org.bukkit.NamespacedKey("other", "state");
arrow.getPersistentDataContainer().set(unrelated, org.bukkit.persistence.PersistentDataType.STRING, "retained");
f.dispatcher.accept(launch);
assertEquals(java.util.Set.of(unrelated), arrow.getPersistentDataContainer().getKeys());
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
var veto = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, null, effect, a.player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false);
veto.setCancelled(true); f.dispatcher.accept(veto);
assertTrue(veto.isCancelled());
assertFalse(b.core.pdc.getKeys().stream().anyMatch(key -> key.getKey().startsWith("combat_dot_")));
var before = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id());
createAndInvite(f, a, b); accept(f, b);
assertEquals(before, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(a.core.record.id()), "Party proximity does not grant progress or XP");
f.plugin.setEnabled(false); f.awaitWorkers();
for (var timer : f.timers) verify(timer, atLeastOnce()).cancel();
for (var pulse : f.pulses) pulse.run();
}
}
@Test void aCapturedFantasySourceRequiresCurrentOffhandCustodyAndConnectionNotJustRegistryPresence() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var arrow = projectile(f, a.player);
a.core.online = false;
assertTrue(hit(f, arrow, b.player).isCancelled());
a.core.online = true;
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); // Before the slot-change/recovery callback.
assertTrue(hit(f, arrow, b.player).isCancelled());
a.core.inventory.setItemInOffHand(sheet);
assertFalse(hit(f, arrow, b.player).isCancelled());
}
}
@Test void nativeSerializationAndRestartKeepStaleDamageProvenanceButNeverRestorePartiesOrXp() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var arrow = projectile(f, a.player);
var restoredArrow = entity(org.bukkit.entity.Arrow.class); when(restoredArrow.getShooter()).thenReturn(a.player);
restoredArrow.getPersistentDataContainer().readFromBytes(arrow.getPersistentDataContainer().serializeToBytes(), true);
var poison = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
apply(f, b, null, poison, a.player);
createAndInvite(f, a, b); accept(f, b);
f.plugin.setEnabled(false); f.awaitWorkers();
b.core.pdc.readFromBytes(b.core.pdc.serializeToBytes(), true);
f.plugin.setEnabled(true);
assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(a.core.id).isEmpty());
assertTrue(hit(f, restoredArrow, b.player).isCancelled());
assertTrue(tickDamage(f, b, DamageCause.POISON).isCancelled());
assertFalse(hit(f, a.player, b.player).isCancelled());
assertFalse(tickDamage(f, b, DamageCause.LAVA).isCancelled());
// Malformed plugin-owned provenance cannot confer immunity or erase other namespaces.
var key = b.core.pdc.getKeys().stream().filter(k -> k.getKey().startsWith("combat_dot_")).findFirst().orElseThrow();
var other = new org.bukkit.NamespacedKey("other", "state");
b.core.pdc.set(other, org.bukkit.persistence.PersistentDataType.STRING, "preserved");
b.core.pdc.set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
assertFalse(tickDamage(f, b, DamageCause.POISON).isCancelled());
assertFalse(b.core.pdc.has(key));
assertEquals("preserved", b.core.pdc.get(other, org.bukkit.persistence.PersistentDataType.STRING));
}
}
@Test void weakerHiddenEffectsAndInfiniteDurationsMatchNativeReplacementSemantics() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = actor(f); var b = actor(f); f.activate(a); f.activate(b);
var type = org.bukkit.potion.PotionEffectType.WITHER;
var strong = new org.bukkit.potion.PotionEffect(type, 40, 2);
apply(f, b, null, strong, null);
var weaker = new org.bukkit.potion.PotionEffect(type, -1, 0);
var hidden = new org.bukkit.event.entity.EntityPotionEffectEvent(b.player, strong, weaker, a.player,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, false);
f.dispatcher.accept(hidden); assertFalse(hidden.isCancelled());
var nativeEffect = effects.get(b.player).get(type);
nativeEffect.update(new net.minecraft.world.effect.MobEffectInstance(
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type), -1, 0));
createAndInvite(f, a, b); accept(f, b);
assertFalse(tickDamage(f, b, DamageCause.WITHER).isCancelled());
effects.get(b.player).put(type, nativeEffect.hiddenEffect);
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
// An infinite active effect is not replaced by a shorter same-strength application.
assertFalse(apply(f, b, weaker, new org.bukkit.potion.PotionEffect(type, 20, 0), null).isCancelled());
assertTrue(tickDamage(f, b, DamageCause.WITHER).isCancelled());
}
}
static org.bukkit.event.entity.EntityPotionEffectEvent apply(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor target, org.bukkit.potion.PotionEffect old,
org.bukkit.potion.PotionEffect effect, Entity source) {
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, old, effect, source,
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
old == null ? org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED : org.bukkit.event.entity.EntityPotionEffectEvent.Action.CHANGED, true);
f.dispatcher.accept(event);
if (!event.isCancelled()) {
var nativeEffect = new net.minecraft.world.effect.MobEffectInstance(
org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(effect.getType()), effect.getDuration(), effect.getAmplifier());
var current = effects.get(target.player).get(effect.getType());
if (current == null) effects.get(target.player).put(effect.getType(), nativeEffect);
else if (event.isOverride() || current.getAmplifier() > nativeEffect.getAmplifier()
&& current.isShorterDurationThan(nativeEffect)) current.update(nativeEffect);
}
return event;
}
static org.bukkit.event.entity.EntityDamageEvent tickDamage(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor target, DamageCause cause) {
var event = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause, mock(DamageSource.class), 1);
f.dispatcher.accept(event); return event;
}
static org.bukkit.entity.Arrow projectile(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Player owner) {
var arrow = entity(org.bukkit.entity.Arrow.class);
when(arrow.getShooter()).thenReturn(owner);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(arrow));
return arrow;
}
static <T extends Entity> T entity(Class<T> type) {
var entity = mock(type);
when(entity.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return entity;
}
static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); // Health acknowledgment precedes clearing the equipment receipt.
f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
var active = new java.util.HashMap<org.bukkit.potion.PotionEffectType, net.minecraft.world.effect.MobEffectInstance>();
effects.put(a.player, active); fires.put(a.player, 0);
doAnswer(call -> {
var effect = active.get(call.getArgument(0));
return effect == null ? null : new org.bukkit.potion.PotionEffect(call.getArgument(0), effect.getDuration(), effect.getAmplifier());
}).when(a.player).getPotionEffect(any());
doAnswer(call -> fires.get(a.player)).when(a.player).getFireTicks();
return a;
}
static void createAndInvite(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor a, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
PartyBookNativeTest.open(f, a); action(f, a, "Create party"); invite(f, a);
}
static void invite(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) throws Exception {
PartyBookNativeTest.open(f, a); action(f, a, "Invite players"); action(f, a, "Invite");
}
static void accept(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor b) throws Exception {
PartyBookNativeTest.open(f, b); action(f, b, "Invitations"); action(f, b, "Accept");
}
static void action(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a, String label) throws Exception {
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[" + label + "]"));
PartyBookNativeTest.drain(f);
}
static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, Entity source, Entity target) {
var damage = mock(DamageSource.class);
when(damage.getCausingEntity()).thenReturn(source);
when(damage.getDirectEntity()).thenReturn(source);
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
f.dispatcher.accept(event);
return event;
}
}
@@ -0,0 +1,95 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.party.PartyRoster;
import java.nio.file.Path;
import net.kyori.adventure.text.Component;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Packaged plugin startup and registered native lifecycle events; not proof of book/effect controls. */
class PartyLifecycleNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredQuitHandlesLastMemberEvenWhileNativeOnlineFlagIsStillTrue() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
leader.core.online = false;
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertTrue(parties.partyOf(member.core.id).isEmpty());
assertTrue(parties.partyOf(leader.core.id).isEmpty());
}
}
@Test void disablingAndReenablingDisbandsAndCannotReuseOldInvitationsOrServices() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var old = parties(f);
var party = old.create(leader.core.id).orElseThrow();
var invitation = old.invite(leader.core.id, party.id(), member.core.id).orElseThrow();
f.plugin.setEnabled(false); f.awaitWorkers();
assertTrue(old.partyOf(leader.core.id).isEmpty());
assertFalse(old.accept(member.core.id, invitation.id()));
assertTrue(old.create(leader.core.id).isEmpty());
f.plugin.setEnabled(true);
var fresh = parties(f);
assertNotSame(old, fresh);
assertTrue(fresh.partyOf(leader.core.id).isEmpty());
assertFalse(fresh.accept(member.core.id, invitation.id()));
assertNotEquals(party.id(), fresh.create(leader.core.id).orElseThrow().id());
}
}
@Test void consecutiveQuitEventsAreAuthoritativeEvenIfBothOnlineFlagsLag() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
assertEquals(leader.core.id, parties.partyOf(member.core.id).orElseThrow().leader());
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertTrue(parties.partyOf(leader.core.id).isEmpty());
assertTrue(parties.partyOf(member.core.id).isEmpty());
}
}
@Test void rejoiningBeforeTheOtherMemberQuitsRetainsTheSamePartyAndLeader() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var leader = new FantasyStickLifecycleNativeTest.Actor(data);
var member = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(leader.core.id, leader.player); f.players.put(member.core.id, member.player);
var parties = parties(f);
var party = parties.create(leader.core.id).orElseThrow();
assertTrue(parties.accept(member.core.id, parties.invite(leader.core.id, party.id(), member.core.id).orElseThrow().id()));
f.dispatcher.accept(new PlayerQuitEvent(leader.player, Component.empty()));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(leader.player, Component.empty()));
f.dispatcher.accept(new PlayerQuitEvent(member.player, Component.empty()));
assertEquals(party.id(), parties.partyOf(leader.core.id).orElseThrow().id());
assertEquals(leader.core.id, parties.partyOf(leader.core.id).orElseThrow().leader());
}
}
static PartyRoster parties(FantasyStickLifecycleNativeTest.Fixture f) {
return (PartyRoster) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "parties");
}
}
@@ -54,7 +54,86 @@ class TailBooksNativeTest {
assertTrue(meta.getTitle().length() <= 32);
assertFalse(Character.isHighSurrogate(meta.getTitle().charAt(meta.getTitle().length() - 1)));
assertEquals(Component.text("Tail of " + name), meta.displayName());
assertTrue(meta.pages().contains(Component.text("Tail of " + name)));
String text = meta.pages().stream().map(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()::serialize)
.collect(java.util.stream.Collectors.joining()).replace("\n", "");
assertTrue(text.contains("Tail of " + name), "Readable wrapping must retain every character of the full name");
}
@Test void giantStatsRemainReadableInOneSignedKeepsakeThroughTheActualNativeCodec() {
var old = fallen();
var stats = new EnumMap<Stat, BigInteger>(Stat.class); stats.putAll(old.stats());
String digits = "1234567890".repeat(4000);
stats.put(Stat.STR, new BigInteger(digits));
var saved = new CharacterRecord(old.id(), "雪🐈".repeat(80), old.revision(), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), true, old.deathId(), old.diedAt());
var book = new TailBooks().create(saved);
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
var content = decoded.get(DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);
assertTrue(content.pages().size() > 100, "Signed books are not subject to the writable-book 100-page cap");
var text = new StringBuilder();
for (var page : content.pages()) {
String value = page.raw().getString();
assertTrue(value.split("\\n", -1).length <= 13);
for (String line : value.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108);
text.append(value.replace("\n", ""));
}
assertTrue(text.toString().contains(saved.name())); assertTrue(text.toString().contains(digits));
assertEquals(1, book.getAmount());
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(CraftItemStack.asBukkitCopy(decoded)).orElseThrow());
}
@Test void ordinaryStatsShareOneSummaryAndEachSavedAbilityHasItsOwnReadableSection() {
var old = fallen();
var expiry = old.diedAt().plusSeconds(3600);
var saved = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of("second_wind", "unknown:雪"),
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""),
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), true, old.deathId(), old.diedAt());
var book = new TailBooks().create(saved);
var pages = nativePages(book);
String summary = pages.getFirst().replace("\n", "");
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30",
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
String unknown = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: unknown:雪")).findFirst().orElseThrow();
assertTrue(unknown.contains("Slots: 2")); assertTrue(unknown.contains("Cooldown: none"));
String all = String.join("", pages).replace("\n", "");
assertTrue(all.contains("Saved cooldown")); assertTrue(all.contains("retired_ability"));
assertTrue(all.contains(expiry.plusSeconds(60).toString())); assertTrue(all.contains(old.diedAt().toString()));
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(book).orElseThrow());
}
@Test void emptyAbilitiesAreExplicitAndLongUnknownAbilityPagesRetainEveryCodePoint() {
var old = fallen();
var empty = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var legacyBook = new TailBooks().create(empty);
var original = legacyBook.clone();
assertTrue(nativePages(legacyBook).stream().anyMatch(p -> p.replace("\n", "").contains("Abilities: None")));
String id = "unknown:雪🐈".repeat(4000);
var longAbility = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(id), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var pages = nativePages(new TailBooks().create(longAbility));
assertTrue(pages.size() > 100);
assertTrue(String.join("", pages).replace("\n", "").contains("Ability: " + id));
assertTrue(String.join("", pages).replace("\n", "").contains("Slots: none"));
for (String page : pages) {
assertTrue(page.split("\n", -1).length <= 13);
for (String line : page.split("\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108);
}
assertEquals(original, legacyBook, "Rendering another record must not rewrite any existing signed keepsake");
}
private static List<String> nativePages(ItemStack book) {
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
return java.util.Objects.requireNonNull(decoded.get(DataComponents.WRITTEN_BOOK_CONTENT)).pages().stream()
.map(p -> p.raw().getString()).toList();
}
@Test
@@ -95,7 +174,7 @@ class TailBooksNativeTest {
assertTrue(pages.contains("Level: 2"));
assertTrue(pages.contains("XP: 100"));
assertTrue(pages.contains("CON: 10"));
assertTrue(pages.contains("second_wind"));
assertTrue(pages.replace("\n", "").contains("second_wind"), "Labeled ability IDs remain complete across readable line wrapping");
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), books.identify(CraftItemStack.asBukkitCopy(restored)).orElseThrow());
}
}
@@ -0,0 +1,20 @@
package games.dmg.fantasystick.combat;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import org.junit.jupiter.api.Test;
class BasicAttackTest {
@Test void followsTheApprovedStrengthFormulaWithoutAStatCap() {
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.ZERO));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.TEN));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(11)));
assertEquals(BigInteger.ONE, BasicAttack.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TWO, BasicAttack.damage(BigInteger.valueOf(14)));
var huge = BigInteger.TEN.pow(1000);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO), BasicAttack.damage(huge));
assertThrows(IllegalArgumentException.class, () -> BasicAttack.damage(BigInteger.valueOf(-1)));
assertThrows(NullPointerException.class, () -> BasicAttack.damage(null));
}
}
@@ -0,0 +1,180 @@
package games.dmg.fantasystick.party;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class PartyRosterTest {
final MutableClock clock = new MutableClock();
final Set<UUID> online = new HashSet<>();
final PartyRoster parties = new PartyRoster(clock, online::contains);
@Test void creatorLeadsExactlyOnePlayerOwnedPartyAndSnapshotsCannotMutateMembership() {
UUID leader = player();
var party = parties.create(leader).orElseThrow();
assertEquals(leader, party.leader());
assertEquals(List.of(leader), party.members());
assertEquals(party, parties.partyOf(leader).orElseThrow());
assertTrue(parties.create(leader).isEmpty());
assertThrows(UnsupportedOperationException.class, () -> party.members().clear());
assertTrue(parties.create(UUID.randomUUID()).isEmpty());
}
@Test void invitationsRequireLeaderAuthorityAndRecipientConsentAndCannotBeReplayed() {
UUID leader = player(), member = player(), stranger = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.invite(stranger, party.id(), member).isEmpty());
assertTrue(parties.invite(leader, UUID.randomUUID(), member).isEmpty());
var invitation = parties.invite(leader, party.id(), member).orElseThrow();
assertEquals(invitation, parties.invite(leader, party.id(), member).orElseThrow());
assertTrue(parties.partyOf(member).isEmpty(), "Inviting must not force membership");
assertFalse(parties.accept(stranger, invitation.id()));
assertTrue(parties.accept(member, invitation.id()));
assertFalse(parties.accept(member, invitation.id()));
assertEquals(List.of(leader, member), parties.partyOf(leader).orElseThrow().members());
assertEquals(parties.partyOf(leader), parties.partyOf(member));
assertEquals(List.of(leader), party.members(), "Old snapshots stay immutable");
assertTrue(parties.invite(member, party.id(), stranger).isEmpty());
}
@Test void leaderDisconnectRetainsLeadershipUntilTheLastOnlineMemberDisconnects() {
UUID leader = player(), member = player(), invited = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
var outstanding = parties.invite(leader, party.id(), invited).orElseThrow();
parties.disconnect(leader); // Bukkit may still report the departing player as online inside QuitEvent.
online.remove(leader);
assertEquals(leader, parties.partyOf(member).orElseThrow().leader());
assertEquals(List.of(leader, member), parties.partyOf(member).orElseThrow().members());
parties.disconnect(member); // The invited non-member does not keep the party alive.
assertTrue(parties.partyOf(leader).isEmpty());
assertTrue(parties.partyOf(member).isEmpty());
assertFalse(parties.accept(invited, outstanding.id()));
online.add(leader);
assertNotEquals(party.id(), parties.create(leader).orElseThrow().id());
}
@Test void explicitLeaderLeaveDisbandsWithoutSuccessionAndStaleActionsCannotTouchANewParty() {
UUID leader = player(), member = player(), invited = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
var offer = parties.invite(leader, party.id(), invited).orElseThrow();
assertFalse(parties.leave(member, UUID.randomUUID()));
assertTrue(parties.leave(leader, party.id()));
assertTrue(parties.partyOf(member).isEmpty());
assertTrue(parties.partyOf(leader).isEmpty());
var replacement = parties.create(leader).orElseThrow();
assertFalse(parties.leave(leader, party.id()));
assertFalse(parties.accept(invited, offer.id()));
assertEquals(replacement, parties.partyOf(leader).orElseThrow());
}
@Test void invitationsExpireAtExactlySixtyRealSecondsAndNewInvitesDoNotReviveOldTokens() {
UUID leader = player(), before = player(), at = player();
var party = parties.create(leader).orElseThrow();
var beforeOffer = parties.invite(leader, party.id(), before).orElseThrow();
var atOffer = parties.invite(leader, party.id(), at).orElseThrow();
clock.now = Instant.EPOCH.plusSeconds(60).minusNanos(1);
assertTrue(parties.accept(before, beforeOffer.id()));
clock.now = Instant.EPOCH.plusSeconds(60);
assertFalse(parties.accept(at, atOffer.id()));
var fresh = parties.invite(leader, party.id(), at).orElseThrow();
assertNotEquals(atOffer.id(), fresh.id());
assertFalse(parties.accept(at, atOffer.id()));
assertTrue(parties.accept(at, fresh.id()));
}
@Test void invitationsDoNotReserveSlotsAndAcceptanceRechecksTheSixPlayerCapacity() {
UUID leader = player();
var party = parties.create(leader).orElseThrow();
var offers = new java.util.ArrayList<PartyRoster.Invitation>();
for (int i = 0; i < 7; i++) offers.add(parties.invite(leader, party.id(), player()).orElseThrow());
assertEquals(1, parties.partyOf(leader).orElseThrow().members().size());
for (int i = 0; i < 5; i++) assertTrue(parties.accept(offers.get(i).invitee(), offers.get(i).id()));
assertFalse(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
assertFalse(parties.accept(offers.get(6).invitee(), offers.get(6).id()));
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
assertTrue(parties.invite(leader, party.id(), player()).isEmpty());
assertTrue(parties.leave(offers.getFirst().invitee(), party.id()));
assertTrue(parties.accept(offers.get(5).invitee(), offers.get(5).id()));
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
}
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
UUID leader = player(), member = player(), stranger = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
assertFalse(parties.kick(member, party.id(), leader));
assertFalse(parties.disband(member, party.id()));
assertFalse(parties.disband(stranger, party.id()));
assertFalse(parties.kick(leader, UUID.randomUUID(), member));
assertFalse(parties.kick(leader, party.id(), stranger));
online.remove(leader);
assertFalse(parties.kick(leader, party.id(), member));
assertFalse(parties.disband(leader, party.id()));
online.add(leader);
assertTrue(parties.kick(leader, party.id(), member));
assertTrue(parties.partyOf(member).isEmpty());
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
assertTrue(parties.disband(leader, party.id()));
assertTrue(parties.partyOf(leader).isEmpty());
assertFalse(parties.disband(leader, party.id()));
}
@Test void shutdownRevokesAllPartiesInvitationsAndOldServiceAuthorityWithoutRestartPersistence() {
UUID leader = player(), member = player();
var party = parties.create(leader).orElseThrow();
var offer = parties.invite(leader, party.id(), member).orElseThrow();
parties.close(); parties.close();
assertTrue(parties.partyOf(leader).isEmpty());
assertFalse(parties.accept(member, offer.id()));
assertTrue(parties.create(leader).isEmpty());
assertTrue(parties.invite(leader, party.id(), member).isEmpty());
try (var restarted = new PartyRoster(clock, online::contains)) {
assertTrue(restarted.partyOf(leader).isEmpty());
assertFalse(restarted.accept(member, offer.id()));
assertNotEquals(party.id(), restarted.create(leader).orElseThrow().id());
}
}
@Test void quitImmediatelyRevokesManagementDespiteALaggingOnlineFlagAndJoinRestoresIt() {
UUID leader = player(), member = player(), target = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
parties.disconnect(leader);
assertTrue(parties.invite(leader, party.id(), target).isEmpty());
assertFalse(parties.kick(leader, party.id(), member));
assertFalse(parties.disband(leader, party.id()));
assertFalse(parties.leave(leader, party.id()));
parties.arrive(leader);
assertTrue(parties.invite(leader, party.id(), target).isPresent());
assertTrue(parties.leave(member, party.id()));
assertEquals(List.of(leader), parties.partyOf(leader).orElseThrow().members());
}
@Test void lastOnlineMemberLeavingAlsoDisbandsRatherThanStrandingOfflineMembers() {
UUID leader = player(), member = player();
var party = parties.create(leader).orElseThrow();
assertTrue(parties.accept(member, parties.invite(leader, party.id(), member).orElseThrow().id()));
parties.disconnect(leader);
assertTrue(parties.leave(member, party.id()));
assertTrue(parties.partyOf(leader).isEmpty());
assertTrue(parties.partyOf(member).isEmpty());
}
UUID player() { UUID id = UUID.randomUUID(); online.add(id); return id; }
static final class MutableClock extends Clock {
Instant now = Instant.EPOCH;
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
}
@@ -0,0 +1,99 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class AdventureSettingsTest {
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
@Test void acknowledgesDurableUpdateWithoutDiscardingOtherConfiguration() throws Exception {
var file = directory.resolve("config.yml");
java.nio.file.Files.writeString(file, "starting-points: 73\nother:\n enabled: true\n");
try (var settings = new AdventureSettings(directory, true)) {
assertFalse(settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertFalse(settings.enabled());
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(java.nio.file.Files.readString(file));
assertEquals(false, saved.get("adventure-mode"));
assertEquals(73, saved.get("starting-points"));
assertEquals(java.util.Map.of("enabled", true), saved.get("other"));
assertFalse(AdventureSettings.validate(saved.get("adventure-mode")));
}
}
@Test void acknowledgmentsAreWorkerOwnedOrderedAndSurviveObserverCancellationAndClose() throws Exception {
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var caller = Thread.currentThread();
var settings = new AdventureSettings(directory, true, (from, to) -> {
assertNotSame(caller, Thread.currentThread());
entered.countDown();
try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException interrupted) { throw new java.io.IOException(interrupted); }
java.nio.file.Files.move(from, to, java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
});
try {
var first = settings.set(false);
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(settings.enabled());
first.toCompletableFuture().cancel(false);
var second = settings.set(true);
settings.close();
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(false).toCompletableFuture().get());
release.countDown();
assertFalse(first.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(second.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(settings.enabled());
var saved = new org.yaml.snakeyaml.Yaml().<java.util.Map<String, Object>>load(
java.nio.file.Files.readString(directory.resolve("config.yml")));
assertEquals(true, saved.get("adventure-mode"));
} finally { release.countDown(); settings.close(); }
}
@Test void failedSavePreservesEffectiveValueAndUnrelatedOriginalBytes() throws Exception {
var file = directory.resolve("config.yml");
String original = "other: 17\nadventure-mode: true\n";
java.nio.file.Files.writeString(file, original);
try (var settings = new AdventureSettings(directory, true, (from, to) -> { throw new java.io.IOException("no write"); })) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(settings.enabled());
assertEquals(original, java.nio.file.Files.readString(file));
}
try (var files = java.nio.file.Files.list(directory)) {
assertFalse(files.anyMatch(path -> path.toString().endsWith(".tmp")));
}
}
@Test void corruptDuplicateOversizedOrSymlinkedConfigurationIsRetained() throws Exception {
var file = directory.resolve("config.yml");
for (String invalid : java.util.List.of("a: [broken", "other: 1\nother: 2\n", "[1, 2]", "#".repeat(1_048_577))) {
java.nio.file.Files.writeString(file, invalid);
try (var settings = new AdventureSettings(directory, true)) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(settings.enabled()); assertEquals(invalid, java.nio.file.Files.readString(file));
}
}
java.nio.file.Files.delete(file);
var outside = directory.resolve("outside.yml");
java.nio.file.Files.writeString(outside, "keep: me\n");
java.nio.file.Files.createSymbolicLink(file, outside);
try (var settings = new AdventureSettings(directory, true)) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> settings.set(false).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS));
assertTrue(settings.enabled()); assertEquals("keep: me\n", java.nio.file.Files.readString(outside));
assertTrue(java.nio.file.Files.isSymbolicLink(file));
}
}
@Test void defaultsToTrueAndAcceptsOnlyActualBooleanValues() {
assertTrue(AdventureSettings.validate(null));
assertTrue(AdventureSettings.validate(Boolean.TRUE));
assertFalse(AdventureSettings.validate(Boolean.FALSE));
for (Object invalid : new Object[]{"true", "false", "yes", 0, 1, java.util.List.of(), java.util.Map.of()}) {
assertThrows(IllegalArgumentException.class, () -> AdventureSettings.validate(invalid));
}
}
}
@@ -49,6 +49,203 @@ class CharacterStorageTest {
}
}
@Test void awardsConfiguredExperiencePersistentlyWithMultiLevelAndCapIdempotence() throws Exception {
var repository = new YamlCharacterRepository(data);
var rules = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(2), BigInteger.valueOf(7), 3);
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(rules);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var awarded = storage.awardExperience(live.id(), live.revision(), BigInteger.valueOf(8), rules).join();
assertEquals(CharacterStorage.ExperienceAwardStatus.APPLIED, awarded.status());
assertEquals(1, awarded.levels());
var levelTwo = awarded.record().orElseThrow();
assertEquals(2, levelTwo.level());
assertEquals(BigInteger.valueOf(8), levelTwo.experience());
assertEquals(BigInteger.valueOf(3), levelTwo.unspentPoints());
var capped = storage.awardExperience(live.id(), levelTwo.revision(), BigInteger.valueOf(100), rules).join();
assertEquals(1, capped.levels());
assertEquals(3, capped.record().orElseThrow().level());
assertEquals(BigInteger.valueOf(14), capped.record().orElseThrow().experience());
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, rules).join().status());
assertEquals(CharacterStorage.ExperienceAwardStatus.STALE,
storage.awardExperience(live.id(), live.revision(), BigInteger.ONE, rules).join().status());
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(7), 2);
assertEquals(CharacterStorage.ExperienceAwardStatus.AT_CAP,
storage.awardExperience(live.id(), capped.record().orElseThrow().revision(), BigInteger.ONE, lowered).join().status());
assertEquals(capped.record().orElseThrow(), repository.load(live.id()).orElseThrow(),
"Lowering configuration must not rewrite existing transferable progress");
}
}
@Test
void combatAwardsAtomicallyPersistXpPointsAndPerWorldRestrictedKillHistory() throws Exception {
var repository = new YamlCharacterRepository(data);
var progression = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.TEN, 30);
var rewards = games.dmg.fantasystick.progression.CombatRewardRules.defaults();
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
var first = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var near = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.valueOf(2), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var farFromUpdated = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
java.math.BigDecimal.valueOf(5), java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var applied = storage.awardCombatExperience(live.id(), live.revision(), BigInteger.TEN,
progression, rewards, first).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, applied.status());
assertEquals(1, applied.levels());
assertEquals(first, applied.record().orElseThrow().restrictedKills().get(worldA));
var blocked = storage.awardCombatExperience(live.id(), applied.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, near).join();
assertEquals(CharacterStorage.CombatAwardStatus.SPACING_BLOCKED, blocked.status());
assertEquals(applied.record().orElseThrow().experience(), blocked.record().orElseThrow().experience());
assertEquals(near, blocked.record().orElseThrow().restrictedKills().get(worldA), "Blocked kills still update history");
var second = storage.awardCombatExperience(live.id(), blocked.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, farFromUpdated).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, second.status());
assertEquals(BigInteger.valueOf(20), second.record().orElseThrow().experience());
var otherWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var firstThere = storage.awardCombatExperience(live.id(), second.record().orElseThrow().revision(), BigInteger.TEN,
progression, rewards, otherWorld).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, firstThere.status());
assertEquals(2, firstThere.record().orElseThrow().restrictedKills().size());
var passive = storage.awardCombatExperience(live.id(), firstThere.record().orElseThrow().revision(), BigInteger.ONE,
progression, rewards, null).join();
assertEquals(CharacterStorage.CombatAwardStatus.APPLIED, passive.status());
assertEquals(firstThere.record().orElseThrow().restrictedKills(), passive.record().orElseThrow().restrictedKills());
var lowered = new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.ZERO, BigInteger.valueOf(1000), 1);
var thirdWorld = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(UUID.randomUUID(),
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
var cappedHistory = storage.awardCombatExperience(live.id(), passive.record().orElseThrow().revision(), BigInteger.TEN,
lowered, rewards, thirdWorld).join();
assertEquals(CharacterStorage.CombatAwardStatus.AT_CAP, cappedHistory.status());
assertEquals(passive.record().orElseThrow().experience(), cappedHistory.record().orElseThrow().experience());
assertEquals(passive.record().orElseThrow().level(), cappedHistory.record().orElseThrow().level());
assertEquals(thirdWorld, cappedHistory.record().orElseThrow().restrictedKills().get(thirdWorld.world()));
assertEquals(cappedHistory.record().orElseThrow(), repository.load(live.id()).orElseThrow());
}
}
@Test
void abilityUnlockAssignmentAndCooldownAreAtomicCharacterOwnedRevisions() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats()); stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var live = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
BigInteger.valueOf(2), base.health(), base.learned(), base.loadout(), base.cooldowns(), base.restrictedKills(), false, null, null);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
var unlocked = storage.unlockAbility(live.id(), live.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, unlocked.status());
var learned = unlocked.record().orElseThrow();
assertEquals(java.util.Set.of("test_bolt"), learned.learned());
assertEquals(BigInteger.ONE, learned.unspentPoints());
assertEquals(CharacterStorage.AbilityMutationStatus.ALREADY_LEARNED,
storage.unlockAbility(live.id(), learned.revision(), "test_bolt", java.util.Map.of(), BigInteger.ONE).join().status());
assertEquals(CharacterStorage.AbilityMutationStatus.STALE,
storage.unlockAbility(live.id(), live.revision(), "other", java.util.Map.of(), BigInteger.ONE).join().status());
var assigned = storage.assignAbility(live.id(), learned.revision(), "test_bolt", 4).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, assigned.status());
assertEquals("test_bolt", assigned.record().orElseThrow().loadout().get(4));
var moved = storage.assignAbility(live.id(), assigned.record().orElseThrow().revision(), "test_bolt", 1).join();
assertEquals("", moved.record().orElseThrow().loadout().get(4));
assertEquals("test_bolt", moved.record().orElseThrow().loadout().get(1));
var ready = java.time.Instant.parse("2026-09-20T20:00:00Z");
var cooldown = storage.commitAbilityCooldown(live.id(), moved.record().orElseThrow().revision(), "test_bolt", ready).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, cooldown.status());
assertEquals(ready, cooldown.record().orElseThrow().cooldowns().get("test_bolt"));
assertEquals(cooldown.record().orElseThrow(), new YamlCharacterRepository(data).load(live.id()).orElseThrow());
}
}
@Test
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
var repository = new YamlCharacterRepository(data);
var live = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
storage.create(live).join();
assertEquals(CharacterStorage.AbilityMutationStatus.REQUIREMENTS,
storage.unlockAbility(live.id(), live.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
assertEquals(live, repository.load(live.id()).orElseThrow());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.assignAbility(live.id(), live.revision(), "test_bolt", 0).join().status());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.commitAbilityCooldown(live.id(), live.revision(), "test_bolt", java.time.Instant.now()).join().status());
}
var noPointsStats = new java.util.EnumMap<>(live.stats());
noPointsStats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
var noPoints = new CharacterRecord(UUID.randomUUID(), live.name(), BigInteger.ZERO, noPointsStats, live.level(),
live.experience(), BigInteger.ZERO, live.health(), live.learned(), live.loadout(), live.cooldowns(),
live.restrictedKills(), false, null, null);
repository.create(noPoints);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
assertEquals(CharacterStorage.AbilityMutationStatus.NO_POINTS,
storage.unlockAbility(noPoints.id(), noPoints.revision(), "test_bolt",
java.util.Map.of(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE).join().status());
assertEquals(noPoints, repository.load(noPoints.id()).orElseThrow());
}
}
@Test void abilityCooldownRefusesBeforeAndAllowsAtTheExactPersistedDeadline() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
repository.create(learned);
var now = new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2026-09-19T20:00:00Z"));
var clock = new Clock() {
@Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public java.time.Instant instant() { return now.get(); }
};
try (var storage = new CharacterStorage(repository, clock)) {
var first = storage.commitAbilityCooldown(learned.id(), learned.revision(), "fireball", now.get().plusSeconds(4)).join();
now.set(now.get().plusMillis(3999));
assertEquals(CharacterStorage.AbilityMutationStatus.COOLDOWN,
storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball", now.get().plusSeconds(4)).join().status());
now.set(java.time.Instant.parse("2026-09-19T20:00:04Z"));
var exact = storage.commitAbilityCooldown(learned.id(), first.record().orElseThrow().revision(), "fireball",
now.get().plusSeconds(4)).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, exact.status());
assertEquals(java.time.Instant.parse("2026-09-19T20:00:08Z"), exact.record().orElseThrow().cooldowns().get("fireball"));
}
}
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(base);
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated unlock failure"); });
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
assertThrows(CompletionException.class, () -> storage.unlockAbility(base.id(), base.revision(), "test_focus",
java.util.Map.of(), BigInteger.ONE).join());
}
assertEquals(base, repository.load(base.id()).orElseThrow());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var retry = storage.unlockAbility(base.id(), base.revision(), "test_focus", java.util.Map.of(), BigInteger.ONE).join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, retry.status());
assertTrue(retry.record().orElseThrow().learned().contains("test_focus"));
assertEquals(base.unspentPoints().subtract(BigInteger.ONE), retry.record().orElseThrow().unspentPoints());
}
}
@Test
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -38,6 +38,19 @@ class EquipmentSessionStoreTest {
return stage.toCompletableFuture().get(5, TimeUnit.SECONDS);
}
@Test void previousGameModeIsAcknowledgedPersistedAndCannotBeReplacedByARepeatedPreparation() throws Exception {
try (var store = new EquipmentSessionStore(data, CLOCK)) {
var prepared = await(store.prepare(player, session, character, "CREATIVE"));
assertEquals("CREATIVE", prepared.pending().previousGameMode());
try (var reopened = new EquipmentSessionStore(data, CLOCK)) {
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
assertEquals(prepared, await(reopened.prepare(player, session, character, "CREATIVE")));
failure(reopened.prepare(player, session, character, "SURVIVAL"));
assertEquals(prepared, await(reopened.find(player)).orElseThrow());
}
}
}
private Path record() { return data.resolve("sessions").resolve(player + ".yml"); }
private static Throwable failure(CompletionStage<?> stage) {
@@ -0,0 +1,25 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class OrdinaryVitalsTest {
@Test void canonicalOrdinarySnapshotRetainsExactHealthNutritionAndDisplayValues() {
var value = new OrdinaryVitals(12.25, 7, 2.5f, 3.75f, false, 20);
assertEquals(value, OrdinaryVitals.decode(value.encode()));
var scaled = new OrdinaryVitals(1, 0, 0, 0, true, 44);
assertEquals(scaled, OrdinaryVitals.decode(scaled.encode()));
}
@Test void malformedNonfiniteNegativeAndNoncanonicalSnapshotsCannotAuthorizeRestoration() {
for (String input : List.of("", "garbage", "x".repeat(300), "NaN;7;2.0;3.0;false;20.0",
"Infinity;7;2.0;3.0;false;20.0", "-1.0;7;2.0;3.0;false;20.0", "12.0;-1;2.0;3.0;false;20.0",
"12.0;21;2.0;3.0;false;20.0", "12.0;7;NaN;3.0;false;20.0", "12.0;7;2.0;-1.0;false;20.0",
"12.0;7;2.0;Infinity;false;20.0", "12.0;7;2.0;3.0;yes;20.0", "12.0;7;2.0;3.0;false;0.0",
"12;7;2.0;3.0;false;20.0", "12.0;7;2.0;3.0;false;20.0;extra")) {
assertThrows(IllegalArgumentException.class, () -> OrdinaryVitals.decode(input), input);
}
}
}
@@ -43,6 +43,29 @@ class YamlCharacterRepositoryTest {
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
}
@Test
void restrictedKillHistoryRoundTripsPerWorldAndOldRecordsRemainCompatible() throws Exception {
var original = sample(UUID.randomUUID());
var worldA = UUID.randomUUID(); var worldB = UUID.randomUUID();
var history = Map.of(
worldA, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldA,
new BigDecimal("1.25"), new BigDecimal("-64"), new BigDecimal("3E+2")),
worldB, new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(worldB,
BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TEN));
var withHistory = new CharacterRecord(original.id(), original.name(), original.revision(), original.stats(),
original.level(), original.experience(), original.unspentPoints(), original.health(), original.learned(),
original.loadout(), original.cooldowns(), history, original.dead(), original.deathId(), original.diedAt());
var repository = new YamlCharacterRepository(data);
repository.create(withHistory);
assertEquals(withHistory, repository.load(withHistory.id()).orElseThrow());
String yaml = Files.readString(data.resolve("characters").resolve(withHistory.id() + ".yml"));
assertTrue(yaml.contains("restricted-kills:"));
assertTrue(yaml.contains(worldA.toString()));
String legacy = CharacterYaml.encode(original).replace("restricted-kills: []\n", "");
assertEquals(Map.of(), CharacterYaml.decode(legacy).restrictedKills());
}
@Test
void repeatedCreationAndAbandonmentDoNotAccumulatePerCharacterCoordinationFiles() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -0,0 +1,31 @@
package games.dmg.fantasystick.progression;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatContributionsTest {
@Test void deduplicatesCharactersRefreshesTheirDeadlineAndConsumesATargetOnce() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var a = UUID.randomUUID(); var b = UUID.randomUUID();
var start = Instant.parse("2026-09-19T20:00:00Z");
ledger.accept(target, a, start);
ledger.accept(target, b, start);
ledger.accept(target, a, start.plusSeconds(60));
assertEquals(Set.of(a), ledger.consume(target, start.plusSeconds(301)), "Repeated accepted damage refreshes only its character");
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
}
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var character = UUID.randomUUID();
var now = Instant.parse("2026-09-19T20:00:00Z");
ledger.accept(target, character, now); ledger.invalidate(target);
assertEquals(Set.of(), ledger.consume(target, now));
ledger.accept(target, character, now); ledger.close(); ledger.accept(target, character, now);
assertEquals(Set.of(), ledger.consume(target, now));
}
}
@@ -0,0 +1,51 @@
package games.dmg.fantasystick.progression;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class CombatRewardRulesTest {
@Test void poolsAndRoundedSharesPreserveApprovedArithmetic() {
var rules = new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), new BigDecimal("3"));
assertEquals(BigInteger.ONE, rules.pool(CombatRewardRules.Target.PASSIVE, 99));
assertEquals(BigInteger.TEN, rules.pool(CombatRewardRules.Target.ORDINARY, 99));
assertEquals(BigInteger.valueOf(100), rules.pool(CombatRewardRules.Target.BOSS, 99));
assertEquals(BigInteger.valueOf(70), rules.pool(CombatRewardRules.Target.FANTASY_CHARACTER, 7));
assertEquals(BigInteger.valueOf(4), rules.share(BigInteger.TEN, 3));
assertEquals(BigInteger.ONE, rules.share(BigInteger.ONE, 3));
assertEquals(BigInteger.valueOf(34), rules.share(BigInteger.valueOf(100), 3));
assertEquals(BigInteger.valueOf(24), rules.share(BigInteger.valueOf(70), 3));
}
@Test void contributionWindowIncludesExactFiveMinuteBoundaryAndRejectsFutureTimes() {
var rules = CombatRewardRules.defaults();
var hit = Instant.parse("2026-09-19T20:00:00Z");
assertTrue(rules.contributes(hit, hit.plus(Duration.ofMinutes(5))));
assertFalse(rules.contributes(hit, hit.plus(Duration.ofMinutes(5)).plusNanos(1)));
assertFalse(rules.contributes(hit.plusSeconds(1), hit));
}
@Test void restrictedSpacingIsPerWorldAndUsesStraightLineThreeDimensionalDistance() {
var rules = CombatRewardRules.defaults();
var a = UUID.randomUUID(); var b = UUID.randomUUID();
var origin = new CombatRewardRules.KillPosition(a, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO);
assertFalse(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("2.999"), BigDecimal.ZERO, BigDecimal.ZERO)));
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(a, new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("2"))));
assertTrue(rules.spaced(origin, new CombatRewardRules.KillPosition(b, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO)));
}
@Test void rejectsInvalidRewardConfigurationAndArithmeticInputs() {
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.valueOf(-1), BigInteger.TEN, BigInteger.TEN, BigDecimal.ONE)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.TEN, new BigDecimal("-0.1"))),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().share(BigInteger.ONE, 0)),
() -> assertThrows(IllegalArgumentException.class, () -> CombatRewardRules.defaults().pool(CombatRewardRules.Target.FANTASY_CHARACTER, 0)),
() -> assertThrows(IllegalArgumentException.class, () -> new CombatRewardRules.KillPosition(UUID.randomUUID(), new BigDecimal("1E1000000"), BigDecimal.ZERO, BigDecimal.ZERO))
);
}
}