Compare commits

...
86 Commits
Author SHA1 Message Date
dmg 6ac4867821 feat(config): export acknowledged switch-delay changes
Release / release (push) Successful in 18m8s
2026-09-26 13:38:06 -04:00
dmg b0996b0c6e feat(revival): export acknowledged admin Tail events
Release / release (push) Successful in 17m45s
2026-09-26 13:19:05 -04:00
dmg 850a04c4a0 test(combat): await acknowledged native session exit
Release / release (push) Successful in 10m29s
2026-09-26 13:00:55 -04:00
dmg 439c1b9cdd feat(issuance): export acknowledged admin sheet events
Release / release (push) Successful in 18m0s
2026-09-26 12:25:24 -04:00
dmg 2ae8f1ddf9 test(fishing): await acknowledged catch feedback
Release / release (push) Successful in 18m0s
2026-09-26 10:38:33 -04:00
dmg 912c077da1 fix(recovery): preserve pulses when audit world unloads 2026-09-26 10:38:33 -04:00
dmg 9b378b4237 feat(inspire): export accepted native absorption events
Release / release (push) Failing after 9m42s
2026-09-26 08:57:01 -04:00
dmg 93897ce516 feat(hex): export accepted native prevention events
Release / release (push) Successful in 17m49s
2026-09-26 07:40:13 -04:00
dmg fc49ee1cfb feat(shield-bash): audit accepted strike and knockback
Release / release (push) Successful in 16m56s
2026-09-25 23:32:35 -04:00
dmg ee7d9036a2 feat(stone-skin): audit owned damage mitigation
Release / release (push) Failing after 16m11s
2026-09-25 23:13:37 -04:00
dmg 4616ee80e3 feat(second-wind): audit native self-heal and config
Release / release (push) Failing after 17m6s
2026-09-25 23:00:17 -04:00
dmg e6b66d9e34 feat(quickstep): audit owned dash outcomes
Release / release (push) Failing after 8m47s
2026-09-25 22:40:16 -04:00
dmg fc90af3a62 feat(longbow): audit native draw and arrow damage
Release / release (push) Successful in 17m13s
2026-09-25 22:10:24 -04:00
dmg f6d95ca338 feat(frost-shard): audit owned arrow and slow outcomes
Release / release (push) Successful in 17m59s
2026-09-25 21:52:30 -04:00
dmg db4c3b2842 feat(great-sword): audit charged native strikes
Release / release (push) Successful in 16m41s
2026-09-25 21:22:45 -04:00
dmg 830049b339 feat(nutrition): audit native healing and restoration
Release / release (push) Successful in 17m20s
2026-09-25 20:52:29 -04:00
dmg fcbc2814c2 fix(ci): avoid transient event files in native test scan
Release / release (push) Successful in 14m0s
2026-09-25 17:06:08 -04:00
dmg 897a0606ba fix(adventure): retract owned hook after quest catch
Release / release (push) Failing after 13m17s
2026-09-25 16:39:35 -04:00
dmg c13c3093cf test(combat): await proved basic attack reentry
Release / release (push) Successful in 14m58s
2026-09-25 15:57:40 -04:00
dmg bb8d1857b4 feat(combat): audit accepted basic attacks
Release / release (push) Failing after 7m39s
2026-09-25 15:26:45 -04:00
dmg c7fce1a53d fix(adventure): retain quest hook while fish approaches
Release / release (push) Successful in 8m46s
2026-09-25 15:19:11 -04:00
dmg af7c75b6ed feat(parties): audit native protection decisions
Release / release (push) Successful in 15m37s
2026-09-25 14:11:48 -04:00
dmg 39881ad6fc feat(recovery): audit session and configuration outcomes
Release / release (push) Successful in 15m50s
2026-09-25 13:36:58 -04:00
dmg 9737ed0ddb feat(equipment): audit native packing and recovery
Release / release (push) Successful in 15m47s
2026-09-25 12:53:32 -04:00
dmg 53366b3c50 feat(characters): audit craft rename and observed transfer
Release / release (push) Successful in 14m59s
2026-09-25 12:07:48 -04:00
dmg 84205c0c14 feat(sessions): audit persisted character-switch deadlines
Release / release (push) Failing after 7m33s
2026-09-25 11:29:18 -04:00
dmg 2cc053e6ff feat(events): audit bound book volume navigation
Release / release (push) Failing after 7m17s
2026-09-25 10:44:46 -04:00
dmg 3f104cb0e2 feat(events): record native recipe discovery
Release / release (push) Successful in 14m50s
2026-09-25 10:07:18 -04:00
dmg 13828054e5 feat(events): audit combat feedback preferences
Release / release (push) Failing after 14m7s
2026-09-25 09:48:27 -04:00
dmg 39c58ef4d9 feat(events): audit account aura preferences
Release / release (push) Successful in 14m40s
2026-09-25 09:37:45 -04:00
dmg ec73018f93 feat(events): audit account particle preferences
Release / release (push) Successful in 14m30s
2026-09-25 09:21:18 -04:00
dmg b86f703d14 feat(events): record Meteor impact outcomes
Release / release (push) Successful in 12m56s
2026-09-25 03:19:10 -04:00
dmg 2231dd058a feat(events): record War Banner benefit outcomes
Release / release (push) Successful in 7m10s
2026-09-25 03:11:25 -04:00
dmg ff28f2e98d feat(events): record Sanctuary area and healing outcomes
Release / release (push) Successful in 7m19s
2026-09-25 03:02:45 -04:00
dmg 21877f6820 feat(events): record Chain Lightning strike outcomes
Release / release (push) Failing after 13m13s
2026-09-25 02:50:24 -04:00
dmg 228de3d08f feat(events): record Frost Nova burst outcomes
Release / release (push) Successful in 7m29s
2026-09-25 02:42:05 -04:00
dmg e3bc854481 feat(events): record Rallying Cry boost outcomes
Release / release (push) Successful in 13m10s
2026-09-25 02:28:10 -04:00
dmg 3163a491a0 feat(events): record Guardian Bond damage outcomes
Release / release (push) Successful in 13m31s
2026-09-25 02:18:00 -04:00
dmg 08d13d2192 feat(events): record Purify cleanse outcomes
Release / release (push) Successful in 13m17s
2026-09-25 02:01:08 -04:00
dmg 1d0cdc8f0f feat(events): record Mending Light healing outcomes
Release / release (push) Successful in 8m27s
2026-09-25 01:51:17 -04:00
dmg 9674a8e5e6 feat(events): record Fireball and configuration outcomes
Release / release (push) Successful in 14m20s
2026-09-25 01:41:25 -04:00
dmg dd75e6a76f feat(events): add opt-in ability cast event hooks
Release / release (push) Successful in 14m37s
2026-09-25 01:16:43 -04:00
dmg 22d92b5bbf feat(events): record character session and message outcomes
Release / release (push) Successful in 14m5s
2026-09-25 00:18:41 -04:00
dmg 26ccc95cdd feat(events): record character coin outcomes
Release / release (push) Successful in 14m24s
2026-09-24 23:57:29 -04:00
dmg 8ae8cdb603 feat(events): retain shared combat XP evidence
Release / release (push) Successful in 13m53s
2026-09-24 23:40:28 -04:00
dmg d18a41af6c feat(events): record combat reward resolutions
Release / release (push) Successful in 13m58s
2026-09-24 23:31:16 -04:00
dmg 6428ab999b feat(events): record persistent party outcomes
Release / release (push) Successful in 14m5s
2026-09-24 23:17:39 -04:00
dmg 6bb58cd52d feat(events): record complete Revive outcomes
Release / release (push) Successful in 13m59s
2026-09-24 22:44:16 -04:00
dmg 25eb6e3a6b feat(events): record passive recovery outcomes
Release / release (push) Successful in 13m53s
2026-09-24 22:26:45 -04:00
dmg 51b055da12 feat(events): record exact ability refunds
Release / release (push) Successful in 13m52s
2026-09-24 22:11:35 -04:00
dmg 60ccb34ab6 feat(events): record permanent stat mutations
Release / release (push) Successful in 14m32s
2026-09-24 22:00:02 -04:00
dmg 0bdb43b2bb feat(events): record acknowledged character book views
Release / release (push) Failing after 13m19s
2026-09-24 21:47:58 -04:00
dmg ae741496b3 feat(events): record inventory safeguard outcomes
Release / release (push) Successful in 13m50s
2026-09-24 21:30:33 -04:00
dmg fa917e57ba feat(rare-items): add explicit Phoenix Down rescue
Release / release (push) Successful in 13m18s
2026-09-24 21:05:41 -04:00
dmg 689877403f test(events): await downed presentation callback
Release / release (push) Successful in 13m24s
2026-09-24 20:38:55 -04:00
dmg 936e221c81 test(events): await acknowledged downed emission
Release / release (push) Failing after 7m9s
2026-09-24 09:04:20 -04:00
dmg 819a966160 feat(events): record downed combat outcomes
Release / release (push) Failing after 6m54s
2026-09-24 08:51:44 -04:00
dmg 1926dbb9f7 feat(events): add local CloudEvent journal
Release / release (push) Successful in 13m34s
2026-09-23 23:56:05 -04:00
dmg c776dca248 test(downed): allow completed shutdown drain
Release / release (push) Successful in 11m46s
2026-09-23 23:02:32 -04:00
dmg bce5391b70 feat(downed): add durable combat recovery
Release / release (push) Failing after 5m44s
2026-09-23 22:51:31 -04:00
dmg f183d7daaa feat(rare-shop): add magic core consumable
Release / release (push) Successful in 11m23s
2026-09-23 21:10:41 -04:00
dmg c960dbcf7b feat(rare-shop): add confirmed reskill consumable
Release / release (push) Successful in 11m53s
2026-09-23 20:55:32 -04:00
dmg fa87b44cc8 feat(rare-shop): add persistent potion market
Release / release (push) Successful in 11m55s
2026-09-23 20:26:19 -04:00
dmg 70ac5f0801 fix(test): await adventure reward publication
Release / release (push) Successful in 10m43s
2026-09-23 19:32:54 -04:00
dmg d8efc41fb6 fix(test): await durable Tail publication
Release / release (push) Failing after 5m14s
2026-09-23 19:23:52 -04:00
dmg 185de89c85 fix(test): await champion respawn condition
Release / release (push) Failing after 10m11s
2026-09-23 19:08:43 -04:00
dmg 618a6a2776 feat(dungeons): persist kill and trophy rewards
Release / release (push) Failing after 6m11s
2026-09-23 18:57:54 -04:00
dmg e308f2f884 fix(test): await next champion encounter
Release / release (push) Successful in 10m51s
2026-09-23 18:02:52 -04:00
dmg cf2e013f74 fix(test): await durable native publications
Release / release (push) Failing after 10m28s
2026-09-23 17:49:28 -04:00
dmg 720fdc1687 feat(adventure): add persistent camp trophies
Release / release (push) Successful in 6m33s
2026-09-23 17:39:40 -04:00
dmg 4d7ce211f5 fix(adventure): await dungeon mode publication
Release / release (push) Failing after 5m35s
2026-09-23 17:37:05 -04:00
dmg bca3033fd9 feat(adventure): add persistent champion trophies
Release / release (push) Failing after 5m30s
2026-09-23 17:19:46 -04:00
dmg 1e3c7f28e2 fix(adventure): await party acceptance publication
Release / release (push) Successful in 11m30s
2026-09-23 17:03:44 -04:00
dmg dc68811a56 fix(adventure): preview fishing submission terms
Release / release (push) Failing after 6m14s
2026-09-23 16:29:26 -04:00
dmg afc596e727 feat(adventure): add persistent fishing quests 2026-09-23 16:25:14 -04:00
dmg 421e44d86f feat(adventure): add site participation modes
Release / release (push) Successful in 11m36s
2026-09-23 15:23:18 -04:00
dmg 871258070b test(dungeon): await asynchronous creation
Release / release (push) Successful in 6m41s
2026-09-23 15:17:41 -04:00
dmg cbd0f70a8b feat(admin): recover saved character sheets
Release / release (push) Failing after 11m7s
2026-09-23 14:47:25 -04:00
dmg 7b588e08d2 feat(admin): inspect saved characters
Release / release (push) Failing after 6m1s
2026-09-23 12:50:00 -04:00
dmg 86f248d80c feat(commands): split player and admin roots
Release / release (push) Failing after 5m54s
2026-09-23 12:20:39 -04:00
dmg f8fed8998b fix(rewards): apply mob spacing to coins
Release / release (push) Successful in 11m12s
2026-09-23 08:35:39 -04:00
dmg 9837afc549 fix(sessions): preserve cross-world Tail delivery
Release / release (push) Successful in 11m37s
2026-09-23 08:22:11 -04:00
dmg 1ea4e8c13f feat(particles): add account-wide visibility control
Release / release (push) Successful in 11m33s
2026-09-23 07:11:10 -04:00
dmg e3f2f2481b feat(particles): add viewer-specific active auras
Release / release (push) Successful in 11m38s
2026-09-23 06:28:58 -04:00
dmg 6ec97569d3 feat(admin): add read-only active character view
Release / release (push) Successful in 11m51s
2026-09-23 00:02:07 -04:00
dmg fd5961ccd8 fix(sessions): simplify switch cooldown feedback
Release / release (push) Successful in 11m8s
2026-09-22 23:26:29 -04:00
257 changed files with 24779 additions and 1140 deletions

No files matched your search

+20 -7
View File
@@ -1,6 +1,6 @@
# Fantasy Stick
**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.**
**Transferable characters, equipment restoration, inventory safety, character inspection, native session health, durable downed decisions and combat Revive, parties, and switch delay 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, 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.
@@ -20,6 +20,14 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
## Local event files
Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8 JSONL under `<plugin data directory>/events/YYYY-MM-DD/events-<boot UUID>-<segment>.jsonl`. The initial foundation records plugin lifecycle/recovery and correlated observed/resolved native interactions for active characters: block/entity/item/inventory/vehicle/control use, item pickup/drop, held-slot changes and discrete teleports. The downed feature additionally records durable downing/resolution facts, learned combat-Revive casts and results, accepted final-death/Tail transitions, prevented waiting/protection damage, protection expiry, and authorized downed-policy reads/changes. Stable episode/action identities and original deadlines, attribution, custody, locations, frozen rewards, caster cooldown/range/line-of-sight context survive recovery. Phoenix Down purchase, explicit downed-menu use, exact item/health proof, protection/prevention and expiry records are emitted only from their acknowledged feature boundaries. It intentionally excludes movement/look ticks, rendering, particles and unrelated ordinary-player activity. It has no dependency on Event Producer, no network endpoint and no player-facing log reader.
A stable plugin-owned server UUID and per-lifetime boot/sequence identity distinguish records. Events rotate at UTC midnight, on each writer lifetime and before 128 MiB; an oversized record stays whole in its own segment. Accepted work is first retained in internal pending files and exported at least once with unchanged IDs, so consumers must deduplicate by `(source, id)`. Startup recovers pending work, quarantines malformed/interrupted tails outside `*.jsonl`, and reports incomplete prior lifetimes without inventing a shutdown. Queue overload and pre-durable native-observation crashes remain truthful loss windows; logging never rolls back or replays gameplay.
These files have no automatic deletion, compression or retention limit, so disk use grows until operators manage it under a separately approved policy. Current generic records include stable account/character/session IDs and precise locations; later feature-owned event slices may include complete public and recognized private-message text. Restrict event files and backups to operators accordingly. They are not encrypted or cryptographically tamper-evident, and feedback/particle preferences do not disable auditing.
## Character storage and keepsakes
`YamlCharacterRepository` stores schema-version-1 records at `<plugin data directory>/characters/<character UUID>.yml`. Character identity, name, progress, seven-slot loadout, ability cooldowns, explicit `dead` flag and latest death ID/time are preserved; the record has no holder-owned equipment or ordinary-health/nutrition snapshot. Integer values and decimal health retain exact precision, and timestamps use UTC RFC 3339 strings. Unknown learned ability IDs and unlearned cooldowns survive reload.
@@ -136,9 +144,12 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
Administration (permission `fantasystick.admin`, default operators):
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/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.
- `/fstickadmin character find <name...>` and `character show <character-uuid>` — asynchronously locate authoritative saved characters and inspect one exact UUID read-only.
- `/fstickadmin character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
- `/fstickadmin character recover <character-uuid> <online-player>` — previews and explicitly confirms a generation-revoking reissue of the same saved character; dead characters use a distinct no-Tail revival action, and unresolved native delivery remains fail-closed.
- `/fstickadmin inspect <player-uuid>` — asynchronous receipt/native-session inspection.
- `/fstickadmin recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
- `/fstickadmin 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.
@@ -166,17 +177,19 @@ A same-process retry can acknowledge gear already verified restored for that exa
## 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.
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. 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.
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death, explicitly consume one character-owned Phoenix Down, or wait for the learned, aimed, any-party Revive. Phoenix Down is a persistent Rare Items product with global midnight-UTC stock 0–2, base price 1000 and the shared ±10% pricing policy; a newly delivered product appears only at the next scheduled or authorized restock. Purchase has no immediate effect and the character Inventory instructs use only while downed. An enabled exact-menu choice reserves one item against that episode, competes through the same single-outcome authority, restores full current fantasy health in place and applies the captured `rare-phoenix-down-invulnerability-seconds` deadline (default 5, zero allowed). Logout, restart, waiting, menu reopen, Accept Death, timeout and ordinary Inventory use never consume one automatically. A learned Revive or final death that wins leaves the item intact.
Accept Death and timeout compete with Revive for one persisted outcome. Successful Revive saves full current fantasy health, resumes the same active character in place and uses one absolute configurable protection deadline. Final death records the decision time—or the original expiry for delayed offline reconciliation—then applies frozen PvP reward/coin shares and party consequences exactly once before guarded Tail conversion. Ordinary health is restored before health-scale setters can send or queue client health packets, and return uses only the captured session-entry location. Refused or unavailable destinations retain explicit recovery evidence. New Tails retain the existing signed, authoritative death identity and readable static character history; 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.
The preceding equipment/progression checkpoint sections retain their historical evidence; current downed, Revive, nutrition, progression and combat behavior is governed by their later delivered stories and native integration tests.
## Character inspection and inventory safety
@@ -51,10 +51,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
private games.dmg.fantasystick.persistence.RareSettings rareSettings;
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
private games.dmg.fantasystick.shop.RareMerchantDirectory rareMerchants;
private games.dmg.fantasystick.shop.RareMerchantController rareMerchantController;
private games.dmg.fantasystick.shop.RareMarket rareMarket;
private games.dmg.fantasystick.shop.RareShop rareShop;
private games.dmg.fantasystick.shop.RareShopMenus rareShopMenus;
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
private games.dmg.fantasystick.shop.RareReskillService rareReskills;
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private games.dmg.fantasystick.shop.PhoenixDownService phoenixDowns;
private org.bukkit.scheduler.BukkitTask rareMarketPulse,rareMenuPulse;
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
private games.dmg.fantasystick.presentation.ParticleDelivery particleDelivery;
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
private volatile Object lifetime;
private CharacterPresentation presentation;
@@ -109,6 +124,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.persistence.GreatSwordSettings greatSwordSettings;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
@@ -124,7 +140,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
private games.dmg.fantasystick.adventure.FishingController fishingController;
private games.dmg.fantasystick.dungeon.DungeonDirectory dungeons;
private games.dmg.fantasystick.session.DownedDirectory downed;
private games.dmg.fantasystick.session.DownedController downedController;
private games.dmg.fantasystick.persistence.DownedSettings downedSettings;
private games.dmg.fantasystick.dungeon.DungeonController dungeonController;
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
@@ -142,8 +163,44 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private games.dmg.fantasystick.events.LocalEventLog eventLog;
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.NutritionEventBridge nutritionAuditEvents;
private java.util.concurrent.CompletionStage<Void> nutritionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
private java.util.concurrent.CompletionStage<Void> switchConfigAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
private void dispatchOwnedHealing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
var policy = nutritionPolicy;
if (policy == null) getServer().getPluginManager().callEvent(event);
else policy.dispatchOwnedHealing(event, getServer().getPluginManager()::callEvent);
}
@Override public void onEnable() {
try {
Object generation = new Object();
@@ -173,6 +230,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(key)));
var rareConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.RareSettings.keys())rareConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.RareSettings.defaultValue(key)));
var downedConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.DownedSettings.keys())downedConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.DownedSettings.defaultValue(key)));
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
coinConfiguration.put(key, getConfig().getString(key,
@@ -207,7 +266,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
var rewardRules = combatRewardRules(hardMobXp);
var fireballConfiguration = fireballConfiguration();
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
var greatSwordSettings = greatSwordSettings();
var greatSwordConfiguration = greatSwordConfiguration();
var greatSwordInitial = games.dmg.fantasystick.persistence.GreatSwordSettings.parse(greatSwordConfiguration);
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
@@ -247,6 +307,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
getDataFolder().toPath(), partyEconomyConfiguration);
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
rareSettings=new games.dmg.fantasystick.persistence.RareSettings(getDataFolder().toPath(),rareConfiguration);
downedSettings=new games.dmg.fantasystick.persistence.DownedSettings(getDataFolder().toPath(),downedConfiguration);
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
getDataFolder().toPath(), hardMobXp.toString());
@@ -261,6 +323,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
characters,gearSettings::rules,Clock.systemUTC(),
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
try{rareInventory=new games.dmg.fantasystick.shop.RareInventoryDirectory(new games.dmg.fantasystick.persistence.YamlRareInventoryRepository(getDataFolder().toPath()));}catch(java.io.IOException invalidRareInventory){throw new java.io.UncheckedIOException("Rare Inventory could not be loaded",invalidRareInventory);}
var rareRandom=java.util.random.RandomGenerator.getDefault();rareMarket=new games.dmg.fantasystick.shop.RareMarket(new games.dmg.fantasystick.persistence.YamlRareCatalogRepository(getDataFolder().toPath()),new games.dmg.fantasystick.persistence.RarePurchaseOperationStore(getDataFolder().toPath()),characters,rareInventory,rareSettings::rules,Clock.systemUTC(),(minimum,maximum)->(int)rareRandom.nextLong(minimum,(long)maximum+1));
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
task -> getServer().getScheduler().runTask(this, task),
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
@@ -272,8 +336,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
} catch (java.io.IOException invalidPreferences) {
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
}
particleDelivery = new games.dmg.fantasystick.presentation.ParticleDelivery(getServer(), feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Player particle delivery failed", failure));
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
java.util.function.Consumer<Throwable> presentationFailures = problem ->
getLogger().log(Level.WARNING, "Character presentation failed", problem);
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
@@ -312,13 +380,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
var adventureRepository=new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath());
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
adventureRepository, Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
fishing = new games.dmg.fantasystick.adventure.FishingDirectory(
new games.dmg.fantasystick.persistence.YamlFishingRepository(getDataFolder().toPath()));
dungeons = new games.dmg.fantasystick.dungeon.DungeonDirectory(
new games.dmg.fantasystick.persistence.YamlDungeonRepository(getDataFolder().toPath()),
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
downed = new games.dmg.fantasystick.session.DownedDirectory(getDataFolder().toPath(),Clock.systemUTC());
if(adventureRepository.requiresDungeonRewardMigration())adventureQuests.migrateLegacyDungeonRewards(id->dungeons.find(id).flatMap(value->value.rooms().stream().filter(room->room.boss()!=null).findFirst().map(room->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.LegacyDungeonReward(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.completionReward().xp(),value.completionReward().coins()),room.id(),dungeons.runView(id,adventureQuests.snapshot().catalog()==null?new java.util.UUID(0,0):adventureQuests.snapshot().catalog().revision()).map(games.dmg.fantasystick.dungeon.DungeonState.Run::defeated).orElse(false))))).join();
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
rareMerchants=new games.dmg.fantasystick.shop.RareMerchantDirectory(new games.dmg.fantasystick.persistence.YamlRareMerchantRepository(getDataFolder().toPath()));
} catch (java.io.IOException invalidManagedState) {
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
}
@@ -329,6 +402,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
downedController=new games.dmg.fantasystick.session.DownedController(this,sessions,downed,Clock.systemUTC(),failure->getLogger().log(Level.WARNING,"Downed transition failed",failure));
phoenixDowns=new games.dmg.fantasystick.shop.PhoenixDownService(rareInventory,rareSettings,downed,sessions,Clock.systemUTC(),nextTick,downedController::revive,failure->getLogger().log(Level.WARNING,"Phoenix Down use failed",failure));
downedController.attachPhoenix(phoenixDowns);phoenixDowns.install();
sessions.attachDowned(downed,downedSettings::timeout,downedController::open);
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
@@ -341,7 +418,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
}
}));
sessions.attachPartyDefeat(characterParties::defeat);
sessions.attachPartyDefeat(character->{var before=characterParties.partyOf(character).orElse(null);return characterParties.defeat(character).thenApply(outcome->{var bridge=partyAuditEvents;if(bridge!=null)bridge.finalDefeat(character,before,outcome);return outcome;});});
var reconcilingParties = characterParties;
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
@@ -356,15 +433,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()),
particleDelivery);
sessions.attachDefeatCapture(combatExperience::captureDownedDefeat);
sessions.attachDownedRewardAllocation(combatExperience::allocateDownedDefeat);
sessions.attachDownedDefeatRewards(combatExperience::settleDownedDefeat);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
getDataFolder().toPath(), fireballConfiguration);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial, particleDelivery);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial, particleDelivery);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
@@ -375,7 +455,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
@@ -396,7 +476,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
@@ -414,26 +494,32 @@ public final class FantasyStickPlugin extends JavaPlugin {
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
getDataFolder().toPath(), frostNovaConfiguration);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties, particleDelivery);
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(
chainLightningInitial, sessions, parties, particleDelivery);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
combatMitigation, nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()),
particleDelivery);
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
warBanner = new games.dmg.fantasystick.ability.WarBanner(
warBannerInitial, sessions, parties, outgoingDamageBonus, particleDelivery);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties, particleDelivery);
greatSwordSettings = new games.dmg.fantasystick.persistence.GreatSwordSettings(
getDataFolder().toPath(), greatSwordConfiguration);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordInitial);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
revive.attachDowned(downedController,downedSettings::protection);
abilityRegistry.register(fireball.definition());
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
@@ -458,11 +544,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions, nextTick);
sessions.attachNutritionPolicy(nutritionPolicy);
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
getDataFolder().toPath(), recoveryConfiguration);
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
Clock.systemUTC(), nextTick, this::dispatchOwnedHealing,
games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING,
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
@@ -475,7 +562,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
getDataFolder().toPath(), healthStealInitial.healing().toString());
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
this::dispatchOwnedHealing, games.dmg.fantasystick.session.NativeHealthLimit::read,
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
combatFeedback);
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
@@ -544,26 +631,44 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(lifetime==generation)localArmorMenus.refreshOpen();});}
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
rareShop=new games.dmg.fantasystick.shop.RareShop(rareMerchants,sessions,rareMarket,rareInventory,nextTick,failure->getLogger().log(Level.WARNING,"Rare Items purchase failed",failure));
rareShopMenus=new games.dmg.fantasystick.shop.RareShopMenus(this,rareShop);var localRareMenus=rareShopMenus;
rareMarket.attachListener(catalog->{if(lifetime!=generation)return;if(rareEvents!=null)rareEvents.catalogRefreshed(catalog);try{nextTick.accept(()->{if(lifetime==generation)localRareMenus.refreshOpen();});}catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Rare Items menu refresh failed",failure);}});
rareMerchantController=new games.dmg.fantasystick.shop.RareMerchantController(this,rareMerchants,rareShopMenus::open);
rarePotions=new games.dmg.fantasystick.shop.RarePotionService(rareInventory,rareSettings,characters,sessions,this::dispatchOwnedHealing,nextTick,failure->getLogger().log(Level.WARNING,"Rare potion use failed",failure));
rareReskills=new games.dmg.fantasystick.shop.RareReskillService(rareInventory,characters,sessions,nextTick,record->{for(String ability:record.learned())abilityEngine.remove(record.id(),ability);speedI.sweep();lowLightVision.sweep();},failure->getLogger().log(Level.WARNING,"Rare Reskill use failed",failure));
rareMagicCores=new games.dmg.fantasystick.shop.RareMagicCoreService(rareInventory,characters,sessions,nextTick,failure->getLogger().log(Level.WARNING,"Rare Magic Core use failed",failure));
rarePotions.install();rareReskills.install();rareMagicCores.install();books.attachRareInventory(rareInventory,rarePotions,rareReskills,rareMagicCores);
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault());
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault(), particleDelivery);
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
this, adventureQuests, adventureQuestController, nextTick);
fishing.registerLabels(value->adventureQuests.findSite(value.site()).map(games.dmg.fantasystick.adventure.AdventureQuestState.Site::fishing).filter(java.util.Objects::nonNull).filter(definition->definition.itemId().equals(value.item())).map(games.dmg.fantasystick.adventure.AdventureQuestState.FishingDefinition::itemName).orElse(value.name()));
fishingController=new games.dmg.fantasystick.adventure.FishingController(this,adventureQuests,fishing,
adventureQuestSettings,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),
failure->getLogger().log(Level.WARNING,"Fishing "+failure.phase()+" failed for character "+failure.character()+"; operation "+failure.operation(),failure.cause()));
adventureQuestController.registerFishing(fishingController);adventureQuestController.registerResetParticipant(fishingController);books.attachFishingInventory(fishing);
sessionEvents.attachNativeUseControl(fishingController::nativeUse);
var bossRecoverySettings=outOfCombatRecoverySettings;var bossQuickerRegeneration=quickerRegeneration;var bossQuickerRegenerationII=quickerRegenerationII;var bossHealthStealSettings=healthStealSettings;
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing());
var custodyDirectory=dungeons;
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing(),particleDelivery);
dungeonController.registerFishing(fishingController);
var custodyDirectory=dungeons;var downedDirectory=downed;
java.util.function.Predicate<java.util.UUID> unavailableCharacter=character->custodyDirectory.custody(character).isPresent()||downedDirectory.all().stream().anyMatch(value->value.character().equals(character)&&value.phase()==games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING);
sessions.attachUnavailableCharacter(unavailableCharacter);
characters.attachUnavailableCharacter(unavailableCharacter);
characterParties.attachUnavailableCharacter(unavailableCharacter);
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())).toList());
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z(),null,value.participationMode())).toList());
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
adventureQuestController.registerResetParticipant(dungeonController);
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
combatExperience.attachManagedOrigins(entity->adventureQuestController.encounterOrigin(entity).or(()->dungeonController.encounterOrigin(entity)));
combatExperience.attachManagedSettlement(dungeonController::settleKill);
combatExperience.attachExcluded(adventureQuestController::managed);
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|| armorerController.tagged(entity) || rareMerchantController.tagged(entity) || adventureQuestController.giver(entity));
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings,
@@ -572,6 +677,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachFireball(fireballSettings, fireball);
admin.attachGreatSword(greatSwordSettings, greatSword);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
@@ -585,7 +691,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause())));
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery));
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
@@ -595,6 +701,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachGearSettings(gearSettings);
admin.attachArmorers(armorers,armorerController);
admin.attachArmorMarket(armorMarket);
admin.attachRareMarket(rareMerchants,rareMerchantController,rareMarket,rareSettings);
admin.attachDownedSettings(downedSettings);
admin.attachCoinSettings(coinSettings);
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
@@ -608,17 +716,135 @@ public final class FantasyStickPlugin extends JavaPlugin {
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
"Missing packaged fantasyfeedback command");
playerFeedbackCommand.setExecutor(feedbackCommand);
playerFeedbackCommand.setTabCompleter(feedbackCommand);
admin.attachCharacterBooks(books, characters);
characterRecovery = new games.dmg.fantasystick.session.AdminCharacterRecovery(
characters, receipts, revivals, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING,
"Saved character recovery " + problem.operation() + " for character "
+ problem.character() + " and recipient " + problem.recipient(), problem.cause()));
admin.attachCharacterRecovery(characterRecovery);
var playerRoot = new games.dmg.fantasystick.session.FantasyPlayerCommand(
admin, feedbackCommand, particlesCommand);
var playerCommand = java.util.Objects.requireNonNull(getCommand("fstick"),
"Missing packaged fstick command");
playerCommand.setExecutor(playerRoot);
playerCommand.setTabCompleter(playerRoot);
var adminCommand = java.util.Objects.requireNonNull(getCommand("fstickadmin"),
"Missing packaged fstickadmin command");
adminCommand.setExecutor(admin);
adminCommand.setTabCompleter(admin);
try {
eventLog = games.dmg.fantasystick.events.LocalEventLog.open(getDataFolder().toPath(), Clock.systemUTC(),
new games.dmg.fantasystick.events.LocalEventLog.RuntimeInfo("FantasyStick",
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
message -> getLogger().warning(message));
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
particlesCommand.attachEvents(preferenceEvents);
feedbackCommand.attachEvents(preferenceEvents);
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(shieldBash::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(localBasicAttack::ownsAcceptedDamage);
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
eventLog::emit, Clock.systemUTC()));
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
abilityEngine.enableCastEventsFor("fireball");
abilityEngine.enableCastEventsFor("great_sword");
abilityEngine.enableCastEventsFor("frost_shard");
abilityEngine.enableCastEventsFor("longbow");
abilityEngine.enableCastEventsFor("quickstep");
abilityEngine.enableCastEventsFor("second_wind");
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("shield_bash");
abilityEngine.enableCastEventsFor("hex");
abilityEngine.enableCastEventsFor("inspire");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
abilityEngine.enableCastEventsFor("rallying_cry");
abilityEngine.enableCastEventsFor("frost_nova");
abilityEngine.enableCastEventsFor("chain_lightning");
abilityEngine.enableCastEventsFor("sanctuary");
abilityEngine.enableCastEventsFor("war_banner");
abilityEngine.enableCastEventsFor("meteor");
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Admin revival event intent failed", failure));
admin.attachAdminRevivalEvents(adminRevivalEvents);
revive.attachAdminRevivalEvents(adminRevivalEvents);
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
eventLog::emit, Clock.systemUTC()));
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
nutritionAuditEvents = new games.dmg.fantasystick.events.NutritionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Nutrition event intent failed", failure));
sessions.attachNutritionEvents(nutritionAuditEvents);
nutritionPolicy.attachEvents(nutritionAuditEvents);
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
sessions.attachSwitchEvents(switchAuditEvents);
switchConfigEvents = new games.dmg.fantasystick.events.SwitchConfigEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Switch configuration event intent failed", failure));
admin.attachSwitchConfigEvents(switchConfigEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
crafting.attachIdentityEvents(identityAuditEvents);
admin.attachCharacterIssueEvents(identityAuditEvents);
anvilNames.attachIdentityEvents(identityAuditEvents);
sessions.attachIdentityEvents(identityAuditEvents);
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
sessions.attachEquipmentEvents(equipmentAuditEvents);
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
messageAuditEvents = new games.dmg.fantasystick.events.MessageEventBridge(
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; adminRevivalEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; nutritionAuditEvents = null; switchAuditEvents = null; switchConfigEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getPluginManager().registerEvents(anvilNames, this);
getServer().getPluginManager().registerEvents(sessionEvents, this);
getServer().getPluginManager().registerEvents(downedController, this);
getServer().getPluginManager().registerEvents(characterRecovery, this);
getServer().getPluginManager().registerEvents(chatNames, this);
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
@@ -627,18 +853,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
getServer().getPluginManager().registerEvents(armorerController,this);
getServer().getPluginManager().registerEvents(armorShopMenus,this);
getServer().getPluginManager().registerEvents(rareMerchantController,this);
getServer().getPluginManager().registerEvents(rareShopMenus,this);
getServer().getPluginManager().registerEvents(adventureQuestController,this);
getServer().getPluginManager().registerEvents(fishingController,this);
getServer().getPluginManager().registerEvents(dungeonController,this);
downedController.install();
partyRegistrarController.install();
trainingDummyController.install();
armorerController.install();
rareMerchantController.install();
dungeonController.install();
adventureQuestController.install();
fishingController.install();
var localArmorMarket=armorMarket;
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
localArmorMarket.current().whenComplete((catalog,failure)->{
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
var localRareMarket=rareMarket;rareMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;localRareMarket.current().whenComplete((catalog,failure)->{if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Rare Items restock check failed",failure);});},20L,20L);
rareMenuPulse=getServer().getScheduler().runTaskTimer(this,localRareMenus::refreshOpen,1200L,1200L);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(healthSteal, this);
getServer().getPluginManager().registerEvents(combatExperience, this);
@@ -671,17 +905,69 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, particleDelivery,
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");
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
if (eventLog != null) {
var recipeEvents = new games.dmg.fantasystick.events.RecipeDiscoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
var replayNutritionEvents = nutritionAuditEvents;
nutritionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
if (replayNutritionEvents != null) replayNutritionEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
if (replaySwitchConfigEvents != null) replaySwitchConfigEvents.replay();
});
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
if (replaySwitchEvents != null) replaySwitchEvents.replay();
});
var replayAdminRevivalEvents = adminRevivalEvents;
adminRevivalAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin revival event intent drain failed", failure);
if (replayAdminRevivalEvents != null) replayAdminRevivalEvents.replay();
});
var replayIdentityEvents = identityAuditEvents;
identityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
if (replayIdentityEvents != null) replayIdentityEvents.replay();
});
var replayEquipmentEvents = equipmentAuditEvents;
equipmentAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
});
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
if (eventLog != null) {
var preferenceReplay = preferenceEvents;
var savedPreferences = feedbackPreferences;
eventLog.ready().whenComplete((delivery, failure) -> {
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
&& preferenceReplay != null && savedPreferences != null)
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
});
}
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -696,10 +982,22 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingProtocol = protocolPresentation; protocolPresentation = null;
var closingChat = chatNames; chatNames = null;
var closingAdmin = admin; admin = null;
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingParticlesCommand = particlesCommand; particlesCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
particleDelivery = null;
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingNutritionAuditEvents = nutritionAuditEvents; nutritionAuditEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingSwitchConfigEvents = switchConfigEvents; switchConfigEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -712,13 +1010,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdventureQuests = adventureQuests; adventureQuests = null;
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
adventureQuestCommands = null;
var closingDungeons=dungeons;dungeons=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingFishing=fishing;fishing=null;var closingFishingController=fishingController;fishingController=null;
var closingDungeons=dungeons;dungeons=null;var closingDowned=downed;downed=null;var closingDownedController=downedController;downedController=null;phoenixDowns=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
var closingPartyManagement = partyManagement; partyManagement = null;
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
var closingRareSettings=rareSettings;rareSettings=null;var closingDownedSettings=downedSettings;downedSettings=null;var closingRareInventory=rareInventory;rareInventory=null;
var closingRareMerchants=rareMerchants;rareMerchants=null;var closingRareMerchantController=rareMerchantController;rareMerchantController=null;
var closingRareMarket=rareMarket;rareMarket=null;var closingRareShopMenus=rareShopMenus;rareShopMenus=null;rareShop=null;rarePotions=null;rareReskills=null;rareMagicCores=null;
var closingRareMarketPulse=rareMarketPulse;rareMarketPulse=null;var closingRareMenuPulse=rareMenuPulse;rareMenuPulse=null;
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
@@ -735,6 +1038,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFireballSettings = fireballSettings; fireballSettings = null;
var closingGreatSwordSettings = greatSwordSettings; greatSwordSettings = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
@@ -805,9 +1109,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::close);
if (closingGreatSwordSettings != null) cleanup("Great Sword configuration", closingGreatSwordSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
@@ -839,13 +1145,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
if(closingRareSettings!=null)cleanup("rare configuration",closingRareSettings::close);
if(closingDownedSettings!=null)cleanup("downed configuration",closingDownedSettings::close);
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
if(closingFishingController!=null)cleanup("fishing",closingFishingController::close);
if(closingFishing!=null)cleanup("fishing persistence",closingFishing::close);
if (closingDungeonController != null) cleanup("dungeons", closingDungeonController::close);
if (closingDungeons != null) cleanup("dungeon persistence", closingDungeons::close);
if (closingDownedController != null) cleanup("downed controller", closingDownedController::close);
if (closingDowned != null) cleanup("downed persistence", closingDowned::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
@@ -866,6 +1178,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
closingRareSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareSettings.drained().toCompletableFuture(),
closingDownedSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingDownedSettings.drained().toCompletableFuture(),
closingRareInventory==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareInventory.drained().toCompletableFuture(),
closingRareMerchants==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMerchants.drained().toCompletableFuture(),
closingRareMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingRareMarket.drained().toCompletableFuture(),
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartyEconomySettings.drained().toCompletableFuture(),
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -874,12 +1191,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
: closingAdventureQuestSettings.drained().toCompletableFuture(),
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdventureQuests.drained().toCompletableFuture(),
closingFishing==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingFishing.drained().toCompletableFuture(),
closingDungeons == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingDungeons.drained().toCompletableFuture(),
closingDowned == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingDowned.drained().toCompletableFuture(),
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFireballSettings.drained().toCompletableFuture(),
closingGreatSwordSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingGreatSwordSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
@@ -971,21 +1293,83 @@ public final class FantasyStickPlugin extends JavaPlugin {
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
if(closingRareMarketPulse!=null)cleanup("rare market schedule",closingRareMarketPulse::cancel);
if(closingRareMenuPulse!=null)cleanup("rare menu schedule",closingRareMenuPulse::cancel);
if(closingRareShopMenus!=null)cleanup("rare shop menus",closingRareShopMenus::close);
if(closingRareMerchantController!=null)cleanup("rare merchant entities",closingRareMerchantController::close);
if(closingRareMarket!=null)cleanup("rare market",closingRareMarket::close);
if(closingRareInventory!=null){if(closingRareMarket==null)cleanup("rare inventory",closingRareInventory::close);else closingRareMarket.drained().whenComplete((ignored,failure)->cleanup("rare inventory",closingRareInventory::close));}
if(closingRareMerchants!=null)cleanup("rare merchants",closingRareMerchants::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 (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingNutritionAuditEvents != null) cleanup("nutrition event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
nutritionAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Nutrition owner drain failed", failure);
closingNutritionAuditEvents.close();
return null;
}).thenCompose(ignored -> closingNutritionAuditEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingSwitchDelaySettings.drained();
switchConfigAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Switch setting drain failed", failure);
closingSwitchConfigEvents.close();
return null;
}).thenCompose(ignored -> closingSwitchConfigEvents.drained());
});
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
switchAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Switch exit drain failed", failure);
closingSwitchAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
});
if (closingEquipmentAuditEvents != null) cleanup("equipment event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
equipmentAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Equipment session drain failed", failure);
closingEquipmentAuditEvents.close();
return null;
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
});
if (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
closingSessionRecoveryAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
unregister(closingChat);
unregister(closingEventCapture);
unregister(closingMessageAuditEvents);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
unregister(closingTrainingDummyController);
unregister(closingPartyRegistrarMenus);
unregister(closingArmorerController);unregister(closingArmorShopMenus);
unregister(closingRareMerchantController);unregister(closingRareShopMenus);
unregister(closingAdventureQuestController);
unregister(closingFishingController);
unregister(closingDungeonController);
unregister(closingDownedController);
unregister(closingCombatExperience);
unregister(closingHealthSteal);
unregister(closingFireball);
@@ -999,6 +1383,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingAdminRevival);
unregister(closingCharacterRecovery);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingOutOfCombatRecovery);
@@ -1013,6 +1398,24 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
});
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
adminRevivalAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
closingAdminRevivalAuditEvents.close();
return null;
}).thenCompose(ignored -> closingAdminRevivalAuditEvents.drained());
});
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
identityAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
closingIdentityAuditEvents.close();
return null;
}).thenCompose(ignored -> closingIdentityAuditEvents.drained());
});
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
// Never await IO on the server thread or close receipts ahead of accepted preparations.
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
@@ -1244,15 +1647,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
}
private java.util.Map<String, String> greatSwordConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (var key : games.dmg.fantasystick.persistence.GreatSwordSettings.keys())
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.GreatSwordSettings.defaultValue(key)));
games.dmg.fantasystick.persistence.GreatSwordSettings.parse(values);
return java.util.Map.copyOf(values);
}
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
@@ -1266,8 +1667,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
default -> notice.message();
};
player.sendMessage(message);
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);
var particles = particleDelivery;
if (particles == null) return;
if (notice.code().equals("DEFEAT")) particles.world(org.bukkit.Particle.DUST,
player.getLocation().add(0, 1, 0), 12, 0.3, 0.4, 0.3, 0,
new org.bukkit.Particle.DustOptions(org.bukkit.Color.RED, 1));
else particles.viewer(player, 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();
@@ -31,7 +31,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
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
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || !pointCost.equals(BigInteger.ONE)
|| 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()))) {
@@ -43,11 +43,42 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Set<String> eventOwnedAbilities = new HashSet<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private games.dmg.fantasystick.events.AbilityCastEventBridge castEvents;
private boolean closed;
public void attachCastEvents(games.dmg.fantasystick.events.AbilityCastEventBridge events) {
if (closed || castEvents != null) throw new IllegalStateException("Cast events already attached or engine closed");
castEvents = Objects.requireNonNull(events);
}
/** An ability owner opts in only after its actual effect-specific event path is delivered. */
public void enableCastEventsFor(String abilityId) {
if (closed || registry.find(Objects.requireNonNull(abilityId)).isEmpty())
throw new IllegalArgumentException("Unknown or closed ability event owner");
eventOwnedAbilities.add(abilityId);
}
public boolean eventsEnabledFor(String abilityId) { return eventOwnedAbilities.contains(abilityId); }
private void resolve(games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
String outcome, String reason) {
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|| definition.id().equals("shield_bash") || definition.id().equals("hex")
|| definition.id().equals("inspire"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -149,11 +180,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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())) {
refuseCast(input, record, definition, "not_learned_or_assigned");
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)) {
refuseCast(input, record, definition, "cooldown_active");
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
@@ -164,11 +197,15 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
}
if (!accepted) {
refuseCast(input, record, definition, "target_ineligible");
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; }
if (pending.putIfAbsent(key, token) != null) {
refuseCast(input, record, definition, "save_in_progress");
input.player().sendMessage("That cast is still being saved."); return;
}
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
@@ -176,22 +213,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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);
}
});
var audit = castEvents == null || !eventOwnedAbilities.contains(definition.id())
? null : castEvents.begin(input, record, definition);
try {
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, audit, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); resolve(audit, "failed", "scheduler_rejected");
report(key.character(), key.ability(), rejected);
}
});
} catch (RuntimeException failure) {
pending.remove(key, token); resolve(audit, "failed", "save_rejected");
report(key.character(), key.ability(), failure);
}
}
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;
Key key, Object token, long generation, games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token) || closed) { resolve(audit, "interrupted", "session_or_engine_closed"); return; }
if (failure != null) {
resolve(audit, "failed", "save_failed");
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
@@ -199,6 +245,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
resolve(audit, "refused", mutation.status().name().toLowerCase(java.util.Locale.ROOT));
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
@@ -208,23 +255,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
resolve(audit, "interrupted", "session_publication_changed");
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())) {
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
resolve(audit, "interrupted", "eligibility_changed");
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
var runtime = new Runtime(key, input, definition, generation, audit,
deferNativeCast ? saved : null, readyAt);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
runtime.publishCast();
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
runtime.publishCast();
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
@@ -293,12 +349,42 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
private final CharacterRecord deferredCastRecord;
private final Instant deferredDeadline;
private final Map<String, Object> castEvidence = new HashMap<>();
private boolean castPublished;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Map<UUID, Map<String, Object>> observedEndEvidence = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
private boolean finished;
private boolean closing;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterRecord deferredCastRecord, Instant deferredDeadline) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
this.audit = audit; this.deferredCastRecord = deferredCastRecord; this.deferredDeadline = deferredDeadline;
}
void publishCast() {
if (deferredCastRecord == null || castPublished) return;
castPublished = true;
if (castEvents != null) castEvents.cast(audit, deferredCastRecord, deferredDeadline, definition,
Map.copyOf(castEvidence));
}
@Override public void castEvidence(Map<String, Object> evidence) {
if (!castPublished && !finished && deferredCastRecord != null)
castEvidence.putAll(Objects.requireNonNull(evidence));
}
@Override public void resolutionEvidence(Map<String, Object> evidence) {
if (!finished) {
var combined = new java.util.LinkedHashMap<>(resolutionEvidence);
combined.putAll(Objects.requireNonNull(evidence));
resolutionEvidence = Map.copyOf(combined);
}
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@@ -306,7 +392,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
return !closed && !finished && !closing && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
}
@@ -361,12 +447,110 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public java.time.Instant now() { return clock.instant(); }
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
UUID application = castEvents == null ? UUID.randomUUID() : castEvents.effectApplied(audit, effectId, target, evidence);
observedEffects.put(application, effectId);
return application;
}
@Override public void acceptedDamage(LivingEntity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (castEvents == null || audit == null) return;
try {
var details = new java.util.LinkedHashMap<String, Object>(evidence);
if (target instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
details.put("target_character_id", active.character().toString());
details.put("target_session_id", active.session().toString());
});
castEvents.acceptedDamage(audit, target, requested, actual, direct, details);
} catch (RuntimeException ignored) { /* Recording cannot change accepted native damage. */ }
}
@Override public void acceptedHeal(EquipmentSessions.Active aided, Player target,
CharacterRecord saved, double requested, double actual,
double before, double after, double maximum) {
if (castEvents != null && audit != null)
castEvents.healed(audit, target, aided.character(), aided.session(), saved,
requested, actual, before, after, maximum);
}
@Override public void acceptedCleanse(EquipmentSessions.Active aided, Player target, List<String> removed) {
if (castEvents != null && audit != null)
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
}
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
double incoming, double actual, double remaining,
UUID bondApplication) {
if (castEvents != null && audit != null)
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
incoming, actual, remaining, bondApplication);
}
@Override public void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker,
Entity protectedTarget, double incoming, double remaining, double actual) {
if (castEvents == null || audit == null) return;
var evidence = new java.util.LinkedHashMap<String, Object>();
if (protectedTarget instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
evidence.put("protected_character_id", active.character().toString());
evidence.put("protected_session_id", active.session().toString());
});
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
incoming, remaining, actual, evidence);
}
@Override public void acceptedAbsorption(UUID application, Player target, Entity attacker,
double incoming, double before, double after,
double consumed, double remaining,
org.bukkit.Location targetAtHit, org.bukkit.Location attackerAtHit) {
if (castEvents == null || audit == null) return;
try {
var aided = sessions.active(target.getUniqueId()).filter(sessions::current).orElse(null);
castEvents.absorbed(audit, application, target, aided, attacker, incoming,
before, after, consumed, remaining, targetAtHit, attackerAtHit);
} catch (RuntimeException ignored) { /* Recording cannot change native absorption or support. */ }
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
if (castEvents != null && audit != null)
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
effectEnded(application, reason, observedEndEvidence.getOrDefault(application, Map.of()));
}
@Override public void effectEndEvidence(UUID application, Map<String, Object> evidence) {
if (observedEffects.containsKey(application))
observedEndEvidence.put(application, Map.copyOf(Objects.requireNonNull(evidence)));
}
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
var effectId = observedEffects.remove(application);
observedEndEvidence.remove(application);
if (effectId != null && castEvents != null)
castEvents.effectEnded(audit, application, effectId, reason, evidence);
}
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
"healed", "cleansed", "protected", "boosted", "obstructed", "replaced").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
}
@Override public void finish() {
if (outcome == null) outcome = "completed";
close();
}
@Override public void close() {
if (finished) return;
finished = true;
if (finished || closing) return;
closing = true;
publishCast();
if (outcome == null) outcome = "interrupted";
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); }
finished = true;
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
if (castEvents != null) castEvents.resolved(audit, outcome,
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
@@ -5,6 +5,8 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
@@ -15,12 +17,58 @@ public interface AbilityExecution extends AutoCloseable {
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
/** Native-thread clock shared with cast and effect audit timestamps. */
default java.time.Instant now() { return java.time.Instant.now(); }
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
/** An owner reports an effect only after its actual native application. */
UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence);
/** Only the effect owner calls this after positive, uncancelled native damage is accepted. */
void acceptedDamage(LivingEntity target, double requested, double actual, Entity direct,
Map<String, Object> evidence);
/** Only after the aided record and native health have both been acknowledged. */
void acceptedHeal(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target,
games.dmg.fantasystick.persistence.CharacterRecord saved,
double requested, double actual, double before, double after, double maximum);
/** Record only harmful native effects actually removed from this target. */
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target, List<String> removedEffects);
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player ally, Entity attacker, double incoming,
double actual, double remaining, UUID bondApplication);
/** Record only the winning owned outgoing reduction after final uncancelled native damage. */
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
double incoming, double remaining, double actual) { }
/** Record only observed native consumption of this execution's owned temporary health. */
default void acceptedAbsorption(UUID application, org.bukkit.entity.Player target, Entity attacker,
double incoming, double before, double after, double consumed,
double remaining, org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit) { }
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
void effectEnded(UUID applicationId, String reason);
/** End an observed effect with actual native cleanup/restoration evidence. */
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
effectEnded(applicationId, reason);
}
/** Capture remaining owned effect state during resource cleanup for the terminal effect record. */
default void effectEndEvidence(UUID applicationId, Map<String, Object> evidence) { }
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
default void castEvidence(Map<String, Object> evidence) { }
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
default void resolutionEvidence(Map<String, Object> evidence) { }
/** An owner may specify a truthful terminal outcome before finishing. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
void finish();
@Override void close();
}
@@ -10,6 +10,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -48,19 +49,33 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested;
final BigDecimal defined; final UUID chainId; final int ordinal;
final UUID previousId; final Map<String, Object> previousLocation;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
UUID chainId, int ordinal, LivingEntity previous) {
this.execution = execution; this.target = target; this.requested = requested;
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
previousId = previous == null ? null : previous.getUniqueId();
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
}
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private Attempt damaging;
private boolean closed;
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -98,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player();
var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) return;
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial;
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
int maximum = Math.min(64, 1 + captured.additionalTargets());
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
strike(execution, caster, current, defined); visual(caster, current);
if (strike + 1 == maximum) break;
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
attempted++; if (attempt.accepted) accepted++;
visual(caster, current);
if (hop + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
previous = current;
current = next(caster, current, visited, captured.jumpRange());
}
var outcome = new LinkedHashMap<String, Object>();
outcome.put("chain_id", chainId.toString());
outcome.put("initial_target_id", initial.getUniqueId().toString());
outcome.put("initial_target_location", locationData(initial.getLocation()));
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
}
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
double amount = finite(definiteNonnegative(defined));
var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; }
return attempt;
}
private LivingEntity selectInitial(ControlInput input, double range) {
@@ -174,17 +203,46 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_kind", "owned_chain_strike");
evidence.put("chain_id", attempt.chainId.toString());
evidence.put("hop_ordinal", attempt.ordinal);
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
if (attempt.previousId != null) {
evidence.put("previous_target_id", attempt.previousId.toString());
evidence.put("previous_target_location", attempt.previousLocation);
}
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), evidence);
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static void visual(Player caster, LivingEntity target) {
/** Exclude only this in-flight native hop from the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
particles.world(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
} catch (RuntimeException ignored) { }
}
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
final java.util.function.Consumer<Accepted> acceptedObservation; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.Consumer<Accepted> acceptedObservation) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
this.acceptedObservation = acceptedObservation;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
@@ -32,7 +35,8 @@ public final class CombatMitigation implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
public record Accepted(EntityDamageByEntityEvent event, double incoming, double remaining, double prevented) { }
private record Prevention(Grant grant, double incoming, double reduced) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
@@ -42,10 +46,23 @@ public final class CombatMitigation implements Listener, AutoCloseable {
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
return grant(execution, player, percent, current, null);
}
/** Legacy owner observation; keep existing prevention consumers unchanged. */
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> observation) {
return grantAccepted(execution, player, percent, current,
observation == null ? null : accepted -> observation.accept(accepted.event(), accepted.prevented()));
}
/** An owner may observe the selected, positive native prevention and its original hit value. */
public AutoCloseable grantAccepted(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.Consumer<Accepted> acceptedObservation) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent, current);
var grant = new Grant(execution, player, percent, current, acceptedObservation);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@@ -66,8 +83,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
if (original > reduced) pending.put(event, new Prevention(strongest, original, reduced));
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -75,7 +91,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
double remaining = event.getDamage();
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(),
BigDecimal.valueOf(actual)); }
catch (RuntimeException ignored) { /* Feedback cannot interrupt an accepted native hit or its audit. */ }
if (prevention.grant.acceptedObservation != null) try {
prevention.grant.acceptedObservation.accept(new Accepted((EntityDamageByEntityEvent) event,
prevention.incoming, remaining, actual));
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
}
@Override public void close() {
@@ -44,6 +44,7 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
@@ -55,6 +56,8 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requestedDamage;
boolean accepted;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -68,7 +71,12 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public Fireball(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Fireball(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -117,9 +125,15 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "range", "effective_settings", settingsData(current))); execution.finish();
});
execution.later(current.lifetime(), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "lifetime", "effective_settings", settingsData(current))); execution.finish();
});
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -135,14 +149,44 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
state.requestedDamage = nativeDamage;
damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
String outcome = state.accepted ? "hit" : event.isCancelled() ? "cancelled" : "miss";
try {
if (outcome.equals("miss") && (closed || !state.execution.stillCurrent())) outcome = "interrupted";
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("projectile_id", state.projectile.getUniqueId().toString());
evidence.put("direct_entity_id", state.projectile.getUniqueId().toString());
evidence.put("effective_settings", settingsData(state.settings));
var impact = event.getEntity().getLocation();
if (impact != null && impact.getWorld() != null) evidence.put("target_location", locationData(impact));
if (event.getHitEntity() != null) evidence.put("target_entity_id", event.getHitEntity().getUniqueId().toString());
state.execution.resolve(outcome, evidence);
} catch (RuntimeException auditFailure) {
state.execution.resolve(outcome);
} finally {
state.execution.finish();
}
}
}
private static Map<String, Object> settingsData(Settings settings) {
return Map.of("base-damage", settings.baseDamage().toString(),
"cooldown-seconds", seconds(settings.cooldown()).toPlainString(),
"range", Double.toString(settings.range()),
"lifetime", seconds(settings.lifetime()).toPlainString(),
"projectile-speed", Double.toString(settings.speedPerTick()));
}
private static Map<String, Object> locationData(Location location) {
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(),
"y", location.getY(), "z", location.getZ());
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
@@ -156,16 +200,25 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (state != null && damaging != state) event.setCancelled(true);
}
/** One native application belongs to this projectile, including a fantasy-player target. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return false;
var causing = event.getDamageSource().getCausingEntity();
return causing == null || causing == state.execution.input().player();
}
@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;
if (state == null || state.accepted || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || !ownsAcceptedDamage(event)) return;
state.accepted = true;
state.execution.acceptedDamage(state.target, state.requestedDamage, event.getFinalDamage(),
state.projectile, Map.of("damage_kind", "owned_direct_projectile"));
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@@ -176,7 +229,9 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
for (var state : List.copyOf(projectiles.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
projectiles.clear(); damaging = null;
}
}
@@ -50,48 +50,78 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
private static final class SlowLease implements AutoCloseable {
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
final AbilityExecution execution; UUID application;
final long started = System.nanoTime(); boolean owned; boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
this.execution = execution;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
boolean apply() {
owned = target.addPotionEffect(applied);
if (owned) try {
var evidence = new LinkedHashMap<String, Object>();
evidence.put("amplifier", applied.getAmplifier());
evidence.put("duration_ticks", applied.getDuration());
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
if (previous != null) {
evidence.put("replaced_amplifier", previous.getAmplifier());
evidence.put("replaced_duration_ticks", previous.getDuration());
}
application = execution.effectApplied("frost_nova_slowness", target, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
return owned;
}
@Override public void close() { close("interrupted"); }
void close(String reason) {
if (closed) return; closed = true; if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
|| current.getDuration() > expected + 2) {
if (application != null) execution.effectEnded(application, "superseded");
return;
}
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
if (application != null) execution.effectEnded(application, reason);
}
}
private final class LeaseGroup implements AutoCloseable {
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
String endReason = "interrupted"; boolean closed;
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
@Override public void close() {
if (closed) return; closed = true; active.remove(execution, this);
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
}
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
private Attempt damaging;
private boolean closed;
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this(settings, sessions, parties, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.parties = Objects.requireNonNull(parties); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -128,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
int affected = 0;
for (var target : candidates(source, origin, captured.radius())) {
int affected = 0, accepted = 0;
var candidates = candidates(source, origin, captured.radius());
for (var target : candidates) {
if (affected == 64 || !execution.stillCurrent()) break;
if (!eligible(execution, target, origin, captured.radius())) continue;
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
try { execution.damage(target, amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
if (!attempt.accepted) continue;
accepted++;
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
int ticks = Math.toIntExact(Math.max(1,
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
captured.slownessAmplifier(), false, true, true));
captured.slownessAmplifier(), false, true, true), execution);
if (lease.apply()) group.leases.add(lease);
}
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
try { particles.world(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
var result = new LinkedHashMap<String, Object>();
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
result.put("radius", captured.radius()); result.put("requested_amount", amount);
result.put("burst_location", locationData(origin));
execution.resolve(accepted > 0 ? "hit" : "miss", result);
if (group.leases.isEmpty()) execution.finish();
else execution.later(captured.slownessDuration(), execution::finish);
else execution.later(captured.slownessDuration(), () -> {
group.endReason = "expired"; execution.finish();
});
}
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
@@ -183,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
if (!ownsAcceptedDamage(event) || attempt.accepted
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
/** The per-target native hit belongs to this cast, not the generic damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
var source = attempt.execution.input().player();
var causing = event.getDamageSource().getCausingEntity();
var direct = event.getDamageSource().getDirectEntity();
return (causing == null || causing == source) && (direct == null || direct == source);
}
private static Map<String, Object> locationData(Location origin) {
var world = origin.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
}
private static boolean unobstructed(Location origin, LivingEntity target) {
var world = origin.getWorld(); if (world == null) return false;
var location = target.getLocation(); if (location.getWorld() != world) return false;
@@ -6,6 +6,7 @@ import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -58,7 +59,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requested;
boolean accepted;
boolean nativeCancelled;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -74,13 +77,33 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
private final LivingEntity target;
private final PotionEffect previous;
private final PotionEffect applied;
private final AbilityExecution execution;
private final long started = System.nanoTime();
private UUID application;
private String endReason = "interrupted";
private boolean owned;
private boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
this.execution = execution;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
boolean apply() {
owned = target.addPotionEffect(applied);
if (owned) try {
var evidence = new LinkedHashMap<String, Object>();
evidence.put("amplifier", applied.getAmplifier());
evidence.put("duration_ticks", applied.getDuration());
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
evidence.put("prior_layer", previous == null ? "none" : "replaced");
if (previous != null) {
evidence.put("prior_amplifier", previous.getAmplifier());
evidence.put("prior_duration_ticks", previous.getDuration());
}
application = execution.effectApplied("frost_shard_slowness", target, evidence);
} catch (RuntimeException ignored) { /* Native effect authority is independent of auditing. */ }
return owned;
}
void expire() { endReason = "expired"; close(); }
@Override public void close() {
if (closed) return;
closed = true;
@@ -88,22 +111,39 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
if (current == null) {
end(endReason.equals("expired") ? "expired" : "native_layer_missing", "none"); return;
}
if (current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2 || current.getDuration() < expected - 2) {
end("superseded", "preserved_newer"); return;
}
target.removePotionEffect(PotionEffectType.SLOWNESS);
String restored = "none";
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
if (remaining > 0)
restored = target.addPotionEffect(previous.withDuration(remaining)) ? "prior" : "refused";
}
end(endReason, restored);
}
private void end(String reason, String restored) {
if (application != null) execution.effectEnded(application, reason, Map.of("restored_layer", restored));
}
}
private volatile Settings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public FrostShard(Settings settings) {
this(settings, games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public FrostShard(Settings settings, games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
@@ -124,6 +164,18 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
private static Settings captured(AbilityDefinition definition) {
var values = definition.parameters();
return new Settings(values.get("base-damage").toBigIntegerExact(), definition.cooldown(),
values.get("range").doubleValue(), duration(values.get("lifetime")),
values.get("projectile-speed").doubleValue(), values.get("slowness-amplifier").intValueExact(),
duration(values.get("slowness-duration")));
}
private static Duration duration(BigDecimal seconds) {
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(4), intelligence);
}
@@ -140,7 +192,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var current = captured(execution.definition());
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
@@ -155,13 +207,16 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
Runnable expire = () -> {
if (projectiles.containsKey(projectile.getUniqueId())) {
try { execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Audit capture cannot retain an expired Arrow. */ }
execution.finish();
}
};
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
execution.later(current.lifetime(), expire);
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -177,25 +232,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
if (state == null) return;
boolean retainExecution = false;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
boolean currentSession = !closed && state.execution.stillCurrent();
if (!currentSession || event.isCancelled()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
Map.of("projectile_id", state.projectile.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Audit capture cannot retain an invalid hit. */ }
return;
}
var current = state.settings;
BigInteger amount = damage(current.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
state.target = target; state.requested = nativeDamage; damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
"target_entity_id", target.getUniqueId().toString(),
"impact_location", locationData(state.projectile.getLocation()))); }
catch (RuntimeException ignored) { /* Capturing evidence cannot prevent native Slowness. */ }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
current.slownessAmplifier(), false, true, true));
current.slownessAmplifier(), false, true, true), state.execution);
if (lease.apply()) {
state.execution.own(lease);
state.execution.later(current.slownessDuration(), state.execution::finish);
state.execution.later(current.slownessDuration(), () -> {
lease.expire(); state.execution.finish();
});
retainExecution = true;
}
}
@@ -211,18 +279,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
if (state == null || state.accepted || event.getEntity() != state.target) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
if (event.isCancelled()) { state.nativeCancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
state.accepted = true;
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.projectile,
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
"impact_location", locationData(state.projectile.getLocation()))); }
catch (RuntimeException ignored) { /* An audit failure cannot remove an accepted combat contribution. */ }
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
return direct == state.projectile || event instanceof EntityDamageByEntityEvent by
&& by.getDamager() == state.projectile;
}
private static Map<String, Object> locationData(Location location) {
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
}
private static String level(int amplifier) {
return switch (amplifier + 1) {
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
@@ -33,18 +33,25 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
final double requested;
final BigDecimal full;
final float charge;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
boolean cancelled;
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal full, float charge) {
this.execution = execution; this.target = target;
this.requested = requested; this.full = full; this.charge = charge;
}
}
private final Settings settings;
private Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
@@ -81,8 +88,11 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
BigInteger strength = execution.character().stats().get(Stat.STR);
BigInteger baseDamage = execution.definition().parameters().get("base-damage").toBigIntegerExact();
float charge = execution.input().attackStrength();
BigDecimal full = damage(baseDamage, strength, 1.0f);
BigDecimal amount = damage(baseDamage, strength, charge);
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
@@ -91,23 +101,40 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
var attempt = new Attempt(execution, target, nativeDamage, full, charge);
damaging = attempt;
try { execution.damage(target, nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
try {
execution.damage(target, nativeDamage, source);
execution.resolve(attempt.reported ? "hit" : attempt.cancelled ? "cancelled" : "miss",
Map.of("requested_amount", BigDecimal.valueOf(nativeDamage).toPlainString(),
"full_amount", full.toPlainString(), "charge", Float.toString(charge)));
} finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
/** The generic incoming-player adapter must yield to this contained strike's effect owner. */
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
}
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target) return false;
var source = event.getDamageSource();
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
}
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
if (attempt == null || attempt.reported || !matches(attempt, event)) return;
if (event.isCancelled()) { attempt.cancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
attempt.execution.input().player(), Map.of("charge", Float.toString(attempt.charge),
"full_amount", attempt.full.toPlainString(),
"scaled_requested_amount", BigDecimal.valueOf(attempt.requested).toPlainString()));
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
final Player ally;
final EquipmentSessions.Active aided;
final Settings settings;
UUID application;
String endReason = "interrupted";
boolean closed;
State(AbilityExecution execution, Player guardian, Player ally,
EquipmentSessions.Active aided, Settings settings) {
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
closed = true;
byGuardian.remove(guardian.getUniqueId(), this);
byAlly.remove(ally.getUniqueId(), this);
if (application != null) execution.effectEnded(application, endReason);
}
}
private static final class NativeTransfer {
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
BigDecimal accepted = BigDecimal.ZERO;
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
this.requested = requested; this.available = available;
}
}
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Damage damage;
private final boolean nativeDamageMode;
private final Consumer<Support> supports;
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
private int transferring;
private NativeTransfer nativeTransfer;
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
this.settings = Objects.requireNonNull(settings);
this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.damage = damage == null ? this::nativeDamage : damage;
this.nativeDamageMode = damage == null;
this.damage = nativeDamageMode ? this::nativeDamage : damage;
this.supports = Objects.requireNonNull(supports);
}
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
var state = new State(execution, guardian, ally, aided, captured);
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
execution.own(state); execution.later(captured.duration(), execution::finish);
execution.own(state);
state.application = execution.effectApplied("guardian_bond", ally,
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
"range", Double.toString(captured.range()),
"duration_seconds", seconds(captured.duration())));
execution.later(captured.duration(), () -> {
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
if (!Double.isFinite(remaining)) return;
event.setDamage(remaining);
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
if (!nativeDamageMode) {
// Injected test adapters return their final accepted result directly.
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
}
}
/** Only an uncancelled original hit can claim actual prevention. */
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedPrevention(EntityDamageByEntityEvent event) {
var reduction = pendingReductions.remove(event);
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
if (actual <= 0) return;
var state = reduction.state();
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
reduction.incoming(), actual, event.getFinalDamage(), state.application);
if (nativeDamageMode) {
var amount = BigDecimal.valueOf(actual);
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
supports.accept(new Support(reduction.attacker().getUniqueId(),
state.execution.input().session().character(), state.aided, amount));
}
}
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
requested, BigDecimal.valueOf(health));
nativeTransfer = transfer;
try { guardian.damage(amount, attacker); }
finally { nativeTransfer = null; }
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
if (transfer.owner != null && transfer.accepted.signum() > 0)
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
transfer.accepted.doubleValue(), transfer.attacker,
Map.of("damage_kind", "guardian_bond_redirection"));
}
/** The guardian's accepted native hit has one richer bond-owned damage record. */
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
var transfer = nativeTransfer;
return transfer != null && event instanceof EntityDamageByEntityEvent hit
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
}
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
}
private void breakBond(State state) {
state.close(); state.execution.finish();
state.endReason = "eligibility_changed";
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
}
private static boolean eligible(Player guardian, Player ally, double range) {
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
@Override public void close() {
if (closed) return; closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
byGuardian.clear(); byAlly.clear();
for (var state : List.copyOf(byGuardian.values())) {
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
}
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
}
}
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition());
if (!inRange(execution.input(), target, current.range()) || execution.trainingDummy(target)) {
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
execution.finish(); return;
}
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
});
var state = new State(execution, target, grant);
var previous = hexes.put(target.getUniqueId(), state);
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
execution.own(state);
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
evidence.put("duration_seconds", seconds(current.duration()));
var activatedAt = execution.now();
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
application.set(execution.effectApplied("hex", target, evidence));
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
}
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var 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()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@Override public void activate(AbilityExecution execution) {
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
var s=from(execution.definition());
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
}
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
},remaining->{
var id=application.get();
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
});
execution.own(grant);
var evidence=new LinkedHashMap<String,Object>();
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("duration_seconds",seconds(s.duration()));
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
temporaryHealth.currentSession(target).ifPresent(active->{
evidence.put("target_character_id",active.character().toString());
evidence.put("target_session_id",active.session().toString());
});
application.set(execution.effectApplied("inspire",target,evidence));
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
final Settings settings;
final BigDecimal damage;
LivingEntity target;
double requested;
boolean accepted;
boolean nativeCancelled;
boolean finished;
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
execution.later(shot.lifetime(), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
Runnable expire = () -> {
if (arrows.containsKey(arrow.getUniqueId())) {
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
execution.finish();
}
};
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
execution.later(shot.lifetime(), expire);
}
@EventHandler(priority = EventPriority.HIGHEST)
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
var state = arrows.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target)
boolean currentSession = !closed && state.execution.stillCurrent();
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|| !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
return;
}
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : state.damage.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
return;
}
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
state.target = target; state.requested = nativeDamage; damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
"target_entity_id", target.getUniqueId().toString(),
"impact_location", locationData(state.arrow.getLocation()))); }
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
} finally {
state.close(); state.execution.finish();
}
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
if (state == null || state.accepted || event.getEntity() != state.target) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.arrow)) return;
if (event.isCancelled()) { state.nativeCancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
state.accepted = true;
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
"impact_location", locationData(state.arrow.getLocation()))); }
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
&& by.getDamager() == state.arrow;
}
private static Map<String, Object> locationData(Location location) {
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
@@ -101,25 +101,31 @@ public final class MendingLight implements AbilityEffect {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
if (target == null) { execution.resolve("miss"); execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
if (attribute == null) { execution.resolve("miss"); execution.finish(); return; }
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
if (!Double.isFinite(proposed) || proposed <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
execution.resolve(event.isCancelled() ? "cancelled" : "miss"); execution.finish(); return;
}
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
} catch (RuntimeException rejected) { execution.resolve("failed"); execution.finish(); }
});
}
@@ -127,20 +133,34 @@ public final class MendingLight implements AbilityEffect {
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.resolve("failed");
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) {
execution.resolve("interrupted"); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) {
execution.resolve("interrupted"); return;
}
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue());
double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) {
execution.resolve("miss"); return;
}
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) { execution.resolve("miss"); return; }
execution.acceptedHeal(target.active(), target.player(), persisted, accepted, actual, current, after, maximum);
execution.resolve("healed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"target_character_id", target.active().character().toString(),
"actual_amount", BigDecimal.valueOf(actual).stripTrailingZeros().toPlainString()));
var amount = BigDecimal.valueOf(actual).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
@@ -39,23 +39,65 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private static final class Attempt {
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
boolean accepted;
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
}
}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
void step(){
if(closed||impacted)return;
if(!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
execution.finish();return;
}
elapsed++;
if(elapsed>=maximumTicks){impact();execution.finish();return;}
warningVisual();execution.later(Duration.ofMillis(50),this::step);
}
void impact(){
if(closed||impacted)return;impacted=true;
if(!groundCurrent()||!execution.stillCurrent()){
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
return;
}
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
for(var target:candidates){
if(attempted==64||!execution.stillCurrent())break;
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
try{execution.damage(target,amount,sourceDamage);}
finally{if(damaging==attempt)damaging=null;}
attempted++;if(attempt.accepted)accepted++;
}
var evidence=new LinkedHashMap<String,Object>();
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
evidence.put("impact_location",locationData(ground.origin()));
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this(settings,sessions,parties,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.particles=Objects.requireNonNull(particles);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
@@ -64,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),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 Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
@Override public void activate(AbilityExecution execution){
if(closed||!execution.stillCurrent())return;
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
new Warning(execution,target,captured).start();
}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
attempt.accepted=true;
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
}
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
public boolean ownsAcceptedDamage(EntityDamageEvent event){
var attempt=damaging;
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
var caster=attempt.execution.input().player();
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
return (causing==null||causing==caster)&&(direct==null||direct==caster);
}
private static Map<String,Object> locationData(Location location){
var world=location==null?null:location.getWorld();
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
"x",location.getX(),"y",location.getY(),"z",location.getZ());
}
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
if (!Double.isFinite(effective) || effective <= 0) return;
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
used.base, effective, event.getFinalDamage(), used.grant.percent);
var actual = BigDecimal.valueOf(effective);
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
@@ -25,24 +25,36 @@ import org.bukkit.projectiles.ProjectileSource;
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent;
final Consumer<Accepted> observation; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent, Consumer<Accepted> observation) {
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
this.observation = observation;
}
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
}
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
public record Accepted(LivingEntity debuffedAttacker, Entity recipient,
double incoming, double remaining, double prevented) { }
private record Prevention(Grant grant, Entity recipient, double incoming, double reduced) { }
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
private long sequences; private boolean closed;
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
public AutoCloseable grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
return grantObserved(execution, target, percent, null);
}
public AutoCloseable grantObserved(AbilityExecution execution, LivingEntity target, BigDecimal percent,
Consumer<Accepted> observation) {
Objects.requireNonNull(execution); Objects.requireNonNull(target); Objects.requireNonNull(percent);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant = new Grant(execution, target, percent, observation);
grants.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
public void reduce(EntityDamageByEntityEvent event){
@@ -53,20 +65,31 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),original,reduced));
}
@EventHandler(priority=EventPriority.MONITOR)
public void accepted(EntityDamageEvent event){
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
if(!(prevention.recipient instanceof Player player))return;
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !prevention.grant.execution.stillCurrent()) return;
double remaining = event.getDamage();
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
var amount = BigDecimal.valueOf(actual);
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, prevention.grant.target, amount); }
catch (RuntimeException ignored) { /* Feedback cannot interrupt accepted native prevention. */ }
if (prevention.grant.observation != null) try {
prevention.grant.observation.accept(new Accepted(prevention.grant.target,
prevention.recipient, prevention.incoming, remaining, actual));
} catch (RuntimeException ignored) { /* Audit cannot alter damage or rewards. */ }
if (!(prevention.recipient instanceof Player player)) return;
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,amount));
}
private static Entity attacker(EntityDamageByEntityEvent event){
@@ -72,16 +72,22 @@ public final class Purify implements AbilityEffect {
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
if (target == null) { execution.resolve("miss"); return; }
var removed = new java.util.ArrayList<String>();
int attempted = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
attempted++;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
if (target.player().getPotionEffect(type) == null)
removed.add(type.getKey().getKey().toUpperCase(java.util.Locale.ROOT));
}
if (removed == 0) return;
var amount = BigDecimal.valueOf(removed);
if (removed.isEmpty()) { execution.resolve(attempted == 0 ? "miss" : "cancelled"); return; }
execution.acceptedCleanse(target.active(), target.player(), List.copyOf(removed));
execution.resolve("cleansed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"removed_effects", List.copyOf(removed)));
var amount = BigDecimal.valueOf(removed.size());
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
@@ -41,11 +41,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
final Location origin;
final Vector horizontal;
final Settings settings;
final long started = System.nanoTime();
final long boundTicks;
Location lastObserved;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
State(AbilityExecution execution, Player player, Location origin, Vector horizontal,
Settings settings, long boundTicks) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
this.horizontal = horizontal; this.settings = settings; this.boundTicks = boundTicks;
}
void finish(String outcome, Location observed) {
lastObserved = observed == null ? null : observed.clone();
execution.resolve(outcome);
execution.finish();
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
@@ -57,6 +66,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
try {
var to = lastObserved == null ? player.getLocation() : lastObserved;
if (to != null) {
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("to_location", locationData(to));
evidence.put("elapsed_ms", Math.max(0, (System.nanoTime() - started) / 1_000_000L));
evidence.put("bound_ticks", boundTicks);
if (to.getWorld() != null && to.getWorld().equals(origin.getWorld())) {
double dx = to.getX() - origin.getX(), dz = to.getZ() - origin.getZ();
evidence.put("horizontal_distance", Math.hypot(dx, dz));
} else evidence.put("horizontal_distance_status", "not_comparable_cross_world");
execution.resolutionEvidence(evidence);
}
} catch (RuntimeException ignored) { /* Auditing cannot change native motion or cleanup. */ }
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
@@ -98,24 +121,41 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
var origin = player.getLocation().clone();
try { execution.castEvidence(Map.of("from_location", locationData(origin))); }
catch (RuntimeException ignored) { /* Capturing an origin cannot affect admission. */ }
if (!accepts(execution.input())) {
rejectedVelocity(execution, origin, "native_recheck_refused"); return;
}
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
Vector horizontal = direction.clone().multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
try { execution.castEvidence(Map.of("intended_direction", vectorData(direction),
"native_velocity_status", "pending_native_decision")); }
catch (RuntimeException ignored) { /* Capturing intent cannot affect native velocity. */ }
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
if (event.isCancelled() || !finite(event.getVelocity())) {
rejectedVelocity(execution, origin, "native_velocity_cancelled_or_invalid"); return;
}
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
if (acceptedHorizontal.lengthSquared() < 1.0e-12) {
rejectedVelocity(execution, origin, "native_velocity_without_horizontal_motion"); return;
}
try { execution.castEvidence(Map.of("accepted_velocity", vectorData(accepted),
"native_velocity_status", "accepted")); }
catch (RuntimeException ignored) { /* Capturing accepted velocity cannot affect movement. */ }
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
var state = new State(execution, player, origin, acceptedHorizontal, current, ticks + 1);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
if (previous != null) previous.finish("replaced", previous.player.getLocation());
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), () -> {
execution.resolve("completed", Map.of("reason", "elapsed_bound")); execution.finish();
});
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -123,18 +163,44 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
if (to == null || !state.execution.stillCurrent()) {
state.finish("interrupted", to); return;
}
state.lastObserved = to.clone();
if (to.getWorld() == null || !to.getWorld().equals(state.origin.getWorld())) {
state.finish("interrupted", to); return;
}
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
state.owned = false; state.finish("replaced", to); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()) {
state.finish("completed", to);
} else if (new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.finish("obstructed", to);
}
}
private static void rejectedVelocity(AbilityExecution execution, Location origin, String reason) {
try { execution.castEvidence(Map.of("native_velocity_status", "cancelled"));
execution.resolve("cancelled", Map.of("reason", reason,
"to_location", locationData(origin), "horizontal_distance", 0)); }
catch (RuntimeException ignored) { /* Native rejection is not contingent on audit capture. */ }
execution.finish();
}
private static Map<String, Object> vectorData(Vector vector) {
return Map.of("x", vector.getX(), "y", vector.getY(), "z", vector.getZ());
}
private static Map<String, Object> locationData(Location location) {
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
}
private static boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
final UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("speed_amplifier", area.settings.speedAmplifier());
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
execution.own(area);
area.application = execution.effectApplied("rallying_cry_area", null,
Map.of("radius", Double.toString(area.settings.radius()),
"duration_seconds", seconds(area.settings.duration())));
area.refresh(); execution.later(area.settings.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private void breakArea(Area area) {
area.endReason = "eligibility_changed"; area.close();
area.execution.resolve("interrupted"); area.execution.finish();
}
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var area : List.copyOf(areas.values())) {
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
}
areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
@@ -50,6 +50,11 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private final Consumer<Throwable> failures;
private final TailBooks tails = new TailBooks();
private final CharacterSheets sheets = new CharacterSheets();
private games.dmg.fantasystick.session.DownedController downed;
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.function.Supplier<Duration> downedProtection=()->Duration.ofSeconds(5);
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
private final Map<UUID, View> targets = new LinkedHashMap<>();
private boolean closed;
@@ -59,16 +64,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
final games.dmg.fantasystick.events.ReviveEventBridge.Cast cast;
boolean selecting;
boolean eventResolved;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;this.cast=cast;
}
void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}}
void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}}
void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}}
@Override public void close() {
if (finished) return;
finished = true;
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
inventory.clear();
if(!selecting)resolve("cancelled_or_expired","tail_selection_closed");
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
}
}
@@ -80,6 +91,15 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
}
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
}
public AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
@@ -100,15 +120,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
execution.finish(); return;
}
if(downed!=null&&downed.downed(target)){var episode=downedEpisode(target);if(episode==null){execution.finish();return;}var action=UUID.randomUUID();var caster=execution.character();var cooldown=caster.cooldowns().get("revive");var distance=execution.input().player().getLocation().distance(target.getLocation());var castAt=clock.instant();var decision=new games.dmg.fantasystick.session.DownedEpisode.Decision("revive",execution.input().player().getUniqueId(),execution.input().player().getName(),caster.id(),caster.name(),execution.input().session().session(),action,castAt,cooldown,distance,true);if(downedEvents!=null)downedEvents.abilityCast(episode,decision);downed.revive(target,downedProtection.get(),decision,status->{if(downedEvents!=null&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED&&status!=games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED_PENDING_RECOVERY)downedEvents.abilityResolved(episode,decision,status.name().toLowerCase(java.util.Locale.ROOT),clock.instant());execution.input().player().sendMessage(switch(status){case APPLIED->"Revive restored the downed character in place.";case APPLIED_PENDING_RECOVERY->"Revive restored the downed character; durable publication is pending recovery.";case EXPIRED->"Revive arrived after the downed countdown expired.";case STALE->"That downed episode was already resolved or changed.";case FAILED->"Downed Revive is pending recovery; no second rescue was attempted.";});execution.finish();});return;}
var cast=reviveEvents==null?null:reviveEvents.cast(execution,target);
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
if(cast!=null)reviveEvents.resolved(cast,"no_eligible_tail","tail_scan");execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
if (failure != null) { if(cast!=null)reviveEvents.failed(cast,"tail_inspection_failed");report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) {if(cast!=null)reviveEvents.resolved(cast,"stale","tail_inspection"); execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
@@ -119,13 +141,15 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
if(cast!=null)reviveEvents.resolved(cast,"expired_or_stale","tail_eligibility");execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
open(execution, target, eligible, now,cast);
}));
}
private games.dmg.fantasystick.session.DownedEpisode downedEpisode(Player target){return downed==null?null:downed.episode(target);}
private Player target(ControlInput input) {
Player caster = input.player();
Entity supplied = input.target();
@@ -165,7 +189,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now,games.dmg.fantasystick.events.ReviveEventBridge.Cast cast) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
@@ -178,7 +202,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
var view = new View(execution, target, inventory, Map.copyOf(choices),cast);
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
@@ -222,14 +246,14 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
view.resolve("stale","tail_confirmation");view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (failure != null) {view.fail("tail_recheck_failed"); report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.resolve("expired_or_stale","tail_confirmation");view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
@@ -240,17 +264,17 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.resolve("unsafe_capacity","tail_reservation");view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
candidate.slot(), source.getAmount(), destination);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|| !unchanged(view.target, candidate)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.execution.finish(); return;
}
deliver(view, dead, reservation);
@@ -270,7 +294,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
try {
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
false, dead.deathId(), dead.diedAt(), dead.sheetGeneration());
ItemStack sheet = sheets.create(provisional);
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
@@ -281,22 +305,22 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
}
view.target.updateInventory();
} catch (RuntimeException uncertain) {
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.execution.finish(); return;
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure);
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
view.execution.finish(); return;
}
if (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
} else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");}
view.execution.finish();
}));
}
@@ -340,7 +364,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
: source != null && source.getAmount() == reservation.sourceAmount() - 1
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
reservations.clear(reservation.character(), reservation.id()); return;
recoveryEvent(reservation,record,target);reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
@@ -357,7 +381,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id());
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
}
}));
}
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
UUID areaApplication; int pulseOrdinal;
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void start() {
states.add(this); execution.own(this);
areaApplication = execution.effectApplied("sanctuary_area", null,
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
reconcile(); visual(); schedule();
}
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
expired = true; endReason = "expired"; closeMitigation();
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
entry.getValue().close(); mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
}
void pulse() {
pulseOrdinal++;
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
@@ -137,15 +146,50 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
try { particles.world(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
for (var grant : List.copyOf(mitigation.values())) grant.close();
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
}
}
private final class Benefit implements AutoCloseable {
final State state; final Player player; final AutoCloseable grant;
UUID application; boolean closed;
Benefit(State state, Player player) {
this.state = state; this.player = player;
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var attacker = event.getDamageSource().getCausingEntity();
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
attacker = byEntity.getDamager();
if (active != null && attacker != null && amount > 0)
state.execution.acceptedPrevention(active, player, attacker,
event.getDamage() + amount, amount, event.getFinalDamage(), application);
});
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
}
@Override public void close() {
if (closed) return; closed = true;
try { grant.close(); } catch (Exception ignored) { }
if (application != null) state.execution.effectEnded(application,
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
}
}
@@ -153,18 +197,28 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private final Consumer<MendingLight.Support> supports;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this(settings, characters, sessions, parties, combatMitigation, nextTick, healingEvents, nativeLimit, supports,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
this.supports = Objects.requireNonNull(supports); this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
@@ -199,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) return;
state.execution.acceptedHeal(target.active(), target.player(), persisted,
accepted, actual, current, after, maximum);
state.execution.report(AbilityReport.Type.HEALING, target.player(),
BigDecimal.valueOf(restored).stripTrailingZeros());
BigDecimal.valueOf(actual).stripTrailingZeros());
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
@@ -230,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
} catch (RuntimeException invalid) { return false; }
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
@@ -80,17 +80,24 @@ public final class SecondWind implements AbilityEffect {
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
var player = execution.input().player();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
if (attribute == null) { execution.resolve("cancelled", Map.of("reason", "maximum_unavailable")); execution.finish(); return; }
double current = player.getHealth(); double maximum = attribute.getValue();
double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
execution.resolve("cancelled", Map.of("reason", "health_unavailable_or_full")); execution.finish(); return;
}
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
if (!Double.isFinite(proposed) || proposed <= 0) {
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
}
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(missing, event.getAmount());
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
execution.finish(); return;
}
var record = execution.character(); var expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
@@ -99,18 +106,33 @@ public final class SecondWind implements AbilityEffect {
nextTick.accept(() -> {
try {
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
? "health_save_unconfirmed" : "session_changed_before_healing"));
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double nativeBefore = player.getHealth();
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
player.setHealth(publish);
double restored = publish - nativeBefore;
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
player, BigDecimal.valueOf(restored).stripTrailingZeros());
try { player.setHealth(publish); }
catch (RuntimeException nativeFailure) {
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
throw nativeFailure;
}
double nativeAfter = player.getHealth();
double restored = nativeAfter - nativeBefore;
if (Double.isFinite(restored) && restored > 0) {
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
restored, nativeBefore, nativeAfter, maximum); }
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
execution.report(AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(restored).stripTrailingZeros());
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
@@ -19,6 +19,7 @@ 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.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
final AbilityExecution execution; final LivingEntity target; final Settings settings;
final double requested; final double healthBefore;
double finalDamage; boolean accepted; boolean cancelled;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
this.execution = execution; this.target = target; this.settings = settings;
this.requested = requested; this.healthBefore = healthBefore;
}
}
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
if (!inRange(player, target, current.range())) {
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
execution.finish(); return;
}
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
if (!Double.isFinite(amount) || amount <= 0) {
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
}
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
double beforeHealth;
try { beforeHealth = target.getHealth(); }
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
boolean nativeFailed = false;
try {
execution.damage(target, amount, source);
if (attempt.accepted) publishAccepted(attempt);
}
catch (RuntimeException failed) {
nativeFailed = true;
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
throw failed;
} finally {
if (damaging == attempt) damaging = null;
if (!attempt.accepted && !nativeFailed)
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
execution.finish();
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
}
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target) return false;
var source = event.getDamageSource();
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
if (event.isCancelled()) { attempt.cancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
attempt.finalDamage = event.getFinalDamage();
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
private void publishAccepted(Attempt attempt) {
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("stage", "native_damage_accepted");
try {
double afterHealth = attempt.target.getHealth();
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
}
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
attempt.execution.input().player(), evidence); }
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
String knockbackStatus = knockback(attempt);
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
"knockback_status", knockbackStatus));
}
private String knockback(Attempt attempt) {
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
try {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return "velocity_invalid";
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled()) return "native_velocity_cancelled";
if (!finite(event.getVelocity())) return "velocity_invalid";
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
try { attempt.execution.effectApplied("shield_bash_knockback", target,
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
"accepted_velocity_x", decimal(proposed.getX()),
"accepted_velocity_y", decimal(proposed.getY()),
"accepted_velocity_z", decimal(proposed.getZ()),
"status", "native_velocity_accepted")); }
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
return "native_velocity_accepted";
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
}
private static boolean inRange(Player player, LivingEntity target, double range) {
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
accepted -> {
try {
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
});
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
execution.own(state);
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
var activatedAt = execution.now();
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
execution.effectApplied("stone_skin", player, evidence);
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
}
private static Settings from(AbilityDefinition definition) {
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
for (var state : List.copyOf(protections.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
protections.clear();
}
}
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
double consumed,double remaining,org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
private final Map<UUID,List<Grant>> grants=new HashMap<>();
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
return grantObserved(execution,target,amount,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
return grantObserved(execution,target,amount,observation,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
Consumer<Accepted> observation,Consumer<Double> onEnd){
Objects.requireNonNull(execution);Objects.requireNonNull(target);
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
return grant;
}
public Optional<EquipmentSessions.Active> currentSession(Player target){
return sessions.active(target.getUniqueId()).filter(sessions::current);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
if(!Double.isFinite(before)||before<=0||owned<=0)return;
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
var aided=sessions.active(target.getUniqueId()).orElse(null);
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
pending.add(event);
try{nextTick.accept(()->{
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
finally{pending.remove(event);}
});}
catch(RuntimeException ignored){pending.remove(event);}
}
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
if(!Double.isFinite(consumed)||consumed<=0)return;
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
}
private static org.bukkit.Location auditLocation(Entity entity){
if(entity==null)return null;
try{var location=entity.getLocation();return location==null?null:location.clone();}
catch(RuntimeException unavailable){return null;}
}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
}
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
@@ -97,27 +97,43 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
try { particles.world(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
try {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
evidence.put("area_location", locationData(area.origin));
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -157,12 +173,19 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this(settings, sessions, parties, bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
this.particles = Objects.requireNonNull(particles);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
@@ -195,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
area.application = execution.effectApplied("war_banner_area", null,
Map.of("area_location", locationData(origin), "radius", captured.radius(),
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
area.refresh(); execution.later(captured.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
@@ -224,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static Map<String, Object> locationData(Location location) {
var world = location == null ? null : location.getWorld();
if (world == null || world.getUID() == null || world.getName() == null)
return Map.of("location_status", "unavailable");
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
"x", location.getX(), "y", location.getY(), "z", location.getZ());
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.adventure;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.command.CommandSender;
@@ -11,22 +12,33 @@ public final class AdventureQuestCommands {
private static final String PERMISSION="fantasystick.admin";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged.")));}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});}));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged.")));}catch(IllegalArgumentException invalid){usageSite(sender);}}
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});}));}catch(IllegalArgumentException invalid){usageSite(sender);}}
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
private static java.math.BigDecimal percent(String value){if(!value.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,6})?"))throw new IllegalArgumentException();var result=new java.math.BigDecimal(value);if(result.compareTo(java.math.BigDecimal.valueOf(100))>0)throw new IllegalArgumentException();return result;}
private static BigInteger positive(String value){var result=nonnegative(value);if(result.signum()==0)throw new IllegalArgumentException();return result;}
private static BigInteger nonnegative(String value){if(!value.matches("(?:0|[1-9][0-9]*)"))throw new IllegalArgumentException();var result=new BigInteger(value);if(result.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return result;}
private static int bounded(String value,int minimum,int maximum){var parsed=nonnegative(value);if(parsed.compareTo(BigInteger.valueOf(minimum))<0||parsed.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return parsed.intValueExact();}
private static List<String> fishingOptionCompletion(String[] args){String current=args[args.length-1];if(args.length>=2){String previous=args[args.length-2];if(previous.equals("--mode"))return matching(modeValues(),current);if(previous.equals("--radius"))return matching(List.of("4","16","64"),current);if(previous.equals("--chance"))return matching(List.of("0","20","100"),current);if(previous.equals("--required"))return matching(List.of("1","5","2147483647"),current);if(previous.equals("--xp")||previous.equals("--coins"))return matching(List.of("0","10","100","2147483647"),current);}var used=Arrays.stream(args).filter(value->value.startsWith("--")).collect(java.util.stream.Collectors.toSet());return matching(List.of("--chance","--coins","--item","--mode","--name","--radius","--required","--xp").stream().filter(value->!used.contains(value)).toList(),current);}
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
private record Placement(String name,AdventureQuestState.Arena arena){}
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
private static Placement placement(String[] args,int start){if(args.length==start)return new Placement("",null);if(Arrays.stream(args,start,args.length).anyMatch(value->value.startsWith("--")))return null;Integer first=whole(args[start]);if(first==null)return looksNumeric(args[start])||args[start].startsWith("--")?null:new Placement(join(args,start),null);if(args.length>start+1){Integer second=whole(args[start+1]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,start+2),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[start+1]))return null;}return first>=4&&first<=64?new Placement(join(args,start+1),AdventureQuestState.Arena.circle(first)):null;}
private static List<String> modeValues(){return Arrays.stream(AdventureQuestState.ParticipationMode.values()).map(AdventureQuestState.ParticipationMode::value).toList();}
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fstickadmin adventure-site <place <camp|champion> [--mode <mode>] [<radius>|<x-blocks> <z-blocks>] [name...]|place fishing --name <name> --item <item> --required <quantity> --chance <percent> --xp <xp> --coins <coins> [--radius <blocks>] [--mode <mode>]|fishing <site-uuid> [options]|mode <site-uuid> [<mode>]|remove <site-uuid>|teleport <site-uuid>|list>");}
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fstickadmin quest-giver <place [name...]|remove <giver-uuid>|list>");}
}
@@ -31,9 +31,11 @@ import org.bukkit.plugin.Plugin;
public final class AdventureQuestController implements Listener,AutoCloseable {
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,attemptKey,encounterKindKey,menuSiteKey,menuCatalogKey,menuPageKey,menuFishingKey;
private FishingController fishing;
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
@@ -41,52 +43,56 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this(plugin,directory,settings,sessions,parties,characters,progression,nextTick,clock,random,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);this.particles=Objects.requireNonNull(particles);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");attemptKey=new NamespacedKey(plugin,"adventure_attempt_id");encounterKindKey=new NamespacedKey(plugin,"adventure_encounter_kind");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");menuFishingKey=new NamespacedKey(plugin,"quest_fishing_turn_in");}
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!enemy(entity))return Optional.empty();var data=entity.getPersistentDataContainer();UUID site=site(entity),catalog=parse(data.get(catalogKey,PersistentDataType.STRING)),source=parse(data.get(attemptKey,PersistentDataType.STRING));String encoded=data.get(encounterKindKey,PersistentDataType.STRING);if(site==null||catalog==null||source==null||encoded==null)return Optional.empty();games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind kind;try{kind=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.valueOf(encoded);}catch(IllegalArgumentException invalid){return Optional.empty();}if(kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION&&kind!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP)return Optional.empty();var active=attempts.get(site);if(active!=null&&(!active.catalog.equals(catalog)||!active.id.equals(source)))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(kind,source));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&&section(giver.x())==chunk.getX()&&section(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or adventure controller closed");this.fishing=Objects.requireNonNull(fishing);}
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon (maximum)";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}player.openInventory(inventory);}));}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ItemStack(Material.ENDER_CHEST);var turLine truncated
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
private static void acceptanceLore(List<net.kyori.adventure.text.Component> lore,AdventureQuestState.ParticipationMode mode,Context context){if(mode!=AdventureQuestState.ParticipationMode.PARTY_REQUIRED)lore.add(net.kyori.adventure.text.Component.text("Left click: Accept individually"));if(mode!=AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY){if(context.partyId!=null&&context.leader)lore.add(net.kyori.adventure.text.Component.text("Right click: Accept for party"));else lore.add(net.kyori.adventure.text.Component.text("Party acceptance requires the active leader"));}}
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){fishing.open(player);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&&current.active.equals(context.active)&&current.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&&current.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party changed.":"Quest acceptance is pending safe cancellation; retry after recovery.")));return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+quest.expiresAt()+". Follow the Quest Compass.");}));}
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&&current.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)player.spawnParticle(Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.arena)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON||value.type()==AdventureQuestState.SiteType.FISHING)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.getPersistentDataContainer().set(attemptKey,PersistentDataType.STRING,attempt.id.toString());var kind=attempt.type==AdventureQuestState.SiteType.CHAMPION?games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CHAMPION:games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.CAMP;mob.getPersistentDataContainer().set(encounterKindKey,PersistentDataType.STRING,kind.name());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients);} }
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients,attempt);} }
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
private void award(AdventureQuestState.Quest quest,List<UUID> recipients,Attempt attempt){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);if((attempt.type==AdventureQuestState.SiteType.CHAMPION||attempt.type==AdventureQuestState.SiteType.CAMP)&&fishing!=null){var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);var site=directory.findSite(quest.site()).orElse(null);if(carrier==null||site==null){plugin.getLogger().warning("Champion trophy carrier unavailable for quest "+quest.id());return;}var trophyType=attempt.type==AdventureQuestState.SiteType.CHAMPION?FishingState.TrophyType.CHAMPION:FishingState.TrophyType.CAMP;UUID trophy=UUID.nameUUIDFromBytes((trophyType.name().toLowerCase(java.util.Locale.ROOT)+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));String trophyName=trophyType==FishingState.TrophyType.CHAMPION?"Daybreak Champion Head":"Spoils of "+site.displayName();var plan=new FishingState.Trophy(trophy,trophyType,carrier,quest.catalog(),quest.site(),quest.id(),attempt.id,trophyName,site.displayName(),rewards);fishing.earnTrophy(plan).whenComplete((earned,failure)->schedule(()->{if(failure!=null||!earned){plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion trophy persistence failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: its trophy is stored with the captured carrier.");}refreshTrackingPlayers();}));return;}var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
@@ -109,7 +115,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
private void refreshTracking(Player player){refreshTracking(player,false);}
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null||!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,arena(site));boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()||directory.snapshot().reset()!=null){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,site.type()==AdventureQuestState.SiteType.FISHING?AdventureQuestState.Arena.circle(quest.fishing().radius()):arena(site));if(site.type()==AdventureQuestState.SiteType.FISHING&&arrived){sessions.presentBook(active,book->navigation.fishingRod(book,quest,site));return;}if(!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
private static boolean inside(Location location,AdventureQuestState.Site site,AdventureQuestState.Arena arena){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&arena.contains(location.getX()-site.x(),location.getZ()-site.z());}
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
@@ -12,28 +12,40 @@ import org.bukkit.entity.EntityType;
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
public final class AdventureQuestDirectory implements AutoCloseable {
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, POLICY, CONFLICT, CLAIMED }
public enum ModeStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
public enum RerollStatus { APPLIED, STALE }
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
public record ModeResult(ModeStatus status,Optional<AdventureQuestState.Site> site) { }
public record FishingUpdateResult(FishingUpdateStatus status,Optional<AdventureQuestState.Site> site) { }
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}}
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}}
public record LegacyDungeonReward(Reward reward,UUID bossRoom,boolean defeated){public LegacyDungeonReward{Objects.requireNonNull(reward);Objects.requireNonNull(bossRoom);}}
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
private volatile AdventureQuestState snapshot;private volatile java.util.function.Function<AdventureQuestState.Site,Reward> dungeonRewards=ignored->new Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO);private volatile java.util.function.Predicate<AdventureQuestState.Site> dungeonAvailable=ignored->true;private volatile java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> dungeonSites=List::of;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
public AdventureQuestState snapshot(){return snapshot;}
public int defaultRadius(){return settings.get().radius();}
public void registerDungeonRewards(java.util.function.Function<AdventureQuestState.Site,Reward> rewards){dungeonRewards=Objects.requireNonNull(rewards);}
public CompletableFuture<Boolean> migrateLegacyDungeonRewards(java.util.function.Function<UUID,Optional<LegacyDungeonReward>> rewards){Objects.requireNonNull(rewards);return mutate(state->{boolean[] changed={false};var claims=new HashSet<>(state.characterClaims());var quests=new ArrayList<AdventureQuestState.Quest>();for(var quest:state.quests()){var migration=rewards.apply(quest.site());if(migration.isEmpty()||quest.fishing()!=null){quests.add(quest);continue;}var value=migration.orElseThrow();UUID source=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+value.bossRoom()+":"+quest.id()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var allocations=state.rewards().allocations().stream().filter(allocation->allocation.quest().equals(quest.id())&&allocation.source().equals(source)).toList();if(!allocations.isEmpty()){allocations.forEach(allocation->claims.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),allocation.character())));changed[0]=true;continue;}if(value.defeated()){quests.add(quest);continue;}var reward=value.reward();if(quest.rewardXp().equals(reward.xp())&&quest.rewardCoins().equals(reward.coins()))quests.add(quest);else{quests.add(new AdventureQuestState.Quest(quest.id(),quest.catalog(),quest.site(),quest.unitType(),quest.unit(),quest.snapshot(),quest.expiresAt(),reward.xp(),reward.coins(),quest.participationMode()));changed[0]=true;}}var migrated=new AdventureQuestState(state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),claims,state.rosters(),validTracking(state.tracking(),quests),state.rewards(),state.reset());return result(migrated,changed[0]);});}
public void registerDungeonAvailability(java.util.function.Predicate<AdventureQuestState.Site> available){dungeonAvailable=Objects.requireNonNull(available);}
public void registerDungeonSites(java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> sites){dungeonSites=Objects.requireNonNull(sites);}
public boolean available(AdventureQuestState.Site site){return site.type()!=AdventureQuestState.SiteType.DUNGEON||dungeonAvailable.test(site);}
public boolean offerOpen(UUID site){var catalog=snapshot.catalog();return catalog!=null&&catalog.sites().contains(site)&&!catalog.closed().contains(site);}
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);};}
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);case FISHING->new Reward(site.fishing().rewardXp(),site.fishing().rewardCoins());};}
public Optional<AdventureQuestState.Site> findSite(UUID id){return site(snapshot,id);}
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){return placeSite(type,name,world,x,y,z,arena,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena,AdventureQuestState.ParticipationMode mode){if(type==AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Use complete fishing definition");var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena,mode);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<AdventureQuestState.Site> placeFishingSite(String name,String itemName,UUID world,double x,double y,double z,int radius,java.math.BigDecimal chance,BigInteger required,BigInteger rewardXp,BigInteger rewardCoins,AdventureQuestState.ParticipationMode mode){var fishing=new AdventureQuestState.FishingDefinition(UUID.randomUUID(),itemName,required,chance,rewardXp,rewardCoins,radius);var value=new AdventureQuestState.Site(UUID.randomUUID(),AdventureQuestState.SiteType.FISHING,name,world,x,y,z,AdventureQuestState.Arena.circle(radius),mode,fishing);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
public CompletableFuture<FishingUpdateResult> updateFishing(UUID id,AdventureQuestState.FishingDefinition expected,AdventureQuestState.FishingDefinition requested){var current=findSite(id).orElse(null);if(current==null||current.type()!=AdventureQuestState.SiteType.FISHING)return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));if(!current.fishing().equals(expected))return CompletableFuture.completedFuture(new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));return updateFishingSite(id,current,current.name(),requested,current.participationMode());}
public CompletableFuture<FishingUpdateResult> updateFishingSite(UUID id,AdventureQuestState.Site expected,String name,AdventureQuestState.FishingDefinition requested,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expected);Objects.requireNonNull(requested);Objects.requireNonNull(mode);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()==AdventureQuestState.SiteType.FISHING).findFirst();if(found.isEmpty())return result(state,new FishingUpdateResult(FishingUpdateStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(!current.equals(expected))return result(state,new FishingUpdateResult(FishingUpdateStatus.STALE,Optional.of(current)));if(!current.fishing().itemId().equals(requested.itemId()))throw new IllegalArgumentException("Fishing item identity cannot change");var changed=new AdventureQuestState.Site(current.id(),current.type(),name,current.world(),current.x(),current.y(),current.z(),AdventureQuestState.Arena.circle(requested.radius()),mode,requested);if(changed.equals(current))return result(state,new FishingUpdateResult(FishingUpdateStatus.UNCHANGED,Optional.of(current)));var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new FishingUpdateResult(FishingUpdateStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<ModeResult> setSiteMode(UUID id,AdventureQuestState.ParticipationMode expected,AdventureQuestState.ParticipationMode requested){Objects.requireNonNull(expected);Objects.requireNonNull(requested);return mutate(state->{var found=state.sites().stream().filter(value->value.id().equals(id)&&value.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,new ModeResult(ModeStatus.NOT_FOUND,Optional.empty()));var current=found.orElseThrow();if(current.participationMode()!=expected)return result(state,new ModeResult(ModeStatus.STALE,Optional.of(current)));if(current.participationMode()==requested)return result(state,new ModeResult(ModeStatus.UNCHANGED,Optional.of(current)));var changed=new AdventureQuestState.Site(current.id(),current.type(),current.name(),current.world(),current.x(),current.y(),current.z(),current.arena(),requested,current.fishing());var sites=state.sites().stream().map(value->value.id().equals(id)?changed:value).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),new ModeResult(ModeStatus.APPLIED,Optional.of(changed)));});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
@@ -44,7 +56,8 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&&current.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){var offered=findSite(site).orElse(null);return offered==null?CompletableFuture.completedFuture(new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty())):accept(catalog,site,type,unit,members,offered.participationMode());}
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members,AdventureQuestState.ParticipationMode expectedMode){Objects.requireNonNull(expectedMode);var requestedMembers=Set.copyOf(members);return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));var offered=site(state,site).filter(this::available).orElse(null);if(!current.sites().contains(site)||current.closed().contains(site)||offered==null)return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));if(offered.participationMode()!=expectedMode)return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!offered.participationMode().permits(type))return result(state,new AcceptResult(Acceptance.POLICY,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=requestedMembers.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||!Collections.disjoint(q.snapshot(),requestedMembers));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,requestedMembers,current.expiresAt(),reward.xp(),reward.coins(),offered.participationMode(),offered.fishing()==null?null:offered.fishing().terms());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:requestedMembers)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
@@ -59,8 +72,10 @@ public final class AdventureQuestDirectory implements AutoCloseable {
public CompletableFuture<Boolean> bankAndComplete(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,true);}
private CompletableFuture<Boolean> bank(UUID questId,UUID source,Map<UUID,Reward> rewards,boolean complete){Objects.requireNonNull(source);var requested=Map.copyOf(rewards);return mutate(original->{var state=expire(original);var quest=state.quests().stream().filter(value->value.id().equals(questId)).findFirst().orElse(null);if(quest==null||!quest.snapshot().containsAll(requested.keySet())||complete&&requested.isEmpty())return result(state,false);var allocations=new ArrayList<>(state.rewards().allocations());for(var entry:requested.entrySet()){var reward=Objects.requireNonNull(entry.getValue());if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=rewardId(quest,source,entry.getKey());var existing=allocations.stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(existing!=null){if(!existing.xp().equals(reward.xp())||!existing.coins().equals(reward.coins()))throw new IllegalStateException("Conflicting adventure reward allocation");continue;}allocations.add(new AdventureQuestState.RewardAllocation(id,quest.catalog(),quest.site(),quest.id(),source,entry.getKey(),reward.xp(),reward.coins()));}var quests=state.quests();var units=state.unitClaims();var characters=state.characterClaims();if(complete){quests=state.quests().stream().filter(value->!value.id().equals(quest.id())).toList();var changedUnits=new HashSet<>(units);changedUnits.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));units=Set.copyOf(changedUnits);var changedCharacters=new HashSet<>(characters);for(UUID character:requested.keySet())changedCharacters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),character));characters=Set.copyOf(changedCharacters);}var ledger=new AdventureQuestState.RewardLedger(allocations,state.rewards().operations(),state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),quests,units,characters,state.rosters(),ledger),true);});}
public Reward unclaimed(UUID catalog,UUID site,UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
public Reward unclaimedAll(UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
public boolean rewardClaimed(UUID catalog,UUID site,UUID character){return snapshot.rewards().receipts().contains(new AdventureQuestState.RewardReceipt(catalog,site,character));}
public Optional<AdventureQuestState.RewardClaim> pendingClaim(UUID character){return snapshot.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();}
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginLegacyClaim(UUID character){return mutate(original->{var state=expire(original);var pending=state.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.character().equals(character)).sorted(Comparator.comparing(AdventureQuestState.RewardAllocation::id)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());var first=allocations.getFirst();UUID operation=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,first.catalog(),first.site(),character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
@@ -75,7 +90,7 @@ public final class AdventureQuestDirectory implements AutoCloseable {
private static AdventureQuestState copyTracking(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Set<AdventureQuestState.Tracking> tracking){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(tracking,quests),original.rewards(),original.reset());}
private static AdventureQuestState copyRewards(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.RewardLedger rewards){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),rewards,original.reset());}
private static AdventureQuestState copyReset(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.Reset reset){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),pendingRewards(original.rewards()),reset);}
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){var retainedIds=rewards.operations().stream().flatMap(value->value.allocations().stream()).collect(java.util.stream.Collectors.toSet());return new AdventureQuestState.RewardLedger(rewards.allocations().stream().filter(value->retainedIds.contains(value.id())).toList(),rewards.operations(),Set.of());}
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){return rewards;}
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
private record Result<T>(AdventureQuestState state,T value){}
@@ -3,6 +3,7 @@ package games.dmg.fantasystick.adventure;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Material;
@@ -11,10 +12,13 @@ import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.CompassMeta;
import org.bukkit.persistence.PersistentDataType;
/** Pure presentation of one accepted quest through the authentic bound book control. */
public final class AdventureQuestNavigation {
private static final String[] BEARINGS={"N","NE","E","SE","S","SW","W","NW"};
public static final org.bukkit.NamespacedKey FISHING_QUEST=new org.bukkit.NamespacedKey("fantasystick","fishing_quest");
public static final org.bukkit.NamespacedKey FISHING_SITE=new org.bukkit.NamespacedKey("fantasystick","fishing_site");
private final Server server;
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);}
@@ -42,6 +46,9 @@ public final class AdventureQuestNavigation {
return item;
}
public ItemStack fishingRod(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site){Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);if(quest.fishing()==null||site.type()!=AdventureQuestState.SiteType.FISHING)throw new IllegalArgumentException("Fishing quest required");var item=ordinaryBook.clone();item.setType(Material.FISHING_ROD);var meta=item.getItemMeta();meta.displayName(Component.text("Quest Fishing Rod"));meta.lore(List.of(Component.text(site.displayName()),Component.text(quest.fishing().itemName()+": "+quest.fishing().required()),Component.text("Right-click: Cast / reel"),Component.text("Sneak-right-click: Character book")));meta.setMaxStackSize(1);for(var enchantment:List.copyOf(meta.getEnchants().keySet()))meta.removeEnchant(enchantment);meta.getPersistentDataContainer().set(FISHING_QUEST,PersistentDataType.STRING,quest.id().toString());meta.getPersistentDataContainer().set(FISHING_SITE,PersistentDataType.STRING,site.id().toString());item.setItemMeta(meta);return item;}
public static UUID fishingQuest(ItemStack item){try{String value=item==null?null:item.getItemMeta().getPersistentDataContainer().get(FISHING_QUEST,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
public Optional<Component> guidance(Player player,AdventureQuestState.Site site,boolean arrived){
Objects.requireNonNull(player);Objects.requireNonNull(site);
if(arrived)return Optional.empty();
@@ -56,6 +63,6 @@ public final class AdventureQuestNavigation {
static long distance(Location viewer,AdventureQuestState.Site site){return Math.round(Math.hypot(site.x()-viewer.getX(),site.z()-viewer.getZ()));}
static String bearing(Location viewer,AdventureQuestState.Site site){double angle=Math.atan2(site.x()-viewer.getX(),-(site.z()-viewer.getZ()));int index=(int)Math.round(angle/(Math.PI/4));return BEARINGS[Math.floorMod(index,8)];}
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";};}
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";case FISHING->"Fishing site";};}
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
}
@@ -10,9 +10,24 @@ import org.bukkit.entity.EntityType;
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
public enum SiteType { CAMP, CHAMPION, DUNGEON }
public enum SiteType { CAMP, CHAMPION, DUNGEON, FISHING }
public enum UnitType { SOLO, PARTY }
public enum ParticipationMode {
INDIVIDUAL_ONLY("individual-only"), PARTY_AVAILABLE("party-available"), PARTY_REQUIRED("party-required");
private final String value;
ParticipationMode(String value){this.value=value;}
public String value(){return value;}
public static ParticipationMode parse(String value){return Arrays.stream(values()).filter(mode->mode.value.equals(value)).findFirst().orElseThrow(()->new IllegalArgumentException("Invalid participation mode"));}
public boolean permits(UnitType type){return switch(this){case INDIVIDUAL_ONLY->type==UnitType.SOLO;case PARTY_AVAILABLE->true;case PARTY_REQUIRED->type==UnitType.PARTY;};}
}
public enum ArenaShape { CIRCLE, RECTANGLE }
public record FishingTerms(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
public FishingTerms {Objects.requireNonNull(itemId);itemName=validName(itemName);Objects.requireNonNull(required);Objects.requireNonNull(chancePercent);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);chancePercent=chancePercent.stripTrailingZeros();if(itemName.isBlank()||required.signum()<=0||required.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||chancePercent.scale()>6||chancePercent.compareTo(java.math.BigDecimal.ZERO)<0||chancePercent.compareTo(java.math.BigDecimal.valueOf(100))>0||rewardXp.signum()<0||rewardCoins.signum()<0||rewardXp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||rewardCoins.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||radius<4||radius>64)throw new IllegalArgumentException("Invalid fishing terms");}
}
public record FishingDefinition(UUID itemId,String itemName,BigInteger required,java.math.BigDecimal chancePercent,BigInteger rewardXp,BigInteger rewardCoins,int radius) {
public FishingDefinition {new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);itemName=validName(itemName);chancePercent=chancePercent.stripTrailingZeros();}
public FishingTerms terms(){return new FishingTerms(itemId,itemName,required,chancePercent,rewardXp,rewardCoins,radius);}
}
public record Arena(ArenaShape shape,int xBlocks,int zBlocks) {
public Arena {
Objects.requireNonNull(shape);
@@ -26,10 +41,12 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode,FishingDefinition fishing) {
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);Objects.requireNonNull(participationMode);if((type==SiteType.FISHING)!=(fishing!=null)||fishing!=null&&(!Arena.circle(fishing.radius()).equals(arena)))throw new IllegalArgumentException("Invalid fishing site"); }
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena,ParticipationMode participationMode){this(id,type,name,world,x,y,z,arena,participationMode,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena){this(id,type,name,world,x,y,z,arena,ParticipationMode.PARTY_AVAILABLE,null);}
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null,ParticipationMode.PARTY_AVAILABLE,null);}
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";case FISHING->"Fishing Site";}:name;}
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
}
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
@@ -42,8 +59,10 @@ public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog ca
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
}
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode,FishingTerms fishing) {
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);Objects.requireNonNull(participationMode);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit)))||!participationMode.permits(unitType)||fishing!=null&&(!fishing.rewardXp().equals(rewardXp)||!fishing.rewardCoins().equals(rewardCoins)))throw new IllegalArgumentException("Invalid quest"); }
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins,ParticipationMode participationMode){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,participationMode,null);}
public Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins){this(id,catalog,site,unitType,unit,snapshot,expiresAt,rewardXp,rewardCoins,ParticipationMode.PARTY_AVAILABLE,null);}
}
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
@@ -0,0 +1,103 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.*;
import games.dmg.fantasystick.progression.*;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerFishEvent;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
/** Owned quest-rod fishing, virtual Inventory, recoverable submission and non-expiring claims. */
public final class FishingController implements Listener,AutoCloseable,AdventureResetParticipant {
public record Failure(UUID operation,UUID character,String phase,Throwable cause){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,UUID party,Set<UUID> members){}
private record HookClaim(UUID player,UUID session,UUID character,UUID quest,UUID hook,BigInteger sampledXp){}
private record Holder(UUID player,UUID session,UUID character,UUID nonce,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
private final Plugin plugin;private final AdventureQuestDirectory adventures;private final FishingDirectory fishing;private final AdventureQuestSettings settings;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final Consumer<Failure> failures;
private final NamespacedKey actionKey;private final Map<UUID,HookClaim> hooks=new HashMap<>();private final Map<UUID,UUID> menus=new HashMap<>();private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
public FishingController(Plugin plugin,AdventureQuestDirectory adventures,FishingDirectory fishing,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,Consumer<Failure> failures){this.plugin=Objects.requireNonNull(plugin);this.adventures=Objects.requireNonNull(adventures);this.fishing=Objects.requireNonNull(fishing);this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.failures=Objects.requireNonNull(failures);actionKey=new NamespacedKey(plugin,"fishing_menu_action");}
public void install(){for(var pending:fishing.pendingCatches())recoverCatch(pending);for(var trophy:fishing.snapshot().trophies())reconcileTrophy(trophy);for(var submission:fishing.pendingSubmissions())recoverSubmission(submission);for(var claim:fishing.snapshot().claims())recoverClaim(claim);pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);}
private void pulse(){if(closed)return;for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId())&&!nativeUse(player,player.getInventory().getItemInMainHand()))cleanup(player);}
@Override public java.util.concurrent.CompletionStage<Void> reset(AdventureQuestState.Reset reset){for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();return java.util.concurrent.CompletableFuture.completedFuture(null);}
public boolean nativeUse(Player player,ItemStack item){if(closed||adventures.snapshot().reset()!=null||AdventureQuestNavigation.fishingQuest(item)==null)return false;var selected=sessions.selectedControl(player,item).orElse(null);if(selected==null||selected.slot()!=0)return false;var context=context(player);var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);return quest!=null&&eligible(context,quest)&&quest.fishing()!=null&&quest.site().equals(site(item));}
public BigInteger quantity(UUID character,UUID item){return fishing.quantity(character,item);}
public java.util.concurrent.CompletionStage<Boolean> earnTrophy(FishingState.Trophy trophy){return fishing.prepareTrophy(trophy).thenCompose(status->{if(status==FishingDirectory.TrophyPrepareStatus.REDEEMED)return java.util.concurrent.CompletableFuture.completedFuture(true);var retained=fishing.trophy(trophy.id()).orElseThrow();return adventures.complete(retained.quest(),retained.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(retained.id())).thenApply(committed->committed!=FishingDirectory.TrophyCommitStatus.NOT_FOUND);});}
private void reconcileTrophy(FishingState.Trophy trophy){if(trophy.phase()==FishingState.TrophyPhase.EARNED)return;adventures.complete(trophy.quest(),trophy.rewards().keySet()).thenCompose(ignored->fishing.commitTrophy(trophy.id())).whenComplete((ignored,failure)->{if(failure!=null)report(trophy.id(),trophy.carrier(),"trophy-reconcile",failure);});}
public void open(Player player){open(player,0);}
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)
public void fish(PlayerFishEvent event){
if(closed)return;
var player=event.getPlayer();
var item=player.getInventory().getItemInMainHand();
if(!nativeUse(player,item))return;
var context=context(player);
var quest=context==null?null:adventures.findQuest(AdventureQuestNavigation.fishingQuest(item)).orElse(null);
if(context==null||quest==null||!eligible(context,quest))return;
switch(event.getState()){
case FISHING->{var hook=event.getHook();hooks.put(player.getUniqueId(),new HookClaim(player.getUniqueId(),context.active().session(),context.record().id(),quest.id(),hook.getUniqueId(),sampleXp()));}
case LURED,BITE->{} // Approach and bite are not reels; retain the original owned hook.
case CAUGHT_ENTITY->{event.setCancelled(true);hooks.remove(player.getUniqueId());event.getHook().remove();player.sendMessage("Quest Fishing Rods cannot pull entities.");}
case CAUGHT_FISH->{
var claim=hooks.get(player.getUniqueId());
if(claim==null||!claim.session().equals(context.active().session())||!claim.character().equals(context.record().id())||!claim.quest().equals(quest.id())||!claim.hook().equals(event.getHook().getUniqueId())){
refuseCatch(event,"Quest catch refused: the cast is no longer valid; cast again.");return;
}
hooks.remove(player.getUniqueId(),claim); // A late callback for another hook must not consume the current cast.
if(!(event.getCaught() instanceof Item caught)){
refuseCatch(event,"Quest catch refused: no native fish item was caught.");return;
}
if(!inside(player.getLocation(),quest)||!inside(event.getHook().getLocation(),quest)){
refuseCatch(event,"Quest catch refused: keep yourself and the bobber inside the fishing area.");return;
}
event.setCancelled(true);event.setExpToDrop(0);caught.remove();event.getHook().remove();
BigInteger xp=claim.sampledXp();boolean awarded=random.nextDouble(100d)<quest.fishing().chancePercent().doubleValue();
UUID operation=UUID.nameUUIDFromBytes((quest.id()+":"+claim.hook()).getBytes(StandardCharsets.UTF_8));
var plan=new FishingState.Catch(operation,context.record().id(),quest.id(),quest.site(),quest.fishing().itemId(),quest.fishing().itemName(),xp,awarded);
fishing.prepareCatch(plan).whenComplete((status,failure)->{if(failure!=null){report(plan,"prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.APPLIED||status==FishingDirectory.PrepareStatus.EXISTING)recoverCatch(plan);});
}
case IN_GROUND,FAILED_ATTEMPT,REEL_IN->hooks.remove(player.getUniqueId());
}
}
private void refuseCatch(PlayerFishEvent event,String reason){event.setCancelled(true);event.setExpToDrop(0);if(event.getCaught()!=null)event.getCaught().remove();event.getPlayer().sendMessage(reason);}
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){cleanup(event.getPlayer());}
@EventHandler(priority=EventPriority.MONITOR)public void changed(org.bukkit.event.player.PlayerItemHeldEvent event){cleanup(event.getPlayer());}
@EventHandler(priority=EventPriority.MONITOR)public void opened(org.bukkit.event.inventory.InventoryOpenEvent event){if(event.getPlayer() instanceof Player player&&hooks.containsKey(player.getUniqueId()))cleanup(player);}
@EventHandler(priority=EventPriority.MONITOR)public void sneakBook(org.bukkit.event.player.PlayerInteractEvent event){if(event.getPlayer().isSneaking()&&AdventureQuestNavigation.fishingQuest(event.getItem())!=null)cleanup(event.getPlayer());}
private void cleanup(Player player){hooks.remove(player.getUniqueId());try{var hook=player.getFishHook();if(hook!=null)hook.remove();}catch(RuntimeException ignored){}}
private void recoverCatch(FishingState.Catch plan){characters.find(plan.character()).whenComplete((record,failure)->{if(failure!=null||record.isEmpty()){if(failure!=null)report(plan,"load",failure);return;}awardCatch(plan,record.orElseThrow());});}
private void awardCatch(FishingState.Catch plan,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),plan.id(),plan.xp(),BigInteger.ZERO,progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(plan,"award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardCatch(plan,award.record().orElseThrow());return;}if(award.record().isEmpty()){return;}var saved=award.record().orElseThrow();fishing.completeCatch(plan.id()).whenComplete((status,completeFailure)->{if(completeFailure!=null){report(plan,"complete",completeFailure);return;}schedule(()->{var active=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(active!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null&&status==FishingDirectory.CompleteStatus.APPLIED){String result=plan.itemAwarded()?"Caught "+plan.itemName()+".":"The catch yielded no quest item.";String xp=award.status()==CharacterStorage.CombatAwardStatus.AT_CAP?" Fantasy XP remained at the level cap.":award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?" Its Fantasy XP was already recorded.":" Earned "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" Fantasy XP.";player.sendMessage(result+xp);}});});});}
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player);});}
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
private void commitSubmission(FishingState.Submission plan,Player player){fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player);});});});});}
private void redeemTrophy(Player player,Context context,String encoded){UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){player.sendMessage("Trophy redemption refused: invalid authority.");return;}fishing.redeemTrophy(trophy,context.record().id()).whenComplete((status,failure)->schedule(()->{if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}if(status==FishingDirectory.TrophyRedeemStatus.APPLIED)player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");open(player);}));}
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
private void claim(Player player,Context context){fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record());});}
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value));});}
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardClaim(player,active,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
private AdventureQuestState.Quest fishingQuest(Context context){if(adventures.snapshot().reset()!=null)return null;return adventures.activeFor(context.record().id(),context.party()).filter(value->value.fishing()!=null&&eligible(context,value)).orElse(null);}
private boolean eligible(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return quest.unit().equals(context.record().id());return context.party()!=null&&quest.unit().equals(context.party())&&quest.snapshot().contains(context.record().id())&&context.members().contains(context.record().id());}
private List<UUID> beneficiaries(Context context,AdventureQuestState.Quest quest){if(quest.unitType()==AdventureQuestState.UnitType.SOLO)return List.of(context.record().id());var party=parties.partyOfCharacter(context.record().id()).filter(value->value.id().equals(quest.unit())).orElse(null);return party==null?List.of():quest.snapshot().stream().filter(party.characters()::contains).distinct().sorted().toList();}
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return new Context(active,record,party==null?null:party.id(),party==null?Set.of(record.id()):Set.copyOf(party.characters()));}
private boolean inside(Location location,AdventureQuestState.Quest quest){var site=adventures.findSite(quest.site()).orElse(null);return site!=null&&site.type()==AdventureQuestState.SiteType.FISHING&&location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&AdventureQuestState.Arena.circle(quest.fishing().radius()).contains(location.getX()-site.x(),location.getZ()-site.z());}
private BigInteger sampleXp(){var rules=settings.rules();long minimum=rules.fishingCatchXpMin().longValueExact(),maximum=rules.fishingCatchXpMax().longValueExact();return BigInteger.valueOf(minimum+(maximum==minimum?0:random.nextLong(maximum-minimum+1)));}
private UUID site(ItemStack item){try{String value=item.getItemMeta().getPersistentDataContainer().get(AdventureQuestNavigation.FISHING_SITE,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(RuntimeException invalid){return null;}}
private ItemStack item(Material material,String name,String action,List<String> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.lore(lore.stream().map(net.kyori.adventure.text.Component::text).toList());if(action!=null)meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);item.setItemMeta(meta);return item;}
private void report(FishingState.Catch plan,String phase,Throwable failure){report(plan.id(),plan.character(),phase,failure);}
private void report(UUID operation,UUID character,String phase,Throwable failure){try{failures.accept(new Failure(operation,character,phase,failure));}catch(RuntimeException ignored){}}
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
@Override public void close(){closed=true;if(pulseTask!=null)pulseTask.cancel();for(var player:plugin.getServer().getOnlinePlayers())if(hooks.containsKey(player.getUniqueId()))cleanup(player);hooks.clear();menus.clear();}
}
@@ -0,0 +1,56 @@
package games.dmg.fantasystick.adventure;
import games.dmg.fantasystick.persistence.YamlFishingRepository;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
/** Serialized save-before-publication authority for fishing stock, submissions and earned shares. */
public final class FishingDirectory implements AutoCloseable {
public enum PrepareStatus { APPLIED, EXISTING, DUPLICATE, INSUFFICIENT, COMPLETED }
public enum CompleteStatus { APPLIED, DUPLICATE, NOT_FOUND }
public enum CommitStatus { APPLIED, DUPLICATE, INSUFFICIENT, NOT_FOUND }
public enum TrophyPrepareStatus { APPLIED, EXISTING, REDEEMED }
public enum TrophyRedeemStatus { APPLIED, REDEEMED, NOT_FOUND, WRONG_CARRIER }
public enum TrophyCommitStatus { APPLIED, DUPLICATE, NOT_FOUND }
private final YamlFishingRepository repository;
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-fishing-persistence"));
private volatile FishingState snapshot;private volatile java.util.function.Function<FishingState.ItemStack,String> labels=FishingState.ItemStack::name;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public FishingDirectory(YamlFishingRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
public FishingState snapshot(){return snapshot;}
public void registerLabels(java.util.function.Function<FishingState.ItemStack,String> labels){this.labels=Objects.requireNonNull(labels);}
public List<FishingState.ItemStack> inventory(UUID character){var resolver=labels;return snapshot.inventory().stream().filter(value->value.character().equals(character)).map(value->{String name=resolver.apply(value);return name.equals(value.name())?value:new FishingState.ItemStack(value.character(),value.item(),value.site(),name,value.amount());}).sorted(Comparator.comparing(FishingState.ItemStack::name).thenComparing(FishingState.ItemStack::item)).toList();}
public BigInteger quantity(UUID character,UUID item){return snapshot.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
public Optional<FishingState.Trophy> trophy(UUID id){return snapshot.trophies().stream().filter(value->value.id().equals(id)).findFirst();}
public List<FishingState.Trophy> trophies(UUID carrier){return snapshot.trophies().stream().filter(value->value.phase()==FishingState.TrophyPhase.EARNED&&value.carrier().equals(carrier)).sorted(Comparator.comparing(FishingState.Trophy::name).thenComparing(FishingState.Trophy::id)).toList();}
public CompletableFuture<TrophyPrepareStatus> prepareTrophy(FishingState.Trophy requested){Objects.requireNonNull(requested);return mutate(state->{if(state.trophyReceipts().contains(requested.id()))return result(state,TrophyPrepareStatus.REDEEMED);var existing=state.trophies().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameTrophyCompletion(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting adventure trophy");return result(state,TrophyPrepareStatus.EXISTING);}var trophies=new ArrayList<>(state.trophies());trophies.add(requested);return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyPrepareStatus.APPLIED);});}
public CompletableFuture<TrophyCommitStatus> commitTrophy(UUID id){return mutate(state->{var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null)return result(state,TrophyCommitStatus.NOT_FOUND);if(trophy.phase()==FishingState.TrophyPhase.EARNED)return result(state,TrophyCommitStatus.DUPLICATE);var trophies=state.trophies().stream().map(value->value.id().equals(id)?value.earned():value).toList();return result(copyTrophies(state,trophies,state.trophyReceipts(),state.allocations()),TrophyCommitStatus.APPLIED);});}
public CompletableFuture<TrophyRedeemStatus> redeemTrophy(UUID id,UUID carrier){Objects.requireNonNull(id);Objects.requireNonNull(carrier);return mutate(state->{if(state.trophyReceipts().contains(id))return result(state,TrophyRedeemStatus.REDEEMED);var trophy=state.trophies().stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(trophy==null||trophy.phase()!=FishingState.TrophyPhase.EARNED)return result(state,TrophyRedeemStatus.NOT_FOUND);if(!trophy.carrier().equals(carrier))return result(state,TrophyRedeemStatus.WRONG_CARRIER);var allocations=new ArrayList<>(state.allocations());for(var entry:trophy.rewards().entrySet()){var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID allocation=trophyAllocationId(trophy.id(),entry.getKey());if(allocations.stream().noneMatch(value->value.id().equals(allocation)))allocations.add(new FishingState.Allocation(allocation,trophy.id(),trophy.catalog(),trophy.site(),entry.getKey(),reward.xp(),reward.coins()));}var receipts=new HashSet<>(state.trophyReceipts());receipts.add(id);return result(copyTrophies(state,state.trophies().stream().filter(value->!value.id().equals(id)).toList(),receipts,allocations),TrophyRedeemStatus.APPLIED);});}
public List<FishingState.Catch> pendingCatches(){return snapshot.catches();}
public CompletableFuture<PrepareStatus> prepareCatch(FishingState.Catch requested){Objects.requireNonNull(requested);return mutate(state->{if(state.catchReceipts().contains(requested.id()))return result(state,PrepareStatus.DUPLICATE);var existing=state.catches().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!existing.orElseThrow().equals(requested))throw new IllegalStateException("Conflicting fishing catch");return result(state,PrepareStatus.EXISTING);}var catches=new ArrayList<>(state.catches());catches.add(requested);return result(copy(state,state.inventory(),catches,state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
public CompletableFuture<CompleteStatus> completeCatch(UUID operation){return mutate(state->{if(state.catchReceipts().contains(operation))return result(state,CompleteStatus.DUPLICATE);var caught=state.catches().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(caught==null)return result(state,CompleteStatus.NOT_FOUND);var inventory=new ArrayList<>(state.inventory());if(caught.itemAwarded()){var existing=inventory.stream().filter(value->value.character().equals(caught.character())&&value.item().equals(caught.item())).findFirst().orElse(null);if(existing==null)inventory.add(new FishingState.ItemStack(caught.character(),caught.item(),caught.site(),caught.itemName(),BigInteger.ONE));else{var updated=new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),caught.itemName(),existing.amount().add(BigInteger.ONE));inventory.replaceAll(value->value==existing?updated:value);}}var receipts=new HashSet<>(state.catchReceipts());receipts.add(operation);var catches=state.catches().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,inventory,catches,receipts,state.submissions(),state.completions(),state.allocations(),state.claims()),CompleteStatus.APPLIED);});}
public CompletableFuture<PrepareStatus> prepareSubmission(FishingState.Submission requested){Objects.requireNonNull(requested);if(requested.phase()!=FishingState.SubmissionPhase.PREPARED)throw new IllegalArgumentException("Submission must begin prepared");return mutate(state->{var existing=state.submissions().stream().filter(value->value.id().equals(requested.id())).findFirst();if(existing.isPresent()){if(!sameSubmission(existing.orElseThrow(),requested))throw new IllegalStateException("Conflicting fishing submission");return result(state,PrepareStatus.EXISTING);}if(state.submissions().stream().anyMatch(value->value.quest().equals(requested.quest())))return result(state,PrepareStatus.COMPLETED);boolean completed=requested.rewards().keySet().stream().anyMatch(character->state.completions().contains(new FishingState.Completion(requested.catalog(),requested.site(),character)));if(completed)return result(state,PrepareStatus.COMPLETED);if(quantity(state,requested.submitter(),requested.item()).compareTo(requested.quantity())<0)return result(state,PrepareStatus.INSUFFICIENT);var submissions=new ArrayList<>(state.submissions());submissions.add(requested);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),submissions,state.completions(),state.allocations(),state.claims()),PrepareStatus.APPLIED);});}
public CompletableFuture<CommitStatus> commitSubmission(UUID operation){return mutate(state->{var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(submission==null)return result(state,CommitStatus.NOT_FOUND);if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)return result(state,CommitStatus.DUPLICATE);if(quantity(state,submission.submitter(),submission.item()).compareTo(submission.quantity())<0)return result(state,CommitStatus.INSUFFICIENT);var inventory=new ArrayList<>(state.inventory());var existing=inventory.stream().filter(value->value.character().equals(submission.submitter())&&value.item().equals(submission.item())).findFirst().orElseThrow();var remaining=existing.amount().subtract(submission.quantity());if(remaining.signum()==0)inventory.remove(existing);else inventory.replaceAll(value->value==existing?new FishingState.ItemStack(existing.character(),existing.item(),existing.site(),existing.name(),remaining):value);var completions=new HashSet<>(state.completions());var allocations=new ArrayList<>(state.allocations());for(var entry:submission.rewards().entrySet()){completions.add(new FishingState.Completion(submission.catalog(),submission.site(),entry.getKey()));var reward=entry.getValue();if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=allocationId(submission.id(),entry.getKey());allocations.add(new FishingState.Allocation(id,submission.id(),submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins()));}var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),CommitStatus.APPLIED);});}
public CompletableFuture<Boolean> finishSubmission(UUID operation){return mutate(state->{var found=state.submissions().stream().filter(value->value.id().equals(operation)&&value.phase()==FishingState.SubmissionPhase.COMMITTED).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions().stream().filter(value->!value.id().equals(operation)).toList(),state.completions(),state.allocations(),state.claims()),true);});}
public List<FishingState.Submission> pendingSubmissions(){return snapshot.submissions();}
public boolean completed(UUID catalog,UUID site,UUID character){return snapshot.completions().contains(new FishingState.Completion(catalog,site,character));}
public FishingState.Reward unclaimed(UUID character){return snapshot.allocations().stream().filter(value->value.character().equals(character)).map(FishingState.Allocation::reward).reduce(FishingState.Reward.empty(),FishingState.Reward::add);}
public Optional<FishingState.Claim> pendingClaim(UUID character){return snapshot.claims().stream().filter(value->value.character().equals(character)).findFirst();}
public CompletableFuture<Optional<FishingState.Claim>> beginClaim(UUID character){return mutate(state->{var existing=state.claims().stream().filter(value->value.character().equals(character)).findFirst();if(existing.isPresent())return result(state,existing);var allocations=state.allocations().stream().filter(value->value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());var ids=allocations.stream().map(FishingState.Allocation::id).collect(java.util.stream.Collectors.toSet());BigInteger xp=allocations.stream().map(FishingState.Allocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(FishingState.Allocation::coins).reduce(BigInteger.ZERO,BigInteger::add);UUID id=UUID.nameUUIDFromBytes((character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(StandardCharsets.UTF_8));var claim=new FishingState.Claim(id,character,ids,xp,coins);var claims=new ArrayList<>(state.claims());claims.add(claim);return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),state.allocations(),claims),Optional.of(claim));});}
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.claims().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var claims=state.claims().stream().filter(value->!value.id().equals(operation)).toList();return result(copy(state,state.inventory(),state.catches(),state.catchReceipts(),state.submissions(),state.completions(),allocations,claims),true);});}
private static boolean sameTrophyCompletion(FishingState.Trophy left,FishingState.Trophy right){return left.id().equals(right.id())&&left.type()==right.type()&&left.carrier().equals(right.carrier())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.quest().equals(right.quest())&&left.name().equals(right.name())&&left.origin().equals(right.origin())&&left.rewards().equals(right.rewards());}
private static boolean sameSubmission(FishingState.Submission left,FishingState.Submission right){return left.id().equals(right.id())&&left.quest().equals(right.quest())&&left.catalog().equals(right.catalog())&&left.site().equals(right.site())&&left.submitter().equals(right.submitter())&&left.item().equals(right.item())&&left.quantity().equals(right.quantity())&&left.rewards().equals(right.rewards());}
private static BigInteger quantity(FishingState state,UUID character,UUID item){return state.inventory().stream().filter(value->value.character().equals(character)&&value.item().equals(item)).map(FishingState.ItemStack::amount).findFirst().orElse(BigInteger.ZERO);}
private static UUID allocationId(UUID submission,UUID character){return UUID.nameUUIDFromBytes(("fishing:"+submission+":"+character).getBytes(StandardCharsets.UTF_8));}
private static UUID trophyAllocationId(UUID trophy,UUID character){return UUID.nameUUIDFromBytes(("trophy:"+trophy+":"+character).getBytes(StandardCharsets.UTF_8));}
private static FishingState copy(FishingState original,List<FishingState.ItemStack> inventory,List<FishingState.Catch> catches,Set<UUID> receipts,List<FishingState.Submission> submissions,Set<FishingState.Completion> completions,List<FishingState.Allocation> allocations,List<FishingState.Claim> claims){return new FishingState(inventory,original.trophies(),original.trophyReceipts(),catches,receipts,submissions,completions,allocations,claims);}
private static FishingState copyTrophies(FishingState original,List<FishingState.Trophy> trophies,Set<UUID> receipts,List<FishingState.Allocation> allocations){return new FishingState(original.inventory(),trophies,receipts,original.catches(),original.catchReceipts(),original.submissions(),original.completions(),allocations,original.claims());}
private record Result<T>(FishingState state,T value){}
private static <T>Result<T> result(FishingState state,T value){return new Result<>(state,value);}
private <T>CompletableFuture<T> mutate(java.util.function.Function<FishingState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Fishing directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,59 @@
package games.dmg.fantasystick.adventure;
import java.math.BigInteger;
import java.util.*;
/** Durable character-owned fishing stock, recoverable operations and non-expiring reward shares. */
public record FishingState(List<ItemStack> inventory,List<Trophy> trophies,Set<UUID> trophyReceipts,
List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,
Set<Completion> completions,List<Allocation> allocations,List<Claim> claims) {
public record Reward(BigInteger xp,BigInteger coins) {
public Reward {Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid fishing reward");}
public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}
public Reward add(Reward other){return new Reward(xp.add(other.xp),coins.add(other.coins));}
}
public record ItemStack(UUID character,UUID item,UUID site,String name,BigInteger amount) {
public ItemStack {Objects.requireNonNull(character);Objects.requireNonNull(item);Objects.requireNonNull(site);name=validName(name);Objects.requireNonNull(amount);if(name.isBlank()||amount.signum()<=0)throw new IllegalArgumentException("Invalid fishing item stack");}
}
public enum TrophyType { CHAMPION, CAMP, DUNGEON }
public enum TrophyPhase { PREPARED, EARNED }
public record Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards,TrophyPhase phase) {
public Trophy {Objects.requireNonNull(id);Objects.requireNonNull(type);Objects.requireNonNull(carrier);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);name=validName(name);origin=validName(origin);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(name.isBlank()||origin.isBlank()||rewards.isEmpty()||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid adventure trophy");}
public Trophy(UUID id,TrophyType type,UUID carrier,UUID catalog,UUID site,UUID quest,UUID source,String name,String origin,Map<UUID,Reward> rewards){this(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.PREPARED);}
public Reward total(){return rewards.values().stream().reduce(Reward.empty(),Reward::add);}
public Trophy earned(){return phase==TrophyPhase.EARNED?this:new Trophy(id,type,carrier,catalog,site,quest,source,name,origin,rewards,TrophyPhase.EARNED);}
}
public record Catch(UUID id,UUID character,UUID quest,UUID site,UUID item,String itemName,BigInteger xp,boolean itemAwarded) {
public Catch {Objects.requireNonNull(id);Objects.requireNonNull(character);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(item);itemName=validName(itemName);Objects.requireNonNull(xp);if(itemName.isBlank()||xp.signum()<0||xp.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException("Invalid fishing catch");}
}
public enum SubmissionPhase { PREPARED, COMMITTED }
public record Submission(UUID id,UUID quest,UUID catalog,UUID site,UUID submitter,UUID item,BigInteger quantity,
Map<UUID,Reward> rewards,SubmissionPhase phase) {
public Submission {Objects.requireNonNull(id);Objects.requireNonNull(quest);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(submitter);Objects.requireNonNull(item);Objects.requireNonNull(quantity);rewards=Map.copyOf(rewards);Objects.requireNonNull(phase);if(quantity.signum()<=0||rewards.isEmpty()||!rewards.containsKey(submitter)||rewards.entrySet().stream().anyMatch(entry->entry.getKey()==null||entry.getValue()==null))throw new IllegalArgumentException("Invalid fishing submission");}
public Submission committed(){return new Submission(id,quest,catalog,site,submitter,item,quantity,rewards,SubmissionPhase.COMMITTED);}
}
public record Completion(UUID catalog,UUID site,UUID character) {public Completion {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
public record Allocation(UUID id,UUID submission,UUID catalog,UUID site,UUID character,BigInteger xp,BigInteger coins) {
public Allocation {Objects.requireNonNull(id);Objects.requireNonNull(submission);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing allocation");}
public Reward reward(){return new Reward(xp,coins);}
}
public record Claim(UUID id,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
public Claim {Objects.requireNonNull(id);Objects.requireNonNull(character);allocations=Set.copyOf(allocations);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(allocations.isEmpty()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid fishing claim");}
}
public FishingState {
inventory=List.copyOf(inventory);trophies=List.copyOf(trophies);trophyReceipts=Set.copyOf(trophyReceipts);catches=List.copyOf(catches);catchReceipts=Set.copyOf(catchReceipts);
submissions=List.copyOf(submissions);completions=Set.copyOf(completions);allocations=List.copyOf(allocations);claims=List.copyOf(claims);
unique(inventory.stream().map(value->value.character()+":"+value.item()).toList());
unique(trophies.stream().map(Trophy::id).toList());unique(catches.stream().map(Catch::id).toList());unique(submissions.stream().map(Submission::id).toList());
unique(allocations.stream().map(Allocation::id).toList());unique(claims.stream().map(Claim::id).toList());
var redeemedTrophies=trophyReceipts;if(trophies.stream().anyMatch(value->redeemedTrophies.contains(value.id())))throw new IllegalArgumentException("Redeemed trophy remains in Inventory");
var completedCatchReceipts=catchReceipts;if(catches.stream().anyMatch(value->completedCatchReceipts.contains(value.id())))throw new IllegalArgumentException("Pending catch already completed");
var allocated=allocations.stream().collect(java.util.stream.Collectors.toMap(Allocation::id,value->value));
var claiming=new HashSet<UUID>();
for(var claim:claims){if(!claiming.add(claim.character()))throw new IllegalArgumentException("Duplicate fishing claim");BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(UUID id:claim.allocations()){var value=allocated.get(id);if(value==null||!value.character().equals(claim.character()))throw new IllegalArgumentException("Invalid fishing claim allocation");xp=xp.add(value.xp());coins=coins.add(value.coins());}if(!xp.equals(claim.xp())||!coins.equals(claim.coins()))throw new IllegalArgumentException("Invalid fishing claim total");}
}
public FishingState(List<ItemStack> inventory,List<Catch> catches,Set<UUID> catchReceipts,List<Submission> submissions,Set<Completion> completions,List<Allocation> allocations,List<Claim> claims){this(inventory,List.of(),Set.of(),catches,catchReceipts,submissions,completions,allocations,claims);}
public static FishingState empty(){return new FishingState(List.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),Set.of(),List.of(),List.of());}
private static String validName(String value){Objects.requireNonNull(value);value=value.strip();if(value.codePointCount(0,value.length())>64||value.codePoints().anyMatch(c->{int type=Character.getType(c);return type==Character.CONTROL||type==Character.FORMAT||type==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid fishing item name");return value;}
private static void unique(List<?> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate fishing identity");}
}
@@ -29,26 +29,55 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
private games.dmg.fantasystick.adventure.FishingDirectory fishing;
private games.dmg.fantasystick.shop.RareInventoryDirectory rareInventory;
private games.dmg.fantasystick.shop.RarePotionService rarePotions;
private games.dmg.fantasystick.shop.RareReskillService rareReskills;
private games.dmg.fantasystick.shop.RareMagicCoreService rareMagicCores;
private final org.bukkit.Server server;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private final Map<UUID, games.dmg.fantasystick.events.BookEventBridge.Request> pendingBookEvents = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.BookEventBridge bookEvents;
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and 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 ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
private static final net.kyori.adventure.key.Key SAVED_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "saved_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_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
private static final net.kyori.adventure.key.Key RARE_USE = net.kyori.adventure.key.Key.key("fantasystick", "rare_use");
private static final net.kyori.adventure.key.Key RARE_RESKILL_CONFIRM = net.kyori.adventure.key.Key.key("fantasystick", "rare_reskill_confirm");
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})\"");
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 record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
private static final java.util.regex.Pattern RARE_CONFIRM_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36})\"");
private record View(String nonce, ControlInput input, int start, int navigationPage,
int previous, int next) { }
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
private record RareView(String nonce,ControlInput input,java.util.Map<String,java.math.BigInteger> quantities){}
private record ReskillView(String nonce,ControlInput input,games.dmg.fantasystick.shop.RareReskillService.Preview preview){}
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
private final Map<UUID,RareView> rareViews=new ConcurrentHashMap<>();
private final Map<UUID,ReskillView> reskillViews=new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
@@ -87,12 +116,139 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.abilityEngine = abilityEngine;
this.passives = Objects.requireNonNull(passives);
this.passiveEngine = passiveEngine;
this.server = Objects.requireNonNull(server);
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) { load(input, 0); }
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
private void load(ControlInput input, int offset) {
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or controller closed");passiveEvents=Objects.requireNonNull(events);}
@Override public void accept(ControlInput input) { load(input, 0, true); }
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
loadReadOnly(administrator, target, active, 0);
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
UUID targetId = target.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
savedViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.find(active.character()).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
var currentTarget = server.getPlayer(targetId);
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
currentTarget, active, offset)); }
catch (RuntimeException error) {
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, active.character(), rejected));
}
});
}
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
if (closed) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
return;
}
loadSavedReadOnly(administrator, character, 0);
}
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset) {
if (closed) return;
UUID administratorId = administrator.getUniqueId();
Object ticket = new Object();
adminViews.remove(administratorId);
savedViews.remove(administratorId);
adminPending.put(administratorId, ticket);
characters.inspect(character).whenComplete((record, failure) -> {
if (closed || adminPending.get(administratorId) != ticket) return;
try {
nextTick.accept(() -> {
if (closed || !adminPending.remove(administratorId, ticket)) return;
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
return;
}
if (failure != null) {
report(new Failure(administratorId, character, failure));
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty()) {
administrator.sendMessage("Saved character view refused: character not found.");
return;
}
try { administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); }
catch (RuntimeException error) {
savedViews.remove(administratorId);
report(new Failure(administratorId, character, error));
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
}
});
} catch (RuntimeException rejected) {
adminPending.remove(administratorId, ticket);
report(new Failure(administratorId, character, rejected));
}
});
}
private void load(ControlInput input, int offset, boolean userRequested) {
load(input, offset, userRequested, null);
}
private void load(ControlInput input, int offset, boolean userRequested, View from) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
@@ -101,45 +257,84 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
statViews.remove(owner);
abilityViews.remove(owner);
passiveViews.remove(owner);
rareViews.remove(owner);
reskillViews.remove(owner);
var current=sessions.currentRecord(input.session()).orElse(null);
var navigation=from==null?null:new games.dmg.fantasystick.events.BookEventBridge.Navigation(
from.start(),from.navigationPage(),offset);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset,navigation):null;
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
if(audit!=null)pendingBookEvents.put(owner,audit);
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 (closed || !pending.remove(owner, ticket)) return;
if (!input.stillCurrent()) { finishRefused(owner,audit,"stale_session"); return; }
if (failure != null) {
finishFailed(owner,audit,"load_failed");
report(input, failure);
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
finishRefused(owner,audit,"character_unavailable");
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); }
try {
var book=render(record.orElseThrow(), input, offset);input.player().openBook(book);
if(audit!=null&&pendingBookEvents.remove(owner,audit)){var pages=((BookMeta)book.getItemMeta()).pages().size();bookEvents.opened(audit,record.orElseThrow(),pages,1);}
}
catch (RuntimeException error) { finishFailed(owner,audit,"native_open_failed");partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
pending.remove(owner, ticket);finishFailed(owner,audit,"scheduler_rejected");
report(input, rejected);
}
});
}
private void finishRefused(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.refused(request,reason);}
private void finishFailed(UUID owner,games.dmg.fantasystick.events.BookEventBridge.Request request,String reason){if(request!=null&&pendingBookEvents.remove(owner,request))bookEvents.failed(request,reason);}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
return renderBook(record, input.player(), input, offset, null, null, false);
}
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int offset) {
return renderBook(record, target, null, offset, administrator, active, false);
}
private ItemStack renderSavedReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator, int offset) {
return renderBook(record, administrator, null, offset, administrator, null, true);
}
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
ControlInput input, int offset, org.bukkit.entity.Player administrator,
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive,
boolean savedState) {
boolean interactive = input != null;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision(), record) : null;
var allOverview = overviewPages(record, subject, statView, savedState);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
var pages = new java.util.ArrayList<Component>();
if (savedState && start == 0) pages.addAll(savedSummaryPages(record));
pages.addAll(allOverview.subList(start, end));
if(end==allOverview.size())pages.add(gearPage(record));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
@@ -151,14 +346,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
.toList();
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
if (!passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
.collect(java.util.stream.Collectors.toUnmodifiableSet()), record) : null;
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.passive.PassiveDefinition::id,
games.dmg.fantasystick.passive.PassiveDefinition::name));
@@ -208,29 +403,72 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
passiveDetails.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.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));
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start, pages.size() + 1,
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
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));
} else if (savedState) {
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(),
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
savedViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(savedPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(savedPageButton("Next pages", view, 1));
} else {
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
adminViews.put(administrator.getUniqueId(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline())
.append(adminPageButton("Next pages", view, 1));
}
pages.add(navigation);
}
if (end == allOverview.size()) pages.addAll(partyControls.pages(input));
if(end==allOverview.size())pages.addAll(inventoryPages(record.id(),input));
if (end == allOverview.size()) pages.addAll(interactive
? partyControls.pages(input) : savedState
? partyControls.readOnlyCharacterPages(record.id())
: partyControls.readOnlyPages(subject.getUniqueId()));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;boolean phoenix=games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN.id().equals(good.id());builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()&&!phoenix?rareButton(view,good.id()):null);if(phoenix&&good.available())builder.text("Use Phoenix Down while downed.");if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));}
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
private java.util.List<Component> savedSummaryPages(CharacterRecord record) {
var builder = new PageBuilder();
boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
builder.text("Saved state");
builder.blank();
builder.text("Character UUID:");
builder.text(record.id().toString());
builder.text(active ? "Active" : "Inactive");
builder.text(record.dead() ? "Dead" : "Alive");
if (record.deathId() != null) builder.text("Death: " + record.deathId());
if (record.diedAt() != null) builder.text("Died: " + record.diedAt());
return builder.pages();
}
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
StatView view, boolean savedState) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
Component action = view != null && record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
@@ -241,8 +479,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Coins: " + record.coins());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
var health = savedState ? record.health() : java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum);
builder.text((savedState ? "Saved health: " : "Health: ") + health.stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
if (savedState) {
builder.blank();
builder.text("Saved cooldowns");
if (record.cooldowns().isEmpty()) builder.text("None");
else record.cooldowns().entrySet().stream().sorted(java.util.Map.Entry.comparingByKey())
.forEach(entry -> builder.text(entry.getKey() + ": " + entry.getValue()));
}
return builder.pages();
}
@@ -362,7 +608,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
@@ -387,7 +634,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
builder.text(definition.name());
builder.text(definition.category().name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
if (view == null) builder.text(state);
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
@@ -475,25 +723,78 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component adminPageButton(String label, AdminView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
private static Component savedPageButton(String label, SavedView view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(SAVED_PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void savedPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = savedViews.get(administrator.getUniqueId());
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) 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 || !savedViews.remove(administrator.getUniqueId(), view)) return;
loadSavedReadOnly(administrator, view.character(), offset);
}
@org.bukkit.event.EventHandler
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
var view = adminViews.get(administrator.getUniqueId());
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) 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 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset);
}
@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;
if (view == null) { refusePage(player,"missing_or_used_view"); return; }
if (view.input().player() != player || event.getTag() == null) { refusePage(player,"foreign_or_missing_payload"); return; }
String raw = event.getTag().string();
if (raw.length() > 64) return;
if (raw.length() > 64) { refusePage(player,"invalid_payload"); return; }
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
if (!match.matches() || !view.nonce().equals(match.group(1))) { refusePage(player,"stale_or_forged_payload"); return; }
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
load(view.input(), offset, true, view);
}
private void refusePage(org.bukkit.entity.Player player,String reason){
if(bookEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)bookEvents.refused(player,active,current,"character_book_navigate",reason);
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
report(new Failure(input.session().player(), input.session().character(), failure));
}
private void report(Failure failure) {
try { failures.accept(failure); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@@ -501,49 +802,53 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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 player = connection.getPlayer();if(event.getTag()==null)return;String raw=event.getTag().string();if(raw.length()>80)return;var match=STAT_PAYLOAD.matcher(raw);if(!match.matches())return;var stat=Stat.valueOf(match.group(2));
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));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refuseStat(player,stat,"stale_forged_or_repeated_action"); return; }
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); }
});
var nativeMaximum=java.math.BigDecimal.valueOf(java.util.Objects.requireNonNull(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
var audit=progressionEvents==null?null:progressionEvents.begin(view.input(),view.before(),stat,currentHealth,nativeMaximum,nativeLimit);
try {
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try { nextTick.accept(() -> finishPurchase(view, stat, audit, purchase, failure)); }
catch (RuntimeException rejected) { if(audit!=null){if(purchase!=null&&purchase.status()==CharacterStorage.StatPurchaseStatus.APPLIED)progressionEvents.increased(audit,purchase.record().orElseThrow(),"scheduler_rejected_before_native_publication",purchase.record().orElseThrow().health());else progressionEvents.failed(audit,"scheduler_rejected");}report(view.input(), rejected); }
});
} catch (RuntimeException failure) { if(audit!=null)progressionEvents.failed(audit,"purchase_rejected");report(view.input(),failure); }
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
private void refuseStat(org.bukkit.entity.Player player,Stat stat,String reason){if(progressionEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)progressionEvents.refused(player,active,current,stat,NativeHealthLimit.read(),reason);}
private void finishPurchase(StatView view, Stat stat, games.dmg.fantasystick.events.ProgressionEventBridge.Action audit, 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.");
if(audit!=null)progressionEvents.failed(audit,"save_failed");report(input, failure);
if(!closed&&input.player().isOnline())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();
if(closed||!input.stillCurrent()){if(audit!=null)progressionEvents.increased(audit,saved,closed?"plugin_shutdown_before_native_publication":"session_changed_before_native_publication",saved.health());return;}
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
if(audit!=null)progressionEvents.increased(audit,saved,"session_changed_before_native_publication",saved.health());
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
if(audit!=null)progressionEvents.increased(audit,saved,"native_publication_failed",saved.health());report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
if(audit!=null)progressionEvents.increased(audit,saved,"acknowledged",java.math.BigDecimal.valueOf(input.player().getHealth()));
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
load(input, 0, false);
return;
}
if(audit!=null)progressionEvents.refused(audit,purchase.status().name().toLowerCase(java.util.Locale.ROOT),purchase.record().orElse(null));
if(closed||!input.stillCurrent())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.";
@@ -551,7 +856,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
load(input, 0, false);
}
@org.bukkit.event.EventHandler
@@ -561,18 +866,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
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))
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
boolean audited=unlearn||unlock&&("revive".equals(ability)||abilityEngine.eventsEnabledFor(ability));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;}
var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null;
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)abilityEvents.failed(audit,"save_or_native_cleanup_uncertain");else abilityEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -580,10 +883,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
private void refuseAbilityMutation(org.bukkit.entity.Player player,String ability,boolean learn,String reason){if(abilityEvents==null||abilities.find(ability).isEmpty())return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)abilityEvents.refused(player,active,current,ability,learn,reason);}
@org.bukkit.event.EventHandler
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
@@ -591,16 +896,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || passiveEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer(); var view = passiveViews.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))
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String passive = match.group(2);
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
: passiveEngine.unlearn(view.input(), passive, view.revision());
operation.whenComplete((mutation, failure) -> {
if(audit!=null){if(failure!=null)passiveEvents.failed(audit,"save_or_native_publication_uncertain");else passiveEvents.resolved(audit,mutation);}
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
@@ -608,10 +911,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return;
}
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
load(view.input(), 0);
load(view.input(), 0, false);
});
}
private void refusePassive(org.bukkit.entity.Player player,games.dmg.fantasystick.passive.PassiveDefinition definition,boolean learn,String reason){if(passiveEvents==null)return;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var current=sessions.currentRecord(active).orElse(null);if(current!=null)passiveEvents.refused(player,active,current,definition,learn,reason);}
private static String passiveMessage(boolean unlock, String passive,
CharacterStorage.PassiveMutationStatus status) {
return switch (status) {
@@ -645,6 +950,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
};
}
@org.bukkit.event.EventHandler
public void rareAction(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rarePotions==null||rareReskills==null||rareMagicCores==null||!RARE_USE.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=rareViews.get(player.getUniqueId());if(view==null||view.input().player()!=player)return;String raw=event.getTag().string();if(raw.length()>120)return;var match=RARE_PAYLOAD.matcher(raw);var product=match.matches()?games.dmg.fantasystick.shop.RareProduct.find(match.group(2)).orElse(null):null;if(product==null||!view.nonce().equals(match.group(1))||!view.quantities().getOrDefault(product.id(),java.math.BigInteger.ZERO).equals(rareInventory.quantity(view.input().session().character(),product))||!rareViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;if(product==games.dmg.fantasystick.shop.RareProduct.RESKILL){var preview=rareReskills.preview(player);if(preview.isEmpty()){player.sendMessage("Reskill preview refused: the character, session or quantity changed.");return;}if(preview.orElseThrow().refund().signum()==0){player.sendMessage("Reskill use refused: this character has no investments to reset.");load(view.input(),0,false);return;}var confirmation=new ReskillView(UUID.randomUUID().toString(),view.input(),preview.orElseThrow());reskillViews.put(player.getUniqueId(),confirmation);player.openBook(reskillConfirmation(confirmation));return;}if(product==games.dmg.fantasystick.shop.RareProduct.MAGIC_CORE){rareMagicCores.use(player,outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Magic Core and gained 1 ordinary unspent point.";case NOT_AVAILABLE->"Magic Core use refused: that item is no longer available.";case STALE->"Magic Core use refused: the character, session, points or quantity changed.";case FAILED->"Magic Core could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});return;}if(!product.heals())return;rarePotions.use(player,product,outcome->{if(closed||!view.input().stillCurrent())return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used "+product.displayName()+" and restored "+outcome.healed().toPlainString()+" health.";case FULL_HEALTH->"Potion use refused: health is already full.";case NO_EFFECT->"Potion use refused: no positive healing was accepted.";case NOT_AVAILABLE->"Potion use refused: that item is no longer available.";case STALE->"Potion use refused: the character, session or quantity changed.";case FAILED->"Potion use could not be acknowledged; retained proof prevents replay.";});load(view.input(),0,false);});}
private static ItemStack reskillConfirmation(ReskillView view){var item=new ItemStack(Material.WRITTEN_BOOK);var meta=(BookMeta)item.getItemMeta();meta.setTitle("Confirm Reskill");meta.setAuthor("Fantasy Stick");var page=Component.text("Confirm Reskill\n\nRefund: "+view.preview().refund()+" points\nStats → 0\nAbilities/passives removed\nHealth only clamps to the new maximum\nCooldowns retained\n\n").append(Component.text("[Confirm Reskill]",net.kyori.adventure.text.format.NamedTextColor.DARK_RED).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_RESKILL_CONFIRM,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+"\""))));meta.pages(java.util.List.of(page));item.setItemMeta(meta);return item;}
@org.bukkit.event.EventHandler
public void reskillConfirm(io.papermc.paper.event.player.PlayerCustomClickEvent event){if(closed||rareReskills==null||!RARE_RESKILL_CONFIRM.equals(event.getIdentifier())||!(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)||event.getTag()==null)return;var player=connection.getPlayer();var view=reskillViews.get(player.getUniqueId());String raw=event.getTag().string();if(view==null||view.input().player()!=player||raw.length()>80)return;var match=RARE_CONFIRM_PAYLOAD.matcher(raw);if(!match.matches()||!view.nonce().equals(match.group(1))||!reskillViews.remove(player.getUniqueId(),view)||!view.input().stillCurrent())return;rareReskills.confirm(player,view.preview(),outcome->{if(closed)return;player.sendMessage(switch(outcome.status()){case APPLIED->"Used Reskill; all purchased stats, abilities and passives were refunded.";case NOTHING_TO_RESET->"Reskill use refused: this character has no investments to reset.";case NOT_AVAILABLE->"Reskill use refused: that item is no longer available.";case STALE->"Reskill use refused: the character, session, build or quantity changed.";case FAILED->"Reskill could not be acknowledged; retained proof prevents replay.";});if(view.input().stillCurrent())load(view.input(),0,false);});}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -652,14 +965,20 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
var bookRequest=pendingBookEvents.remove(owner);if(bookRequest!=null)bookEvents.refused(bookRequest,"player_quit");
adminPending.remove(owner);
adminViews.remove(owner);
savedViews.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
passiveViews.remove(owner);
rareViews.remove(owner);
reskillViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
}
}
@@ -54,6 +54,17 @@ public final class PartyBookControls implements AutoCloseable {
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
/** Informational party pages with no registered action or retained viewer state. */
public List<Component> readOnlyPages(UUID account) {
if (closed) return List.of();
return rootPages(Objects.requireNonNull(account));
}
public List<Component> readOnlyCharacterPages(UUID character) {
if (closed) return List.of();
return rootPages(parties.partyOfCharacter(Objects.requireNonNull(character)));
}
private List<Component> render(ControlInput input, Menu menu, int offset) {
if (closed || !input.stillCurrent()) return List.of();
var build = new Pages(input);
@@ -61,18 +72,7 @@ public final class PartyBookControls implements AutoCloseable {
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()) {
build.pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size() + " active\n\nManage this party\nonly at a Party\nRegistrar."));
build.pages.add(summary);
for (var member : group.characters()) {
Component label=parties.activePlayer(member).map(names).orElse(Component.text("Character\n"+member.toString().substring(0,8)));
build.pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
build.pages.addAll(rootPages(owner));
} else if (menu == Menu.PLAYERS) {
var group = party.orElseThrow();
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
@@ -108,6 +108,29 @@ public final class PartyBookControls implements AutoCloseable {
return List.copyOf(build.pages);
}
private List<Component> rootPages(UUID owner) {
return rootPages(parties.partyOf(owner));
}
private List<Component> rootPages(java.util.Optional<PartyRoster.Party> party) {
var pages = new ArrayList<Component>();
Component summary = Component.text("Party\n\n");
if (party.isEmpty()) {
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
} else {
var group = party.orElseThrow();
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
pages.add(summary);
for (var member : group.characters()) {
Component label = parties.activePlayer(member).map(names)
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
}
}
return List.copyOf(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));
}
@@ -28,6 +28,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private final CombatFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
private Pending pending;
private boolean closed;
@@ -36,9 +37,18 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
final games.dmg.fantasystick.events.BasicAttackEventBridge.Action action;
final double requested;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
boolean nativeSeen;
boolean nativePriorCancelled;
boolean nativeCancelled;
Double nativeFinal;
EntityDamageByEntityEvent acceptedEvent;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback,
double requested, games.dmg.fantasystick.events.BasicAttackEventBridge.Action action) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
this.requested = requested; this.action = action;
}
}
@@ -62,26 +72,38 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
this.trainingDamage = Objects.requireNonNull(trainingDamage);
}
public void attachEvents(games.dmg.fantasystick.events.BasicAttackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Basic attack events already attached");
events = Objects.requireNonNull(bridge);
}
@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;
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
if (closed || pending != null || !(input.target() instanceof LivingEntity) || !input.stillCurrent()) {
refuse(input, null, now, ready, closed ? "closed" : pending != null ? "attack_in_progress"
: !(input.target() instanceof LivingEntity) ? "ineligible_target" : "stale_input");
return;
}
var target = (LivingEntity) input.target();
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) { refuse(input, null, now, ready, "missing_character"); return; }
if (ready != null && now.isBefore(ready)) { refuse(input, record, now, ready, "cadence_not_ready"); return; }
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var action = events == null ? null : events.observed(input, record, target, now, ready, nativeDamage);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID), nativeDamage, action);
pending = attempt;
boolean completed = false;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
});
completed = true;
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) {
@@ -91,9 +113,24 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
: attempt.nativeCancelled ? "cancelled" : "refused",
attempt.accepted ? attempt.nativeSeen ? "native_positive_hit" : "training_measurement"
: !completed ? "native_damage_failed" : attempt.nativePriorCancelled ? "prior_cancelled"
: attempt.nativeCancelled ? "native_cancelled" : attempt.nativeSeen
? "zero_or_invalid_damage" : "native_hit_unverified",
attempt.nativePriorCancelled, attempt.nativeCancelled, attempt.nativeFinal,
attempt.accepted ? readyAt.get(input.session().character()) : null);
}
}
private void refuse(ControlInput input, games.dmg.fantasystick.persistence.CharacterRecord record,
Instant now, Instant ready, String reason) {
if (events == null) return;
var action = events.observed(input, record, input.target(), now, ready, null);
events.resolved(action, "refused", reason, false, false, null, null);
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
@@ -101,13 +138,35 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.LOWEST)
public void observedNativeDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (attempt != null && event.getDamager() == attempt.input.player() && event.getEntity() == attempt.target)
attempt.nativePriorCancelled = event.isCancelled();
}
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
var attempt = pending;
if (closed || attempt == null || !(event instanceof EntityDamageByEntityEvent by)) return false;
if (attempt.accepted) return attempt.acceptedEvent == event;
return by.getDamager() == attempt.input.player() && by.getEntity() == attempt.target
&& !event.isCancelled() && Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0
&& attempt.input.stillCurrent() && sessions.current(attempt.input.session());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
if (closed || attempt == null || attempt.accepted || event.getDamager() != attempt.input.player()
|| event.getEntity() != attempt.target) return;
attempt.nativeSeen = true;
attempt.nativeCancelled = event.isCancelled();
if (event.isCancelled() || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
attempt.acceptedEvent = event;
attempt.nativeFinal = event.getFinalDamage();
if (events != null) events.acceptedDamage(attempt.action, event, attempt.attackedAt.plus(CADENCE));
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -17,6 +17,12 @@ public final class CharacterAnvilNames implements Listener {
private final Consumer<Failure> failures;
private final org.bukkit.inventory.ItemFactory items;
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null) throw new IllegalStateException("Anvil identity events already attached");
identityEvents = java.util.Objects.requireNonNull(events);
}
/** Diagnostics may run on the persistence worker and must not access native player state. */
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
@@ -36,17 +42,27 @@ public final class CharacterAnvilNames implements Listener {
public void onTaken(AnvilTakeResultEvent event) {
var sheets = new CharacterSheets();
var result = event.getResult();
var id = sheets.identify(result);
if (id.isEmpty()) return;
var identity = sheets.identity(result);
if (identity.isEmpty()) return;
var input = event.getInventory().getItem(0);
if (!id.equals(sheets.identify(input))) return;
if (!identity.equals(sheets.identity(input))) return;
String name = name(result);
if (name.equals(name(input))) return;
var player = event.getPlayer().getUniqueId();
var character = id.orElseThrow();
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
try { acknowledged.accept(renamed.orElseThrow()); }
var character = identity.orElseThrow().character();
var audit = identityEvents;
var action = audit == null ? null : audit.rename(event.getPlayer(), character, name);
storage.renameCharacterObserved(character, name, identity.orElseThrow().generation()).whenComplete((outcome, failure) -> {
if (failure != null || outcome.record().isEmpty()) {
failures.accept(new Failure(player, character, failure));
if (audit != null) audit.resolved(action, "refused", failure == null ? outcome.status() : "save_failed");
return;
}
if (audit != null) {
if ("renamed".equals(outcome.status())) audit.renamed(action, outcome.before().orElseThrow(), outcome.record().orElseThrow());
audit.resolved(action, "renamed".equals(outcome.status()) ? "accepted" : "refused", outcome.status());
}
try { acknowledged.accept(outcome.record().orElseThrow()); }
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
});
}
@@ -34,6 +34,14 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
private final Consumer<Failure> failures;
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
private volatile boolean closed;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private final java.util.Set<CraftItemEvent> handledCrafts =
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null || !pending.isEmpty()) throw new IllegalStateException("Craft identity events already attached or attempts started");
identityEvents = java.util.Objects.requireNonNull(events);
}
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
@@ -50,22 +58,30 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
public void onCraft(CraftItemEvent event) {
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
handledCrafts.add(event); // MONITOR can distinguish our containment from an existing veto.
java.util.Optional<ManualCraftPlan> plan;
try {
plan = ManualCraftPlan.capture(event);
} catch (RuntimeException failure) {
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
refuseCraft(event, "failed", "plan");
return;
} finally {
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
event.setCancelled(true);
}
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|| !plan.orElseThrow().matches(player.getOpenInventory())) {
refuseCraft(event, "refused", "invalid_or_stale_preflight");
return;
}
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
var audit = identityEvents;
if (audit != null) attempt.craft = audit.craft(player, attempt.plan.route().name().toLowerCase(java.util.Locale.ROOT), attempt.plan.quantity());
try {
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
attempt.records = records;
attempt.characters = records.stream().map(CharacterRecord::id).toList();
attempt.preparation = storage.prepareCraft(records);
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
@@ -73,9 +89,24 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
pending.remove(attempt.id, attempt);
abandon(attempt);
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "prepare", 0);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onCraftDecision(CraftItemEvent event) {
if (!(event.getRecipe() instanceof Keyed keyed) || !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
if (handledCrafts.remove(event) || !event.isCancelled()) return;
refuseCraft(event, "cancelled", "prior_cancellation");
}
private void refuseCraft(CraftItemEvent event, String outcome, String reason) {
var audit = identityEvents;
if (audit == null || !(event.getWhoClicked() instanceof Player player)) return;
var action = audit.craft(player, "unknown", 0);
audit.resolved(action, outcome, reason, 0);
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.id) != attempt) {
abandon(attempt);
@@ -98,22 +129,45 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
}
if (!delivering.add(attempt.id)) return;
try {
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
else if (attempt.player.isOnline()) {
var audit = identityEvents;
if (failure != null) {
failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "save", 0);
} else if (attempt.player.isOnline()) {
attempt.handoffStarted = true;
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
if (audit != null) {
for (int index = 0; index < result.completed(); index++) audit.created(attempt.craft, attempt.records.get(index), index);
var outcome = switch (result.status()) {
case COMPLETE -> "accepted";
case CANCELLED -> "cancelled";
case STALE -> "stale";
case UNCONFIRMED -> "uncertain";
case UNAVAILABLE, UNSUPPORTED_ROUTE -> "refused";
};
audit.resolved(attempt.craft, outcome,
"drop_" + result.status().name().toLowerCase(java.util.Locale.ROOT), result.completed());
}
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
}
} else {
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
if (audit != null) {
if (attempt.handoffStarted) for (int index = 0; index < attempt.records.size(); index++)
audit.created(attempt.craft, attempt.records.get(index), index);
audit.resolved(attempt.craft, attempt.handoffStarted ? "accepted"
: result == ManualCraftDelivery.Result.STALE ? "stale" : "refused",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.handoffStarted ? attempt.records.size() : 0);
}
}
}
} catch (RuntimeException unexpected) {
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
if (identityEvents != null) identityEvents.resolved(attempt.craft, "uncertain", "delivery", 0);
} finally {
pending.remove(attempt.id, attempt);
abandon(attempt);
@@ -132,6 +186,8 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
final Player player;
final ManualCraftPlan plan;
List<UUID> characters = List.of();
List<CharacterRecord> records = List.of();
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Craft craft;
CharacterStorage.CraftPreparation preparation;
volatile boolean handoffStarted;
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
@@ -11,37 +11,50 @@ import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
public final class RecipeDiscovery implements Listener {
public final class RecipeDiscovery implements Listener, AutoCloseable {
private final NamespacedKey recipe;
private final Logger logger;
private games.dmg.fantasystick.events.RecipeDiscoveryEventBridge events;
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
this.recipe = Objects.requireNonNull(recipe, "recipe");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void attachEvents(games.dmg.fantasystick.events.RecipeDiscoveryEventBridge bridge) {
events = Objects.requireNonNull(bridge);
}
public void discoverAll(Iterable<? extends Player> players) {
for (var player : players) discover(player);
for (var player : players) discover(player, "enable_online");
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
discover(event.getPlayer(), "join");
}
public void discover(Player player) {
public void discover(Player player) { discover(player, "join"); }
private void discover(Player player, String trigger) {
Objects.requireNonNull(player, "player");
try {
if (player.hasDiscoveredRecipe(recipe)) return;
player.discoverRecipes(List.of(recipe));
int notifications = player.discoverRecipes(List.of(recipe));
if (!player.hasDiscoveredRecipe(recipe)) {
logger.warning("Could not discover character-sheet recipe for " + label(player));
}
if (events != null) events.failed(player, trigger, "native_unavailable");
} else if (events != null) events.discovered(player, trigger, notifications);
} catch (RuntimeException failure) {
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
if (events != null) events.failed(player, trigger, "native_failed");
}
}
@Override public void close() {
if (events != null) events.close();
}
private static String label(Player player) {
try { return player.getName(); }
catch (RuntimeException ignored) { return "an unidentified player"; }
@@ -20,30 +20,31 @@ public final class DungeonCommands {
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged.")));}
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}directory.setMode(id,current.revision(),requested).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+value.participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale.")));}
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; reward="+v.reward().xp()+" XP, "+v.reward().coins()+" coins; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"reward"->reward(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}directory.setCompletionReward(dungeon,xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins."));}
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
@@ -60,5 +61,6 @@ public final class DungeonCommands {
private static BigInteger whole(String value){try{return new BigInteger(value);}catch(NumberFormatException invalid){return null;}}
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
private static List<String> modeValues(){return Arrays.stream(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.values()).map(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode::value).toList();}
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|reward|room|boss|enable|disable|open|close|teleport> ...");}
}
@@ -2,6 +2,8 @@ package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.adventure.FishingController;
import games.dmg.fantasystick.adventure.FishingState;
import games.dmg.fantasystick.ability.*;
import games.dmg.fantasystick.passive.PassiveRegistry;
import games.dmg.fantasystick.progression.Stat;
@@ -30,17 +32,22 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
private record AttemptKey(UUID dungeon,UUID room){}
private record PendingKill(UUID dungeon,UUID room,UUID generation,UUID slot){}
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
private record Context(UUID character,UUID party,Set<UUID> members){}
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,recoverySettings,recoveryBonus,healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());}
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing,games.dmg.fantasystick.presentation.ParticleDelivery particles){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);this.particles=Objects.requireNonNull(particles);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
public void registerFishing(FishingController fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing already attached or dungeon controller closed");this.fishing=Objects.requireNonNull(fishing);}
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
public Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> encounterOrigin(Entity entity){if(!managedEnemy(entity))return Optional.empty();UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey),generation=id(entity,generationKey);if(dungeon==null||room==null||generation==null)return Optional.empty();var attempt=attempts.get(new AttemptKey(dungeon,room));if(attempt!=null&&!attempt.generation.equals(generation))return Optional.empty();UUID slot=id(entity,slotKey);if(slot==null&&(!Boolean.TRUE.equals(entity.getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN))||dungeons.room(dungeon,room).map(value->value.boss()==null).orElse(true)))return Optional.empty();if(slot!=null&&(dungeons.room(dungeon,room).stream().flatMap(value->value.mobs().stream()).noneMatch(value->value.id().equals(slot))||dungeons.runView(dungeon,generation).map(run->run.defeatedSlots().contains(slot)||run.defeated()).orElse(true)))return Optional.empty();String encounter=slot==null?"boss":slot.toString();UUID source=UUID.nameUUIDFromBytes((dungeon+":"+room+":"+generation+":"+encounter).getBytes(java.nio.charset.StandardCharsets.UTF_8));if(slot!=null&&attempt==null&&!pendingKills.containsKey(source))return Optional.empty();return Optional.of(new games.dmg.fantasystick.progression.ManagedEncounterOrigin(games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON,source));}
public void settleKill(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){if(origin.kind()!=games.dmg.fantasystick.progression.ManagedEncounterOrigin.Kind.DUNGEON)return;var pending=pendingKills.remove(origin.source());if(pending==null)return;dungeons.defeatSlot(pending.dungeon(),pending.generation(),pending.slot()).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;var room=dungeons.room(pending.dungeon(),pending.room()).orElse(null);var attempt=attempts.get(new AttemptKey(pending.dungeon(),pending.room()));var dungeon=dungeons.find(pending.dungeon()).orElse(null);if(room!=null&&attempt!=null&&dungeon!=null&&room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
@@ -117,9 +124,9 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;dungeons.defeatSlot(dungeonId,generation,slot).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;encounterOrigin(event.getEntity()).ifPresent(origin->pendingKills.putIfAbsent(origin.source(),new PendingKill(dungeonId,roomId,generation,slot)));}
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(room.reward().xp(),List.of(unit),random);var coins=RewardAllocator.allocate(room.reward().coins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));UUID source=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.id()).getBytes(StandardCharsets.UTF_8));return adventures.bank(quest.id(),source,rewards).thenCompose(banked->{if(!banked)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon reward banking refused"));return completeQuest?adventures.complete(quest.id(),Set.copyOf(recipients)):CompletableFuture.completedFuture(true);}).thenCompose(completed->{if(!completed)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon quest completion refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->null);}
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);if(!completeQuest)return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients)).thenApply(ignored->null);if(fishing==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence unavailable"));var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:recipients)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID carrier=quest.unitType()==AdventureQuestState.UnitType.SOLO?quest.unit():recipients.stream().map(parties::partyOfCharacter).flatMap(Optional::stream).filter(value->value.id().equals(quest.unit())).map(PartyRoster.Party::leaderCharacter).findFirst().orElse(null);if(carrier==null)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy carrier unavailable"));UUID trophy=UUID.nameUUIDFromBytes(("dungeon:"+quest.id()).getBytes(StandardCharsets.UTF_8));var plan=new FishingState.Trophy(trophy,FishingState.TrophyType.DUNGEON,carrier,quest.catalog(),dungeon.id(),quest.id(),generation,"Trophy of "+dungeon.name(),dungeon.name(),rewards);return fishing.earnTrophy(plan).thenCompose(earned->{if(!earned)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon trophy persistence refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->(Void)null).toCompletableFuture();}
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
@@ -142,7 +149,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
private static UUID id(Entity entity,NamespacedKey key){try{var value=entity.getPersistentDataContainer().get(key,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
private static void copy(org.bukkit.persistence.PersistentDataContainer target,NamespacedKey key,Entity source){var value=source.getPersistentDataContainer().get(key,PersistentDataType.STRING);if(value!=null)target.set(key,PersistentDataType.STRING,value);}
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;player.spawnParticle(Particle.DUST,sample,1,0,0,0,0,dust);}}}}
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;particles.viewer(player,Particle.DUST,sample,1,0,0,0,0,dust);}}}}
static List<double[]> edges(DungeonState.Bounds b){var result=new ArrayList<double[]>();double x1=b.minX(),x2=b.maxX()+1d,y1=b.minY(),y2=b.maxY()+1d,z1=b.minZ(),z2=b.maxZ()+1d;line(result,x1,x2,y1,z1,0);line(result,x1,x2,y1,z2,0);line(result,x1,x2,y2,z1,0);line(result,x1,x2,y2,z2,0);line(result,z1,z2,y1,x1,1);line(result,z1,z2,y1,x2,1);line(result,z1,z2,y2,x1,1);line(result,z1,z2,y2,x2,1);line(result,y1,y2,x1,z1,2);line(result,y1,y2,x1,z2,2);line(result,y1,y2,x2,z1,2);line(result,y1,y2,x2,z2,2);return List.copyOf(result);}
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
@@ -23,7 +23,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public Optional<DungeonState.CharacterBoss> custodyForDungeon(UUID dungeon){return find(dungeon).stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).findFirst();}
public Optional<DungeonState.Room> room(UUID dungeon,UUID room){return find(dungeon).flatMap(value->value.rooms().stream().filter(v->v.id().equals(room)).findFirst());}
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){return create(name,world,x,y,z,yaw,pitch,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch,AdventureQuestState.ParticipationMode mode){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of(),mode,DungeonState.Reward.empty(),false);return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
public CompletableFuture<DungeonState.Dungeon> setMode(UUID id,UUID expectedRevision,AdventureQuestState.ParticipationMode mode){Objects.requireNonNull(expectedRevision);Objects.requireNonNull(mode);return mutate(state->{var current=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(!current.revision().equals(expectedRevision))throw new IllegalArgumentException("Dungeon changed");if(current.participationMode()==mode)return result(state,current);var changed=new DungeonState.Dungeon(current.id(),current.name(),current.world(),current.entrance(),current.enabled(),UUID.randomUUID(),current.rooms(),mode,current.completionReward(),current.rewardConfigured());return result(replace(state,changed),changed);});}
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
@@ -34,7 +36,7 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
public CompletableFuture<DungeonState.Reward> setReward(UUID id,UUID roomId,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),value,room.boss()),value);}
public CompletableFuture<DungeonState.Reward> setCompletionReward(UUID id,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return mutate(state->{var dungeon=editable(state,id);var changed=new DungeonState.Dungeon(dungeon.id(),dungeon.name(),dungeon.world(),dungeon.entrance(),false,UUID.randomUUID(),dungeon.rooms(),dungeon.participationMode(),value,true);return result(replace(state,changed),value);});}
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
@@ -57,9 +59,9 @@ public final class DungeonDirectory implements AutoCloseable,AdventureResetParti
private static DungeonState.Dungeon editable(DungeonState state,UUID id){var value=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(value.enabled())throw new IllegalArgumentException("Dungeon must be disabled");return value;}
private static Optional<DungeonState.Dungeon> find(DungeonState state,UUID id){return state.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
private static DungeonState replace(DungeonState state,DungeonState.Dungeon dungeon){var values=state.dungeons().stream().map(v->v.id().equals(dungeon.id())?dungeon:v).toList();return new DungeonState(values,state.runs());}
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms);}
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms,original.participationMode(),original.completionReward(),original.rewardConfigured());}
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
private void validate(DungeonState.Dungeon dungeon){if(!dungeon.rewardConfigured())throw new IllegalArgumentException("Dungeon completion reward is not configured");if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
private int linkDistance(){int value=roomLinkDistance.getAsInt();if(value<1||value>256)throw new IllegalArgumentException("Invalid dungeon room link distance");return value;}
private static boolean overlapping(List<DungeonState.Room> rooms){for(int left=0;left<rooms.size();left++)for(int right=left+1;right<rooms.size();right++)if(rooms.get(left).bounds().overlaps(rooms.get(right).bounds()))return true;return false;}
static boolean connected(List<DungeonState.Room> rooms,int maximum){if(rooms.size()<2)return true;var reached=new HashSet<UUID>();var queue=new ArrayDeque<DungeonState.Room>();queue.add(rooms.getFirst());reached.add(rooms.getFirst().id());while(!queue.isEmpty()){var current=queue.remove();for(var room:rooms)if(!reached.contains(room.id())&&current.bounds().withinDistance(room.bounds(),maximum)){reached.add(room.id());queue.add(room);}}return reached.size()==rooms.size();}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.dungeon;
import games.dmg.fantasystick.adventure.AdventureQuestState;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.math.*;
@@ -45,10 +46,13 @@ public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
public Room {Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(bounds);mobs=List.copyOf(mobs);effects=List.copyOf(effects);Objects.requireNonNull(reward);unique(mobs.stream().map(Spawn::id).toList());unique(effects.stream().map(Effect::id).toList());if(mobs.size()>256||effects.size()>32)throw new IllegalArgumentException("Room limit exceeded");}
public String displayName(){return name.isBlank()?"Room":name;}
}
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms) {
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode participationMode,Reward completionReward,boolean rewardConfigured) {
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);Objects.requireNonNull(participationMode);Objects.requireNonNull(completionReward);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms,AdventureQuestState.ParticipationMode mode){this(id,name,world,entrance,enabled,revision,rooms,mode,legacyReward(rooms),true);}
public Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms){this(id,name,world,entrance,enabled,revision,rooms,AdventureQuestState.ParticipationMode.PARTY_AVAILABLE);}
public BigInteger maximumXp(){return completionReward.xp();}
public BigInteger maximumCoins(){return completionReward.coins();}
private static Reward legacyReward(List<Room> rooms){return rooms.stream().filter(room->room.boss()!=null).map(Room::reward).findFirst().orElse(Reward.empty());}
}
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
@@ -0,0 +1,379 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
float attackCharge, int heldTicks, int slot, UUID executionId) {
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
float attackCharge, int heldTicks, int slot) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
attackCharge, heldTicks, slot, UUID.randomUUID());
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
float attackCharge, int heldTicks) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
attackCharge, heldTicks, -1);
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, attackCharge, 0);
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityCastEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Cast begin(ControlInput input, CharacterRecord before, AbilityDefinition definition) {
try {
var player = input.player();
var target = input.target();
var targetAtRequest = target == null ? null : target.getLocation();
var cast = new Cast(UUID.randomUUID(),
new EventInput.Context(player.getUniqueId(), player.getName(), input.session().character(),
before.name(), input.session().session(), location(player.getLocation())),
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant(), input.attackStrength(), input.heldTicks(), input.slot());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
return null; // Auditing may never refuse a cast or change its authority.
}
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
cast(cast, saved, deadline, definition, Map.of());
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition,
Map<String, Object> nativeEvidence) {
if (cast == null) return;
try {
var settings = new LinkedHashMap<String, Object>();
definition.parameters().forEach((key, value) -> settings.put(key, value.toPlainString()));
var data = new LinkedHashMap<String, Object>();
data.put("cooldown_until", deadline);
data.put("after_revision", saved.revision().toString());
if (cast.slot() >= 0) data.put("slot", cast.slot());
data.put("stage", "cooldown_committed");
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
if (cast.abilityId().equals("quickstep")) {
data.put("intended_distance", definition.parameters().get("distance").toPlainString());
for (String key : java.util.List.of("from_location", "intended_direction", "accepted_velocity",
"native_velocity_status"))
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
}
if (cast.abilityId().equals("longbow")) {
data.put("draw_ticks", cast.heldTicks());
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
definition.parameters().get("full-draw-ticks").intValueExact()).stripTrailingZeros().toPlainString());
}
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
} catch (RuntimeException ignored) { /* Capturing metadata cannot refuse a saved cast. */ }
}
public void resolved(Cast cast, String outcome, String reason) {
resolved(cast, outcome, reason, Map.of());
}
public void resolved(Cast cast, String outcome, String reason, Map<String, Object> evidence) {
if (cast == null) return;
var data = new LinkedHashMap<String, Object>(evidence);
data.put("reason", reason);
emit(cast, "ability.resolved", outcome, data);
}
public UUID effectApplied(Cast cast, String effectId, Entity target, Map<String, Object> evidence) {
Objects.requireNonNull(effectId);
Objects.requireNonNull(evidence);
UUID application = UUID.randomUUID();
if (cast != null) {
try {
var data = new LinkedHashMap<String, Object>(evidence);
data.put("effect_id", effectId);
data.put("application_id", application.toString());
if (target != null) {
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var place = target.getLocation();
if (place != null) data.put("target_location", locationData(place));
}
emit(cast, "effect.applied", "applied", data, application);
} catch (RuntimeException ignored) { /* An observation failure cannot interrupt an applied effect. */ }
}
return application;
}
public void acceptedDamage(Cast cast, Entity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (cast == null || !Double.isFinite(requested) || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(evidence);
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("requested_amount", requested);
data.put("actual_amount", actual);
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var targetAtApplication = target.getLocation();
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
data.put("direct_entity_id", direct.getUniqueId().toString());
data.put("direct_entity_type", entityType(direct));
emit(cast, "combat.damage", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
}
public void healed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter, UUID aidedSession,
CharacterRecord saved, double requested, double actual, double before, double after,
double maximum) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("after_revision", saved.revision().toString());
data.put("requested_amount", decimal(requested));
data.put("actual_amount", decimal(actual));
data.put("health_before", decimal(before));
data.put("health_after", decimal(after));
data.put("maximum_health", decimal(maximum));
emit(cast, "combat.healed", "acknowledged", data, application);
} catch (RuntimeException ignored) { /* Healing remains authoritative despite logging failure. */ }
}
private static String decimal(double value) {
if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite health observation");
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
public void cleansed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter,
UUID aidedSession, java.util.List<String> removed) {
if (cast == null || removed == null || removed.isEmpty()) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("self_target", aidedCharacter.equals(cast.context().characterId()));
data.put("removed_effects", java.util.List.copyOf(removed));
data.put("actual_count", Integer.toString(removed.size()));
emit(cast, "combat.cleansed", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
}
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
UUID aidedSession, Entity attacker, double incoming, double actual,
double remaining, UUID bondApplication) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
data.put("target_player_id", ally.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(ally.getLocation()));
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
if (cast.abilityId().equals("stone_skin")) {
var attackLocation = attacker.getLocation();
if (attackLocation != null) data.put("attacker_location", locationData(attackLocation));
}
data.put("incoming_amount", decimal(incoming));
data.put("actual_amount", decimal(actual));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
}
/** Hex owns outgoing prevention even when the protected recipient is not a player. */
public void outgoingPrevented(Cast cast, UUID effectApplication, Entity debuffedAttacker,
Entity protectedTarget, double incoming, double remaining, double actual,
Map<String, Object> recipientEvidence) {
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(recipientEvidence);
UUID application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("effect_application_id", effectApplication.toString());
data.put("causation_id", cast.actionId().toString());
data.put("debuffed_attacker_id", debuffedAttacker.getUniqueId().toString());
data.put("debuffed_attacker_type", entityType(debuffedAttacker));
data.put("attacker_location", locationData(debuffedAttacker.getLocation()));
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
data.put("protected_target_type", entityType(protectedTarget));
data.put("protected_location", locationData(protectedTarget.getLocation()));
data.put("incoming_amount", incoming);
data.put("remaining_amount", remaining);
data.put("actual_amount", actual);
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
}
/** Inspire reports actual accepted native absorption, not a displayed health grant. */
public void absorbed(Cast cast, UUID effectApplication, org.bukkit.entity.Player protectedTarget,
games.dmg.fantasystick.session.EquipmentSessions.Active aided, Entity attacker,
double incoming, double before, double after, double consumed, double remaining,
Location targetAtHit, Location attackerAtHit) {
if (cast == null || effectApplication == null || !Double.isFinite(consumed) || consumed <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
UUID application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("effect_application_id", effectApplication.toString());
data.put("causation_id", cast.actionId().toString());
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
data.put("protected_target_type", "PLAYER");
if (aided != null) {
data.put("protected_character_id", aided.character().toString());
data.put("protected_session_id", aided.session().toString());
}
if (targetAtHit != null) data.put("protected_location", locationData(targetAtHit));
if (attacker != null) {
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
if (attackerAtHit != null) data.put("attacker_location", locationData(attackerAtHit));
} else data.put("attacker_status", "not_applicable");
data.put("incoming_amount", decimal(incoming));
data.put("absorption_before", decimal(before));
data.put("absorption_after", decimal(after));
data.put("actual_amount", decimal(consumed));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native absorption and party rewards are independent of logging. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|| !Double.isFinite(bonus) || bonus <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("attacker_player_id", attacker.getUniqueId().toString());
if (attacking != null) {
data.put("attacker_character_id", attacking.character().toString());
data.put("attacker_session_id", attacking.session().toString());
}
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var location = target.getLocation();
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
data.put("base_amount", decimal(base));
data.put("actual_amount", decimal(bonus));
data.put("final_amount", decimal(finalDamage));
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
data.put("selection", "strongest_applicable_only");
emit(cast, "combat.boosted", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
}
private static String entityType(Entity entity) {
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
var type = entity.getType();
return type == null ? "unavailable" : type.name();
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
effectEnded(cast, application, effectId, reason, Map.of());
}
public void effectEnded(Cast cast, UUID application, String effectId, String reason,
Map<String, Object> evidence) {
if (cast == null) return;
try {
var details = new LinkedHashMap<String, Object>(evidence);
details.put("application_id", application.toString());
details.put("effect_id", effectId);
details.put("reason", reason);
emit(cast, "effect.ended", "ended", details, application);
} catch (RuntimeException ignored) { /* Native cleanup is not conditional on audit evidence. */ }
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
emit(cast, type, outcome, extra, null);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra, UUID application) {
try {
var data = new LinkedHashMap<String, Object>(extra);
data.put("action_kind", "ability_cast");
data.put("ability_id", cast.abilityId());
data.put("outcome", outcome);
if (cast.abilityId().equals("frost_shard") || cast.abilityId().equals("longbow"))
data.put("execution_id", cast.executionId().toString());
if (cast.targetId() != null) {
data.put("target_entity_id", cast.targetId().toString());
data.put("target_type", cast.targetType());
if (!cast.targetLocation().isEmpty()) data.put("target_location", cast.targetLocation());
}
var identity = application == null ? cast.actionId() : application;
sink.accept(new EventInput(stable(identity, type), cast.actionId(),
type.equals("action.observed") ? cast.requestedAt() : clock.instant(), type, cast.context(), data));
} catch (RuntimeException ignored) { /* A writer failure cannot authorize or undo gameplay. */ }
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
}
private Map<String, Object> locationData(Location value) {
return Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private static UUID stable(UUID identity, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:ability-cast:" + identity + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Authorized ability-setting reads and acknowledged updates; no write authority. */
public final class AbilityConfigEventBridge {
public record Actor(String kind, UUID playerId, String playerName) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityConfigEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
/** Capture account identity on the command thread, never on the file worker. */
public Actor capture(CommandSender sender) {
try {
if (sender instanceof Player player) return new Actor("player", player.getUniqueId(), player.getName());
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null, null);
} catch (RuntimeException ignored) {
return null; // Omit uncertain identity, never refuse the authorized action.
}
}
public void read(Actor actor, String abilityId, String key, String value) {
emit("config.read", actor, abilityId, key, null, value);
}
public void changed(Actor actor, String abilityId, String key, String before, String after) {
emit("config.changed", actor, abilityId, key, before, after);
}
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("ability_id", abilityId);
data.put("key", key);
if (before != null) data.put("before", before);
data.put("after", after);
data.put("application", "future_casts_only");
data.put("outcome", "acknowledged");
var action = UUID.randomUUID();
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
var context = actor.playerId() == null ? null
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
}
}
@@ -0,0 +1,54 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned exact ability-refund outcomes; never an authorization or refund authority. */
public final class AbilityEventBridge {
public record Action(UUID actionId,EventInput.Context context,String ability,boolean learn,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(ability);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public AbilityEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,String ability){return begin(input,before,ability,false);}
public Action begin(ControlInput input,CharacterRecord before,String ability,boolean learn){
var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),ability,learn,before);
emit("action.observed",action,"observed",null,null,null);return action;
}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,String reason){refused(player,active,before,ability,false,reason);}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,String ability,boolean learn,String reason){
var action=new Action(UUID.randomUUID(),context(player,active,before.name()),ability,learn,before);emit("action.observed",action,"observed",null,null,null);emit("action.resolved",action,"refused",reason,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null);}
public void resolved(Action action,AbilityEngine.Mutation mutation){
if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutationStatus.APPLIED&&mutation.record()!=null){
emit("action.resolved",action,"accepted","acknowledged",mutation.record(),"acknowledged");
if(action.learn()){emit("ability.learned",action,"accepted","acknowledged",mutation.record(),"acknowledged");emit("ability.assigned",action,"accepted","acknowledged",mutation.record(),"acknowledged");}
else emit("ability.unlearned",action,"accepted","acknowledged",mutation.record(),"acknowledged");
}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record(),null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,String nativePublication){
var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"ability_learn":"ability_unlearn");data.put("ability_id",action.ability());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("outcome",outcome);
var beforeCooldown=before.cooldowns().get(action.ability());if(beforeCooldown!=null)data.put("cooldown_before",beforeCooldown.toString());
if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());var afterCooldown=after.cooldowns().get(action.ability());if(afterCooldown!=null)data.put("cooldown_after",afterCooldown.toString());if(action.learn()){data.put("ability_slot",after.loadout().indexOf(action.ability()));data.put("slot_index_base",0);}else{var cleared=new ArrayList<Integer>();for(int slot=0;slot<before.loadout().size();slot++)if(action.ability().equals(before.loadout().get(slot))&&(slot>=after.loadout().size()||!action.ability().equals(after.loadout().get(slot))))cleared.add(slot);data.put("cleared_slot_indexes",cleared);data.put("slot_index_base",0);}}
if(nativePublication!=null){data.put("native_publication",nativePublication);if(!action.learn())data.put("owned_cleanup","invalidation_acknowledged_close_attempted");}
if(reason!=null)data.put(type.startsWith("ability.")?"native_boundary":"reason",reason);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String characterName){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),characterName,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:ability-mutation:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,197 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.AdminReviveSettings;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Real administrator Tail restoration and acknowledged selection-timeout configuration. */
public final class AdminRevivalEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context, UUID id) { }
public record Selection(Actor actor, UUID targetId, String targetName,
EventInput.Location targetLocation,
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
public AdminRevivalEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "admin-revival-event-intents",
java.util.Set.of("character.revived", "config.changed"), sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(), safeLocation(player)),
player == null ? null : player.getUniqueId());
} catch (RuntimeException unavailable) { return null; }
}
public Selection selection(Player admin, Player target) {
try { return new Selection(actor(admin), target.getUniqueId(), target.getName(),
safeLocation(target), sessions.active(target.getUniqueId()).orElse(null)); }
catch (RuntimeException unavailable) { return null; }
}
public void revived(Selection selection, RevivalStore.Reservation reservation,
CharacterRecord dead, CharacterRecord restored, Player target) {
if (selection == null || selection.actor() == null) return;
try {
var data = revivalData(reservation, restored, target);
data.put("revision_before", dead.revision().toString());
data.put("death_time", dead.diedAt().toString());
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
data.put("recovery_reconciliation", false);
location(data, "target_location", selection.targetLocation());
targetSession(data, selection.targetSession());
var actor = selection.actor();
data.put("admin_player_id", selection.actor().id().toString());
data.put("admin_player_name", actor.context().playerName());
if (actor.context().location() == null) data.put("actor_location_status", "unavailable");
durable("character.revived", reservation.id(), actor.context(), data);
} catch (RuntimeException ignored) { /* Audit failure cannot consume or restore a Tail. */ }
}
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
if (!reservation.administrative()) return;
try {
var data = revivalData(reservation, restored, target);
data.put("actor_kind", "system");
data.put("admin_player_id", reservation.casterCharacter().toString());
data.put("admin_name_status", "unavailable");
data.put("actor_location_status", "unavailable");
data.put("revision_before", restored.revision().subtract(BigInteger.ONE).toString());
data.put("death_time", restored.diedAt().toString());
data.put("death_age_seconds", Long.toString(Math.max(0,
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
data.put("recovery_reconciliation", true);
location(data, "target_location", safeLocation(target));
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
durable("character.revived", reservation.id(), null, data);
} catch (RuntimeException ignored) { }
}
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
var data = new LinkedHashMap<String, Object>();
data.put("revival_kind", "admin_tail");
data.put("revival_operation_id", reservation.id().toString());
data.put("target_player_id", reservation.targetPlayer().toString());
data.put("target_player_name", target.getName());
data.put("character_id", restored.id().toString());
data.put("character_name", restored.name());
data.put("death_id", reservation.death().toString());
data.put("revision_after", restored.revision().toString());
data.put("tail_slot", reservation.sourceSlot());
data.put("tail_custody_id", reservation.death().toString());
data.put("sheet_slot", reservation.sourceAmount() == 1 ? reservation.sourceSlot() : reservation.destinationSlot());
data.put("source_quantity_before", Integer.toString(reservation.sourceAmount()));
data.put("source_quantity_after", Integer.toString(reservation.sourceAmount() - 1));
data.put("restored_health", restored.health().toPlainString());
data.put("maximum_health", restored.maximumHealth().toPlainString());
data.put("cooldowns_retained", true);
data.put("native_delivery", "acknowledged");
data.put("outcome", "acknowledged");
return data;
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var data = config(actor);
data.put("effective", effective);
data.put("outcome", "acknowledged_read");
emit(UUID.randomUUID(), "config.read", actor.context(), data);
}
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
if (actor == null) return;
var data = config(actor);
data.put("before", before);
data.put("after", saved.value());
data.put("effective", saved.value());
data.put("outcome", "acknowledged");
durable("config.changed", UUID.randomUUID(), actor.context(), data);
}
private static Map<String, Object> config(Actor actor) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("key", AdminReviveSettings.KEY);
data.put("applies_to", "later_selections");
if (actor.context() == null) data.put("location_status", "not_applicable");
else if (actor.context().location() == null) data.put("location_status", "unavailable");
return data;
}
private static void targetSession(Map<String, Object> data,
games.dmg.fantasystick.session.EquipmentSessions.Active active) {
if (active == null) { data.put("target_session_status", "no_active_session"); return; }
data.put("target_session_id", active.session().toString());
data.put("target_active_character_id", active.character().toString());
data.put("target_session_status", "captured");
}
private void location(Map<String, Object> data, String key, EventInput.Location value) {
if (value == null) { data.put(key + "_status", "unavailable"); return; }
data.put(key, Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(),
"captured_at", value.capturedAt().toString(), "quality", value.quality()));
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
private EventInput.Location location(Location value) {
try {
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private void durable(String type, UUID action, EventInput.Context context, Map<String, Object> data) {
try { outbox.accepted(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
catch (RuntimeException ignored) { }
}
private void emit(UUID action, String type, EventInput.Context context, Map<String, Object> data) {
try { sink.apply(new EventInput(id(action, type), action, clock.instant(), type, context, data)); }
catch (RuntimeException ignored) { }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:admin-revival:" + action + ":" + kind)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Immutable server-thread snapshots for the owned bound-stick input and its native hit. */
public final class BasicAttackEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public BasicAttackEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action observed(ControlInput input, CharacterRecord record, Entity target, Instant at,
Instant ready, Double requested) {
try {
var player = input.player();
var context = new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : input.session().session(), location(player.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "basic_attack");
data.put("entry_point", "bound_slot_2_attack");
data.put("slot_index", input.slot());
data.put("control_kind", input.kind().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", player.getUniqueId().toString());
data.put("source_character_id", input.session().character().toString());
data.put("source_session_id", input.session().session().toString());
if (record != null) data.put("strength", record.stats().get(games.dmg.fantasystick.progression.Stat.STR).toString());
else data.put("strength_status", "unavailable");
data.put("ready_deadline", (ready == null ? at : ready).toString());
if (ready == null) data.put("ready_deadline_status", "ready_at_observation_no_prior_hit");
if (requested != null) data.put("requested_amount", Double.toString(requested));
else data.put("requested_amount_status", "not_computed");
data.put("prior_cancelled", false);
if (target != null && target.getUniqueId() != null) data.put("target_entity_id", target.getUniqueId().toString());
if (target instanceof org.bukkit.entity.Player) data.put("target_entity_type", "player");
else if (target != null && target.getType() != null)
data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT));
var sourceLocation = location(player.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(UUID.randomUUID(), at, context, Map.copyOf(data));
var values = new LinkedHashMap<String, Object>(data);
values.put("outcome", "observed");
emit(action, "action.observed", "observed", at, values);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean nativePriorCancelled,
boolean finalCancelled, Double finalAmount, Instant nextReady) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("native_prior_cancelled", nativePriorCancelled);
values.put("final_cancelled", finalCancelled);
if (finalAmount != null) values.put("final_amount", Double.toString(finalAmount));
else values.put("final_amount_status", "unavailable");
if (nextReady != null) values.put("next_ready_deadline", nextReady.toString());
emit(action, "action.resolved", "resolved", clock.instant(), values);
}
public void acceptedDamage(Action action, EntityDamageByEntityEvent event, Instant nextReady) {
if (action == null) return;
try {
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", "accepted");
values.put("reason", "native_positive_hit");
values.put("final_cancelled", false);
values.put("final_amount", Double.toString(event.getFinalDamage()));
values.put("next_ready_deadline", nextReady.toString());
var target = event.getEntity();
if (target instanceof org.bukkit.entity.Player player) values.put("target_player_id", player.getUniqueId().toString());
var captured = location(target.getLocation());
if (captured != null) values.put("target_location", coordinates(captured));
else values.put("target_location_status", "unavailable");
emit(action, "combat.damage", "damage", clock.instant(), values);
} catch (RuntimeException ignored) { }
}
private EventInput.Location location(Location nativeLocation) {
return nativeLocation == null || nativeLocation.getWorld() == null ? null
: new EventInput.Location(nativeLocation.getWorld().getUID(), nativeLocation.getWorld().getName(),
nativeLocation.getX(), nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "exact");
}
private Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(), "x", value.x(),
"y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Instant time, Map<String, Object> values) {
try {
sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:basic-attack:" + action.id() + ':'
+ stage).getBytes(StandardCharsets.UTF_8)), action.id(), time, type, action.actor(), values));
} catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,130 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
public final class BookEventBridge {
public record Navigation(int sourceVolumeStart, int sourceNavigationPage, int destinationVolumeStart) {
public Navigation {
if (sourceVolumeStart < 0 || sourceNavigationPage < 1 || sourceNavigationPage > 100
|| destinationVolumeStart < 0 || sourceVolumeStart == destinationVolumeStart)
throw new IllegalArgumentException("Invalid book volume navigation");
}
}
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
int offset, BigInteger requestedRevision, Navigation navigation) {
public Request {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
if (offset < -1) throw new IllegalArgumentException("Invalid book offset");
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public BookEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Request request(ControlInput input, CharacterRecord current, int offset) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
return request(input, current, offset, null);
}
public Request request(ControlInput input, CharacterRecord current, int offset, Navigation navigation) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
var request = new Request(UUID.randomUUID(), context(input, current.name()),
navigation == null ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision(), navigation);
emit("action.observed", request, "observed", null, null, null, null);
return request;
}
public void refused(Request request, String reason) {
emit("action.resolved", request, "refused", reason, null, null, null);
}
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current, String actionKind, String reason) {
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision(),null);
emit("action.observed",request,"observed",null,null,null,null);
refused(request,reason);
}
public void failed(Request request, String reason) {
emit("action.resolved", request, "failed", reason, null, null, null);
}
public void opened(Request request, CharacterRecord loaded, int presentedPages, int presentedVolumes) {
Objects.requireNonNull(loaded);
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
presentedVolumes);
emit(request.navigation() == null ? "character_book.opened" : "character_book.navigated", request,
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
}
private void emit(String type, Request request, String outcome, String reason, BigInteger loadedRevision,
Integer pages, Integer volumes) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
data.put("section", request.navigation() == null ? "overview" : "volume_navigation");
data.put("control_kind", "book"); data.put("control_slot", "0");
if (request.navigation() != null) {
var navigation = request.navigation();
data.put("source_volume_start", Integer.toString(navigation.sourceVolumeStart()));
data.put("source_navigation_page", navigation.sourceNavigationPage());
data.put("destination_volume_start", Integer.toString(navigation.destinationVolumeStart()));
data.put("destination_page", "1");
data.put("destination_section", navigation.destinationVolumeStart() == 0 ? "overview" : "volume_start");
data.put("volume_direction", navigation.destinationVolumeStart() > navigation.sourceVolumeStart()
? "next" : "previous");
}
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
if (loadedRevision != null) data.put("loaded_revision", loadedRevision.toString());
if (pages != null) data.put("presented_pages", pages);
if (volumes != null) data.put("presented_volumes", volumes);
try {
sink.accept(new EventInput(id(request.actionId(), type), request.actionId(), clock.instant(), type,
request.context(), Map.copyOf(data)));
} catch (RuntimeException ignored) {
// Observability cannot change book authorization, rendering or native opening.
}
}
private EventInput.Context context(ControlInput input, String characterName) {
return context(input.player(),input.session(),characterName);
}
private EventInput.Context context(org.bukkit.entity.Player player,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
String characterName) {
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
characterName, active.session(), location(player.getLocation()));
}
private EventInput.Location location(Location location) {
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID action, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:book:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,246 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.nio.file.Path;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captures actual identity-creating and identity-changing native actions, never previews. */
public final class CharacterIdentityEventBridge implements AutoCloseable {
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
private final Function<UUID, Optional<UUID>> activeSession;
private static final int MAX_RECENT_HOLDERS = 8192;
private final Map<UUID, Holder> lastObserved = new LinkedHashMap<>(16, .75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Holder> eldest) {
return size() > MAX_RECENT_HOLDERS; // Eviction is uncertainty, never an invented transfer.
}
};
private record Holder(UUID player, UUID session, EventInput.Location entryLocation) { }
public CharacterIdentityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Function<UUID, Optional<UUID>> activeSession,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.activeSession = Objects.requireNonNull(activeSession);
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed", "character.issued"),
sink, Objects.requireNonNull(failures));
}
public void replay() { outbox.replay(); }
@Override public void close() { outbox.close(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
/** A later acknowledged activation proves a transfer only if we observed a different prior holder. */
public void entered(Player player, UUID session, CharacterRecord record) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
Holder previous;
synchronized (lastObserved) {
previous = lastObserved.put(record.id(), new Holder(actor.playerId(), session, actor.location()));
}
if (previous == null || previous.player().equals(actor.playerId())) return;
var action = id(previous.session(), session.toString());
var values = new LinkedHashMap<String, Object>();
values.put("character_id", record.id().toString());
values.put("generation", record.sheetGeneration().toString());
values.put("from_player_id", previous.player().toString());
values.put("to_player_id", actor.playerId().toString());
values.put("from_session_id", previous.session().toString());
values.put("to_session_id", session.toString());
values.put("observation_boundary", "acknowledged_activation");
values.put("custody_quality", "activation_after_prior_holder");
if (previous.entryLocation() != null) values.put("from_location", locationMap(previous.entryLocation(), "cached_at_previous_activation"));
else values.put("from_location_status", "unavailable");
if (actor.location() != null) values.put("to_location", locationMap(actor.location(), "exact"));
else values.put("to_location_status", "unavailable");
values.put("outcome", "observed");
emit("character.transfer_observed", action, clock.instant(), actor, values, "transfer_observed");
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
}
public record Issue(UUID operation, String actorKind, EventInput.Context actor,
UUID targetPlayer, String targetName) { }
/** Capture the administrator before asynchronous saving; no second admin.action is produced. */
public Issue issue(CommandSender sender, Player target) {
try {
var player = sender instanceof Player actor ? actor : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
EventInput.Context context = player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), null, null, null, safeLocation(player));
return new Issue(UUID.randomUUID(), kind, context, target.getUniqueId(), target.getName());
} catch (RuntimeException ignored) { return null; }
}
/** The saved character and exact native inventory slot were acknowledged before this call. */
public void issued(Issue issue, Player target, CharacterRecord record, int slot) {
if (issue == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", issue.actorKind());
data.put("issue_operation_id", issue.operation().toString());
data.put("target_player_id", issue.targetPlayer().toString());
data.put("target_player_name", issue.targetName());
data.put("character_id", record.id().toString());
data.put("character_name", record.name());
data.put("revision", record.revision().toString());
data.put("generation", record.sheetGeneration().toString());
data.put("starting_level", Integer.toString(record.level()));
data.put("starting_unspent_points", record.unspentPoints().toString());
data.put("starting_health", record.health().toPlainString());
data.put("starting_coins", record.coins().toString());
var stats = new LinkedHashMap<String, Object>();
record.stats().forEach((stat, value) -> stats.put(stat.name().toLowerCase(java.util.Locale.ROOT), value.toString()));
data.put("starting_stats", stats);
data.put("delivered_slot", slot);
data.put("delivery_outcome", "native_inventory_acknowledged");
data.put("outcome", "acknowledged_delivered");
if (issue.actor() == null) data.put("actor_location_status", "not_applicable");
else if (issue.actor().location() == null) data.put("actor_location_status", "unavailable");
var targetLocation = safeLocation(target);
if (targetLocation != null) data.put("target_location", locationMap(targetLocation, "exact"));
else data.put("target_location_status", "unavailable");
durable("character.issued", issue.operation(), clock.instant(), issue.actor(), data, "issued");
} catch (RuntimeException ignored) { /* Audit cannot change acknowledged native issuance. */ }
}
private EventInput.Location safeLocation(Player player) {
try { return location(player.getLocation()); }
catch (RuntimeException unavailable) { return null; }
}
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
UUID holderSession) { }
public Craft craft(Player player, String route, int quantity) {
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var craft = new Craft(UUID.randomUUID(), clock.instant(), context, route, quantity,
activeSession.apply(context.playerId()).orElse(null));
emit("action.observed", craft.action(), craft.time(), context, Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", route, "quantity", Integer.toString(quantity), "outcome", "observed"), "observed");
return craft;
} catch (RuntimeException ignored) { return null; }
}
public record Rename(UUID action, Instant time, EventInput.Context actor, UUID character,
String requestedName, UUID holderSession) { }
public Rename rename(Player player, UUID character, String requestedName) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var action = new Rename(UUID.randomUUID(), clock.instant(), actor, character, requestedName,
activeSession.apply(actor.playerId()).orElse(null));
emit("action.observed", action.action(), action.time(), actor, Map.of(
"action_kind", "character_rename", "character_id", character.toString(),
"outcome", "observed"), "observed");
return action;
} catch (RuntimeException ignored) { return null; }
}
public void renamed(Rename action, CharacterRecord before, CharacterRecord after) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_rename");
values.put("anvil_action_id", action.action().toString());
holder(values, action.actor().playerId(), action.holderSession());
values.put("character_id", after.id().toString());
values.put("generation", after.sheetGeneration().toString());
values.put("before_name", before.name());
values.put("after_name", after.name());
values.put("revision_before", before.revision().toString());
values.put("revision_after", after.revision().toString());
values.put("native_item_outcome", "unverified");
values.put("outcome", "acknowledged");
durable("character.renamed", action.action(), action.time(), action.actor(), values, "renamed");
}
public void resolved(Rename action, String outcome, String reason) {
if (action == null) return;
emit("action.resolved", action.action(), clock.instant(), action.actor(), Map.of(
"action_kind", "character_rename", "character_id", action.character().toString(),
"outcome", outcome, "reason", reason), "resolved");
}
public void created(Craft craft, CharacterRecord record, int index) {
if (craft == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_craft");
values.put("craft_operation_id", craft.action().toString());
holder(values, craft.actor().playerId(), craft.holderSession());
values.put("character_id", record.id().toString());
values.put("character_name", record.name());
values.put("generation", record.sheetGeneration().toString());
values.put("revision", record.revision().toString());
values.put("recipe_id", "fantasystick:character_sheet");
values.put("quantity", Integer.toString(craft.quantity()));
values.put("index", index);
values.put("delivery_route", craft.route());
values.put("outcome", "acknowledged_delivered");
durable("character.created", craft.action(), craft.time(), craft.actor(), values, record.id().toString());
}
public void resolved(Craft craft, String outcome, String reason, int delivered) {
if (craft == null) return;
emit("action.resolved", craft.action(), clock.instant(), craft.actor(), Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", craft.route(), "quantity", Integer.toString(craft.quantity()),
"delivered", Integer.toString(delivered), "outcome", outcome, "reason", reason), "resolved");
}
private void durable(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { outbox.accepted(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* Auditing never changes acknowledged gameplay. */ }
}
private void emit(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { sink.apply(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* The audit cannot change native craft outcomes. */ }
}
private static void holder(Map<String, Object> data, UUID player, UUID session) {
data.put("holder_player_id", player.toString());
data.put("holder_session_status", session == null ? "no_active_session" : "active_at_request");
if (session != null) data.put("holder_session_id", session.toString());
}
private static Map<String, Object> locationMap(EventInput.Location location, String quality) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
"x", location.x(), "y", location.y(), "z", location.z(),
"captured_at", location.capturedAt().toString(), "quality", quality);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID action, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:character-identity:" + action + ':' + stage)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,26 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.CoinRules;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated character coin initialization, final-death loss and configuration records. */
public final class CoinEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public CoinEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
private void emit(UUID action,String type,Map<String,Object> data){try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ManagedEncounterOrigin;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Player;
/** Failure-isolated, single-owner recipient resolution for combat XP and coin shares. */
public final class CombatRewardEventBridge {
public record Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool){
public Capture{Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public CombatRewardEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void resolved(UUID operation,UUID character,BigInteger allocatedExperience,BigInteger allocatedCoins,
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
Player player,String nativeSync,String feedback){
if(award==null||award.record().isEmpty()||before==null)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());try{sink.accept(new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){if(active==null||player==null)return null;var location=player.getLocation();if(location==null||location.getWorld()==null)return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),null);return new EventInput.Context(active.player(),player.getName(),active.character(),characterName,active.session(),new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact"));}
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,286 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.session.DownedEpisode;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated event adapter for durable downed, rescue, death and protection boundaries. */
public final class DownedEventBridge {
private final Consumer<EventInput> sink;
private final Clock clock;
public DownedEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
/** Re-emits stable IDs from durable proof; at-least-once consumers deduplicate by source and ID. */
public void reconcile(Collection<DownedEpisode> episodes) {
for (var episode : List.copyOf(episodes)) {
downed(episode);
if (episode.phase() != DownedEpisode.Phase.RESOLVED) continue;
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityCast(episode, episode.decision());
}
if (!"phoenix_down".equals(decisionSource(episode)) || episode.published()) resolved(episode);
if (episode.decision() != null && "revive".equals(episode.decision().source())) {
abilityResolved(episode);
}
if (episode.outcome() == DownedEpisode.Outcome.FINAL_DEATH && episode.published()) {
finalDeathRecorded(episode);
}
if (episode.published()) outcomePublished(episode);
if (episode.outcome() == DownedEpisode.Outcome.REVIVED && episode.protectionUntil() != null
&& !clock.instant().isBefore(episode.protectionUntil())) protectionEnded(episode);
}
}
public void downed(DownedEpisode episode) {
var data = common(episode);
data.put("stage", "acknowledged");
data.put("outcome", "downed");
emit(stable(episode.id(), "downed"), episode.id(), episode.downedAt(), "character.downed", data);
}
public void resolved(DownedEpisode episode) {
if (episode.phase() != DownedEpisode.Phase.RESOLVED) return;
var data = common(episode);
String outcome = decisionSource(episode);
data.put("stage", "decision_acknowledged");
data.put("outcome", outcome);
data.put("resolved_at", episode.resolvedAt());
data.put("publication_outcome", "pending");
if (episode.protectionUntil() != null) data.put("protection_until", episode.protectionUntil());
if (!episode.finalRewards().isEmpty()) {
data.put("final_rewards", episode.finalRewards().stream().map(reward -> Map.of(
"character_id", reward.character().toString(), "experience", reward.experience().toString(),
"coins", reward.coins().toString())).toList());
}
var decision = episode.decision();
if (decision != null) {
if (decision.actorPlayer() != null) {
data.put("actor_kind", "player");
data.put("actor_player_id", decision.actorPlayer().toString());
data.put("actor_player_name", decision.actorPlayerName());
}
if (decision.actorCharacter() != null) {
data.put("actor_character_id", decision.actorCharacter().toString());
data.put("actor_character_name", decision.actorCharacterName());
}
if (decision.actorSession() != null) data.put("actor_session_id", decision.actorSession().toString());
if (decision.actorCharacter() != null && !decision.actorCharacter().equals(episode.character())) {
data.put("actor_location_status", "unavailable");
}
if (decision.cooldownUntil() != null) data.put("cooldown_until", decision.cooldownUntil());
if (decision.finalRange() != null) data.put("final_range", decision.finalRange());
if (decision.lineOfSight() != null) data.put("line_of_sight", decision.lineOfSight());
if (decision.phoenix() != null) {
var phoenix=decision.phoenix();data.put("selection_entry_point",phoenix.entryPoint());data.put("item_id","phoenix-down");
data.put("quantity_before",phoenix.quantityBefore().toString());data.put("quantity_after",phoenix.quantityAfter().toString());
data.put("health_before",phoenix.healthBefore().toPlainString());data.put("health_after",phoenix.healthAfter().toPlainString());data.put("maximum_health",phoenix.maximum().toPlainString());
data.put("item_use_operation_id",decision.actionId().toString());
}
}
emit(stable(episode.id(), "resolved"), episode.outcomeOperation(), episode.resolvedAt(),
"character.downed_resolved", actor(decision), data);
}
public void finalDeathRecorded(DownedEpisode episode) {
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
var data = common(episode);
data.put("stage", "death_recorded");
data.put("outcome", "acknowledged");
data.put("effective_death_time", episode.resolvedAt());
data.put("death_id", episode.id().toString());
data.put("tail_outcome", "pending");
data.put("return_outcome", "pending");
data.put("reward_outcome", "pending");
if (!episode.finalRewards().isEmpty()) {
data.put("final_rewards", episode.finalRewards().stream().map(reward -> Map.of(
"character_id", reward.character().toString(), "experience", reward.experience().toString(),
"coins", reward.coins().toString())).toList());
}
data.put("consequence_operation_id", episode.outcomeOperation().toString());
emit(stable(episode.id(), "died"), episode.outcomeOperation(), episode.resolvedAt(),
"character.died", data);
}
/** Physical return is a separate observed native result, never proof of final-death commitment or Tail delivery. */
public void returnAttempted(DownedEpisode episode, String outcome) {
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
var data = common(episode);
data.put("action_kind", "final_death_return");
data.put("death_id", episode.id().toString());
data.put("effective_death_time", episode.resolvedAt());
data.put("stage", "native_return_attempt");
data.put("outcome", outcome);
emit(stable(episode.id(), "return"), episode.outcomeOperation(), clock.instant(), "action.resolved", data);
}
public void outcomePublished(DownedEpisode episode) {
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
var data = common(episode);
data.put("action_kind", episode.outcome() == DownedEpisode.Outcome.REVIVED
? episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_native_application":"downed_revival_native_application" : "final_death_tail_delivery");
data.put("stage", "native_effect_acknowledged");
data.put("outcome", "completed");
emit(stable(episode.id(), "published"), episode.outcomeOperation(), episode.resolvedAt(),
"action.resolved", data);
if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&episode.decision()!=null&&"revive".equals(episode.decision().source()))protectionApplied(episode);
}
private void protectionApplied(DownedEpisode episode){var data=common(episode);data.put("effect_id","revive_downed_invulnerability");data.put("application_id",stable(episode.id(),"revive-protection").toString());data.put("stage","native_application_acknowledged");data.put("outcome","applied");data.put("protection_until",episode.protectionUntil());data.put("end_policy","persisted_absolute_deadline");emit(stable(episode.id(),"protection-applied"),episode.outcomeOperation(),episode.resolvedAt(),"effect.applied",data);}
public void prevented(DownedEpisode episode, String reason, double amount) {
if (!Double.isFinite(amount) || amount < 0) return;
var now = clock.instant();
var action = UUID.randomUUID();
var data = common(episode);
data.put("stage", "native_damage");
data.put("outcome", "prevented");
data.put("reason", Objects.requireNonNull(reason));
data.put("prevented_amount", Double.toString(amount));
emit(UUID.randomUUID(), action, now, "combat.prevented", data);
}
public void protectionEnded(DownedEpisode episode) {
if (episode.outcome() != DownedEpisode.Outcome.REVIVED || episode.protectionUntil() == null) return;
var data = common(episode);
data.put("effect_id", episode.decision()!=null&&"phoenix_down".equals(episode.decision().source())?"phoenix_down_invulnerability":"revive_downed_invulnerability");
data.put("stage", "expiry");
data.put("outcome", "expired");
emit(stable(episode.id(), "protection-ended"), episode.outcomeOperation(), episode.protectionUntil(),
"effect.ended", data);
}
public void abilityCast(DownedEpisode episode, DownedEpisode.Decision decision) {
var data = common(episode);
data.put("actor_kind", "player");
data.put("ability_id", "revive");
data.put("stage", "cooldown_committed");
data.put("outcome", "accepted");
data.put("cooldown_until", decision.cooldownUntil());
data.put("actor_player_id", decision.actorPlayer().toString());
data.put("actor_character_id", decision.actorCharacter().toString());
data.put("actor_session_id", decision.actorSession().toString());
data.put("final_range", decision.finalRange());
data.put("line_of_sight", decision.lineOfSight());
emit(stable(decision.actionId(), "ability-cast"), decision.actionId(), decision.castAt(),
"ability.cast", actor(decision), data);
}
public void abilityResolved(DownedEpisode episode) {
var decision = episode.decision();
if (decision == null || !"revive".equals(decision.source())) return;
abilityResolved(episode, decision, "decision_won", episode.resolvedAt());
}
public void abilityResolved(DownedEpisode episode, DownedEpisode.Decision decision,
String outcome, Instant time) {
var data = common(episode);
if (decision != null && decision.actorPlayer() != null) {
data.put("actor_kind", "player");
data.put("actor_player_id", decision.actorPlayer().toString());
data.put("actor_character_id", decision.actorCharacter().toString());
data.put("actor_session_id", decision.actorSession().toString());
}
data.put("ability_id", "revive");
data.put("stage", "downed_target_resolution");
data.put("outcome", Objects.requireNonNull(outcome));
var actionId = decision == null ? episode.outcomeOperation() : decision.actionId();
emit(stable(actionId, "ability-resolved"), actionId, time, "ability.resolved", actor(decision), data);
}
public void configRead(String actorKind, UUID player, String playerName, String key, String value) {
config("config.read", actorKind, player, playerName, key, null, value);
}
public void configChanged(String actorKind, UUID player, String playerName, String key,
String before, String after) {
config("config.changed", actorKind, player, playerName, key, before, after);
}
private void config(String type, String actorKind, UUID player, String playerName, String key,
String before, String after) {
var now = clock.instant();
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", Objects.requireNonNull(actorKind));
if (player != null) data.put("actor_player_id", player.toString());
if (playerName != null) data.put("actor_player_name", playerName);
data.put("key", Objects.requireNonNull(key));
if (before != null) data.put("before", before);
data.put("after", Objects.requireNonNull(after));
data.put("application", key.equals("rare-phoenix-down-invulnerability-seconds")?"future_phoenix_rescues":key.startsWith("rare-price-phoenix-down")||key.startsWith("rare-stock-phoenix-down")?"next_rare_restock":"future_downed_outcomes");
data.put("outcome", "acknowledged");
EventInput.Context context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
emit(UUID.randomUUID(), action, now, type, context, data);
}
private LinkedHashMap<String, Object> common(DownedEpisode episode) {
Objects.requireNonNull(episode);
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", "system");
data.put("episode_id", episode.id().toString());
data.put("subject_character_id", episode.character().toString());
if(episode.characterName()!=null)data.put("subject_character_name",episode.characterName());
data.put("subject_player_id", episode.player().toString());
if(episode.playerName()!=null)data.put("subject_player_name",episode.playerName());
data.put("custody_session_id", episode.session().toString());
data.put("custody", "active_or_recoverable_session");
if (episode.characterRevision() != null) data.put("character_revision", episode.characterRevision().toString());
data.put("downed_at", episode.downedAt());
data.put("deadline", episode.expiresAt());
data.put("lethal_cause", episode.attribution().cause());
if (episode.attribution().attacker() != null) {
data.put("lethal_source_id", episode.attribution().attacker().toString());
}
data.put("player_defeat", episode.attribution().playerDefeat());
data.put("contributors", episode.attribution().contributors().stream().map(UUID::toString).toList());
data.put("victim_level", Integer.toString(episode.attribution().victimLevel()));
data.put("victim_coins", episode.attribution().victimCoins().toString());
data.put("location", location(episode.world(), episode.x(), episode.y(), episode.z(), episode.downedAt()));
data.put("location_role", "downed_subject_anchor");
data.put("entry_return", location(episode.entryWorld(), episode.entryX(), episode.entryY(),
episode.entryZ(), episode.downedAt()));
return data;
}
private static Map<String, Object> location(UUID world, double x, double y, double z, Instant capturedAt) {
return Map.of("world_id", world.toString(), "x", Double.toString(x), "y", Double.toString(y),
"z", Double.toString(z), "captured_at", capturedAt, "quality", "durable_native_capture");
}
private static String decisionSource(DownedEpisode episode) {
if (episode.decision() != null) return episode.decision().source();
if (episode.outcome() == DownedEpisode.Outcome.REVIVED) return "revive";
return episode.resolvedAt().equals(episode.expiresAt()) ? "timeout" : "accept_death";
}
private static EventInput.Context actor(DownedEpisode.Decision decision){if(decision==null||decision.actorPlayer()==null)return null;return new EventInput.Context(decision.actorPlayer(),decision.actorPlayerName(),decision.actorCharacter(),decision.actorCharacterName(),decision.actorSession(),null);}
private static UUID stable(UUID authority, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:" + authority + ':' + kind)
.getBytes(StandardCharsets.UTF_8));
}
private void emit(UUID id, UUID action, Instant time, String type, Map<String, Object> data) {
emit(id,action,time,type,null,data);
}
private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map<String,Object> data){
try {
sink.accept(new EventInput(id, action, time, type, context, data));
} catch (RuntimeException ignored) {
// Logging is observability only and cannot alter the durable gameplay decision.
}
}
}
@@ -0,0 +1,164 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.items.EquipmentStorage;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.nio.file.Path;
import java.util.Set;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes only proved native box custody and the owning acknowledged receipt boundary. */
public final class EquipmentEventBridge implements AutoCloseable {
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
public EquipmentEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder), "equipment-event-intents",
Set.of("equipment.packed", "equipment.restored", "equipment.recovery_resolved"), sink,
Objects.requireNonNull(failures));
}
public void replay() { outbox.replay(); }
@Override public void close() { outbox.close(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
public record Capture(EventInput.Context context, EquipmentStorage.Snapshot box, Instant observedAt,
UUID session, UUID character) { }
public record Action(UUID id, Instant time, EventInput.Context context, UUID session, UUID character,
String kind) { }
public Action action(Player player, UUID session, UUID character, String kind) {
try {
// A receipt proves the character UUID, not its current display name. Keep that UUID
// in the payload rather than inventing an incomplete character envelope context.
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var action = new Action(UUID.randomUUID(), clock.instant(), context, session, character, kind);
var values = actionFields(action);
values.put("outcome", "observed");
emitAction(action, "action.observed", "observed", values);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason) {
if (action == null) return;
var values = actionFields(action);
values.put("outcome", outcome);
values.put("reason", reason);
emitAction(action, "action.resolved", "resolved", values);
}
public void recovery(Action action, UUID session, UUID character, String priorPhase, String currentPhase,
String quality, String outcome, String reason, BigInteger receiptRevision) {
if (action == null) return;
var values = actionFields(action);
values.put("recovery_operation_id", action.id().toString());
values.put("session_id", session.toString());
values.put("character_id", character.toString());
values.put("prior_phase", priorPhase);
values.put("current_phase", currentPhase);
values.put("custody_quality", quality);
values.put("outcome", outcome);
values.put("reason", reason);
if (receiptRevision != null) values.put("receipt_revision", receiptRevision.toString());
try { outbox.accepted(new EventInput(id(action.id(), "recovery_resolved"), action.id(), clock.instant(),
"equipment.recovery_resolved", action.context(), values)); }
catch (RuntimeException ignored) { }
}
private LinkedHashMap<String, Object> actionFields(Action action) {
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", action.kind());
if (action.session() != null) values.put("session_id", action.session().toString());
if (action.character() != null) values.put("character_id", action.character().toString());
return values;
}
private void emitAction(Action action, String kind, String stage, Map<String, Object> values) {
try { sink.apply(new EventInput(id(action.id(), stage + ':' + values.get("outcome") + ':' + values.getOrDefault("reason", "")), action.id(),
stage.equals("observed") ? action.time() : clock.instant(), kind, action.context(), values)); }
catch (RuntimeException ignored) { }
}
public Capture capture(Player player, UUID session, CharacterRecord character, EquipmentStorage.Snapshot box) {
return capture(player, session, character.id(), character.name(), box);
}
/** Recovery can prove a receipt's character UUID without proving a current character name. */
public Capture capture(Player player, UUID session, UUID character, String characterName,
EquipmentStorage.Snapshot box) {
try {
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
characterName == null ? null : character, characterName,
characterName == null ? null : session, location(player.getLocation())),
box, clock.instant(), session, character);
} catch (RuntimeException ignored) { return null; }
}
public void packed(Capture captured, BigInteger revision) {
if (captured == null || revision == null) return;
var values = fields(captured, revision);
values.put("box_custody", "main_storage");
values.put("outcome", "acknowledged_packed");
emit(captured, "equipment.packed", "packed", values);
}
public void restored(Capture captured, BigInteger revision) {
if (captured == null || revision == null) return;
var values = fields(captured, revision);
values.put("displaced_head_handling", captured.box().displacedHead()
? captured.box().boxSource().equals("cursor") ? "cursor" : "freed_box_slot" : "none");
values.put("outcome", "acknowledged_restored");
emit(captured, "equipment.restored", "restored", values);
}
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
var box = captured.box();
var values = new LinkedHashMap<String, Object>();
values.put("session_id", captured.session().toString());
values.put("character_id", captured.character().toString());
values.put("equipment_operation_id", captured.session().toString());
values.put("receipt_revision", revision.toString());
values.put("slot_count", box.packedSlots());
values.put("slot_classes", box.slotClasses());
values.put("hotbar_slot_count", box.hotbarSlots());
values.put("armor_slot_count", box.armorSlots());
values.put("box_source", box.boxSource());
values.put("box_slot", box.boxSlot());
return values;
}
private void emit(Capture captured, String type, String phase, Map<String, Object> values) {
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.observedAt(),
type, captured.context(), values)); }
catch (RuntimeException ignored) { /* Audit failures cannot change native equipment custody. */ }
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:equipment:" + session + ':' + stage)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,195 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.WeakHashMap;
import org.bukkit.Location;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemHeldEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.vehicle.VehicleExitEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
/** Captures immutable generic native action context without changing event cancellation or gameplay. */
public final class EventCaptureListener implements Listener {
private record Captured(UUID action, EventInput.Context context, String kind, Map<String, Object> details) { }
private final LocalEventLog events;
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
/** Feature owners replace the generic incoming view with one richer application record. */
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
ownedDamage = ownedDamage.or(Objects.requireNonNull(owner));
}
public EventCaptureListener(LocalEventLog events, EquipmentSessions sessions, Clock clock) {
this.events = Objects.requireNonNull(events);
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void observe(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) return;
var snapshot = context(event.getPlayer()).orElse(null);
if (snapshot == null) return;
String kind = switch (event.getAction()) {
case RIGHT_CLICK_BLOCK, LEFT_CLICK_BLOCK, PHYSICAL -> "interact_block";
case RIGHT_CLICK_AIR, LEFT_CLICK_AIR -> "interact_item";
};
var details = new LinkedHashMap<String, Object>();
details.put("entry_point", "PlayerInteractEvent");
details.put("native_action", event.getAction().name());
details.put("hand", event.getHand().name());
details.put("initially_cancelled", event.isCancelled());
details.putAll(item(event.getItem()));
downing(event.getPlayer(), details, event.isCancelled());
if (event.getClickedBlock() != null) {details.put("target_type", event.getClickedBlock().getType().name());var target=event.getClickedBlock().getLocation();if(target!=null&&target.getWorld()!=null)details.put("target_location",locationMap(target));}
var value = new Captured(UUID.randomUUID(), snapshot, kind, Map.copyOf(details));
captured.put(event, value);
emit("action.observed", value, "observed", event.isCancelled());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void resolve(PlayerInteractEvent event) { resolveCaptured(event, event); }
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerInteractEntityEvent event){if(event.getHand()!=EquipmentSlot.HAND)return;var details=new LinkedHashMap<>(entity(event.getRightClicked(),"PlayerInteractEntityEvent"));details.put("hand",event.getHand().name());capture(event,event.getPlayer(),"interact_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerInteractEntityEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(InventoryClickEvent event){if(event.getWhoClicked() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point",event.getClass().getSimpleName());details.put("slot",event.getRawSlot());if(event.getView()!=null)details.put("container",event.getView().getType().name());details.put("click",event.getClick().name());details.put("inventory_action",event.getAction().name());details.putAll(item(event.getCurrentItem()));capture(event,player,"inventory_click",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(InventoryClickEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(InventoryDragEvent event){if(event.getWhoClicked() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","InventoryDragEvent");if(event.getView()!=null)details.put("container",event.getView().getType().name());details.put("slots",event.getNewItems().keySet().stream().sorted().toList());details.putAll(item(event.getOldCursor()));capture(event,player,"inventory_drag",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(InventoryDragEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerDropItemEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerDropItemEvent");details.putAll(item(event.getItemDrop().getItemStack()));capture(event,event.getPlayer(),"drop_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerDropItemEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(EntityPickupItemEvent event){if(event.getEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","EntityPickupItemEvent");details.put("remaining",event.getRemaining());details.putAll(item(event.getItem().getItemStack()));capture(event,player,"pickup_item",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(EntityPickupItemEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemConsumeEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerItemConsumeEvent");details.putAll(item(event.getItem()));capture(event,event.getPlayer(),"consume_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemConsumeEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event){capture(event,event.getPlayer(),"recipe_autofill",Map.of("entry_point","PlayerRecipeBookClickEvent","recipe",event.getRecipe().asString()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.player.PlayerArmorStandManipulateEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerArmorStandManipulateEvent");details.put("target_id",event.getRightClicked().getUniqueId().toString());details.putAll(item(event.getPlayerItem()));capture(event,event.getPlayer(),"armor_stand",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.player.PlayerArmorStandManipulateEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.block.BlockPlaceEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","BlockPlaceEvent");details.putAll(item(event.getItemInHand()));capture(event,event.getPlayer(),"place_item",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockPlaceEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerLaunchProjectileEvent");details.putAll(item(event.getItemStack()));capture(event,event.getPlayer(),"launch_projectile",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.entity.EntityShootBowEvent event){if(event.getEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","EntityShootBowEvent");details.putAll(item(event.getBow()));capture(event,player,"shoot_bow",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.entity.EntityShootBowEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(org.bukkit.event.block.BlockDispenseArmorEvent event){if(event.getTargetEntity() instanceof Player player){var details=new LinkedHashMap<String,Object>();details.put("entry_point","BlockDispenseArmorEvent");details.putAll(item(event.getItem()));capture(event,player,"dispense_armor",details,event);}}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockDispenseArmorEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PrePlayerAttackEntityEvent");details.put("target_id",event.getAttacked().getUniqueId().toString());details.put("will_attack",event.willAttack());details.putAll(item(event.getPlayer().getInventory().getItemInMainHand()));capture(event,event.getPlayer(),"attack_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){resolveCaptured(event,event);}
/** Observe the final accepted native damage event once; no damage or health mutation occurs here. */
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0) return;
try { if (ownedDamage.test(event)) return; }
catch (RuntimeException ignored) { /* Observation failure cannot change native damage. */ }
var snapshot = context(player).orElse(null);
if (snapshot == null) return;
var details = new LinkedHashMap<String, Object>();
details.put("target_player_id", player.getUniqueId().toString());
details.put("target_character_id", snapshot.characterId().toString());
details.put("cause", event.getCause().name());
details.put("amount", Double.toString(event.getFinalDamage()));
details.put("outcome", "accepted_native_event");
details.put("cancelled", false);
if (event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent by) {
details.put("source_entity_id", by.getDamager().getUniqueId().toString());
details.put("source_entity_type", by.getDamager().getType().name());
}
try {
var operation = UUID.randomUUID();
events.emit(new EventInput(operation, operation, clock.instant(), "combat.damage", snapshot, details));
} catch (RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemHeldEvent event){capture(event,event.getPlayer(),"held_slot",Map.of("entry_point","PlayerItemHeldEvent","previous_slot",event.getPreviousSlot(),"new_slot",event.getNewSlot()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerSwapHandItemsEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerTeleportEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleEnterEvent event){if(event.getEntered() instanceof Player player)capture(event,player,"vehicle_enter",entity(event.getVehicle(),"VehicleEnterEvent"),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleEnterEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleExitEvent event){if(event.getExited() instanceof Player player)capture(event,player,"vehicle_exit",entity(event.getVehicle(),"VehicleExitEvent"),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleExitEvent event){resolveCaptured(event,event);}
private Map<String,Object> entity(Entity entity,String entry){var details=new LinkedHashMap<String,Object>();details.put("entry_point",entry);details.put("target_id",entity.getUniqueId().toString());details.put("target_type",entity.getType().name());var target=entity.getLocation();if(target!=null&&target.getWorld()!=null)details.put("target_location",locationMap(target));return details;}
private void capture(Object event,Player player,String kind,Map<String,Object> details,Cancellable cancellation){var snapshot=context(player).orElse(null);if(snapshot==null)return;var copied=new LinkedHashMap<>(details);copied.put("initially_cancelled",cancellation.isCancelled());downing(player,copied,cancellation.isCancelled());var value=new Captured(UUID.randomUUID(),snapshot,kind,Map.copyOf(copied));captured.put(event,value);emit("action.observed",value,"observed",cancellation.isCancelled());}
private void resolveCaptured(Object event,Cancellable cancellation){var value=captured.remove(event);if(value!=null)emit("action.resolved",value,cancellation.isCancelled()?"cancelled":"accepted",cancellation.isCancelled());}
private void downing(Player player,Map<String,Object> details,boolean initiallyCancelled){
var found=sessions.downed(player.getUniqueId());
if(found!=null&&found.isPresent()){details.put("downing_id",found.orElseThrow().id().toString());details.put("guard_reason","downed");return;}
var kind=details.get("item_kind");
if(kind!=null&&!kind.equals("ordinary")&&!kind.equals("empty")){details.put("guard_reason","protected_item");return;}
details.put("guard_reason",initiallyCancelled?"prior_cancellation":"none");
}
private static Map<String,Object> item(ItemStack item){
var details=new LinkedHashMap<String,Object>();
if(item==null||item.getType().isAir()){details.put("item_kind","empty");return details;}
details.put("item_type",item.getType().name());
if(!item.hasItemMeta()){details.put("item_kind","ordinary");return details;}
var data=item.getItemMeta().getPersistentDataContainer();var keys=data.getKeys().stream().filter(key->key.getNamespace().equals("fantasystick")).map(NamespacedKey::getKey).collect(java.util.stream.Collectors.toSet());
String kind=keys.contains("sheet_version")?"character_sheet":keys.contains("storage_version")?"storage_box":keys.contains("fishing_quest")?"quest_fishing_rod":keys.contains("control_version")?"control":keys.contains("character_gear")?"character_gear":keys.contains("tail_version")?"tail":"ordinary";
details.put("item_kind",kind);
copy(data,details,"control_kind","control_kind");copy(data,details,"control_owner_id","item_owner_id");copy(data,details,"control_session_id","item_session_id");copy(data,details,"storage_owner_id","item_owner_id");copy(data,details,"storage_session_id","item_session_id");copy(data,details,"character_gear_owner","item_owner_id");copy(data,details,"character_gear_session","item_session_id");copy(data,details,"character_gear_slot","gear_slot");copy(data,details,"character_gear_tier","gear_tier");copy(data,details,"character_id","item_character_id");copy(data,details,"sheet_generation","sheet_generation");copy(data,details,"death_id","death_id");copy(data,details,"fishing_quest","quest_id");copy(data,details,"fishing_site","site_id");
return details;
}
private static void copy(org.bukkit.persistence.PersistentDataContainer data,Map<String,Object> details,String key,String output){var value=data.get(new NamespacedKey("fantasystick",key),PersistentDataType.STRING);if(value!=null&&value.length()<=128)details.put(output,value);}
private void emit(String type, Captured capturedAction, String outcome, boolean cancelled) {
var data = new LinkedHashMap<>(capturedAction.details());
data.put("action_kind", capturedAction.kind());
data.put("outcome", outcome);
data.put("cancelled", cancelled);
if (type.equals("action.resolved")) {
if (!cancelled) data.put("guard_reason", "accepted");
else if (Objects.equals(data.get("guard_reason"), "none")) data.put("guard_reason", "native_cancellation");
}
events.emit(new EventInput(UUID.randomUUID(), capturedAction.action(), clock.instant(), type,
capturedAction.context(), data));
}
private Optional<EventInput.Context> context(Player player) {
var active = sessions.active(player.getUniqueId()).orElse(null);
if (active == null) return Optional.empty();
var record = sessions.currentRecord(active).orElse(null);
if (record == null) return Optional.empty();
return Optional.of(new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), record.name(),
active.session(), location(player.getLocation())));
}
private EventInput.Location location(Location location) {
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
}
private Map<String,Object> locationMap(Location location){var value=new LinkedHashMap<String,Object>();value.put("world_id",location.getWorld().getUID().toString());value.put("world_name",location.getWorld().getName());value.put("x",location.getX());value.put("y",location.getY());value.put("z",location.getZ());return value;}
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick.events;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/** Immutable event input captured before leaving the native/gameplay thread. */
public record EventInput(UUID id, UUID actionId, Instant time, String type, Context context,
Map<String, Object> data) {
public EventInput {
Objects.requireNonNull(id);
Objects.requireNonNull(actionId);
Objects.requireNonNull(time);
if (type == null || !type.matches("[a-z][a-z0-9_.-]*")) throw new IllegalArgumentException("Invalid event type");
data = snapshotMap(Objects.requireNonNull(data));
}
private static Map<String, Object> snapshotMap(Map<String, ?> source) {
var copy = new LinkedHashMap<String, Object>();
source.forEach((key, value) -> copy.put(Objects.requireNonNull(key), snapshot(value)));
return Collections.unmodifiableMap(copy);
}
private static Object snapshot(Object value) {
if (value == null || value instanceof String || value instanceof Boolean || value instanceof UUID
|| value instanceof java.time.temporal.TemporalAccessor) return value;
if (value instanceof Number number) {
if (number instanceof Double doubleValue && !Double.isFinite(doubleValue)
|| number instanceof Float floatValue && !Float.isFinite(floatValue)) {
throw new IllegalArgumentException("Non-finite event number");
}
return number;
}
if (value instanceof Map<?, ?> map) {
var copy = new LinkedHashMap<String, Object>();
map.forEach((key, nested) -> {
if (!(key instanceof String text)) throw new IllegalArgumentException("Non-string event key");
copy.put(text, snapshot(nested));
});
return Collections.unmodifiableMap(copy);
}
if (value instanceof Collection<?> collection) {
return List.copyOf(collection.stream().map(EventInput::snapshot).toList());
}
throw new IllegalArgumentException("Unsupported event value " + value.getClass().getName());
}
public record Context(UUID playerId, String playerName, UUID characterId, String characterName,
UUID sessionId, Location location) {
public Context {
if (playerId == null || playerName == null) throw new IllegalArgumentException("Incomplete player identity");
if ((characterId == null) != (characterName == null) || characterId != null && sessionId == null
|| characterId == null && sessionId != null) {
throw new IllegalArgumentException("Incomplete character identity");
}
}
}
public record Location(UUID worldId, String worldName, double x, double y, double z,
Instant capturedAt, String quality) {
public Location {
Objects.requireNonNull(worldId);
Objects.requireNonNull(worldName);
Objects.requireNonNull(capturedAt);
Objects.requireNonNull(quality);
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) {
throw new IllegalArgumentException("Non-finite event location");
}
}
}
}
@@ -0,0 +1,239 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
final class FeatureEventOutbox implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private final Set<String> types;
private final Path directory;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-feature-event-intents"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
FeatureEventOutbox(Path data, String folder, Set<String> types,
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Consumer<Throwable> failures) {
if (!folder.matches("[a-z-]+")) throw new IllegalArgumentException("Unsafe event intent folder");
directory = data.toAbsolutePath().resolve(folder);
this.types = Set.copyOf(types);
this.sink = sink;
this.failures = failures;
}
void accepted(EventInput event) {
try { worker.execute(() -> {
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
try (var files = Files.list(directory)) {
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
private EventInput write(EventInput event) throws IOException {
if (!types.contains(event.type())) throw new IOException("Unsupported character intent type");
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
Files.createDirectories(directory);
var target = path(event);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
fields.setProperty("id", event.id().toString());
fields.setProperty("action", event.actionId().toString());
fields.setProperty("time", event.time().toString());
fields.setProperty("type", event.type());
var context = event.context();
if (context != null) {
fields.setProperty("player", context.playerId().toString());
fields.setProperty("player_name", context.playerName());
}
if (context != null && context.characterId() != null) {
fields.setProperty("character", context.characterId().toString());
fields.setProperty("character_name", context.characterName());
fields.setProperty("session", context.sessionId().toString());
}
if (context != null && context.location() != null) {
var location = context.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
fields.setProperty("x", Double.toString(location.x()));
fields.setProperty("y", Double.toString(location.y()));
fields.setProperty("z", Double.toString(location.z()));
fields.setProperty("captured_at", location.capturedAt().toString());
fields.setProperty("quality", location.quality());
}
event.data().forEach((key, value) -> {
if (value instanceof java.util.Map<?, ?> nested) {
if (key.contains(".")) throw new IllegalArgumentException("Unsafe nested feature intent key");
nested.forEach((name, part) -> {
if (!(name instanceof String child) || child.contains("."))
throw new IllegalArgumentException("Unsafe nested feature intent key");
encode(fields, "data." + key + "." + child, part);
});
} else encode(fields, "data." + key, value);
});
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged character identity event");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
var temporary = Files.createTempFile(directory, ".identity-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return event;
}
private static void encode(Properties fields, String key, Object value) {
if (value instanceof String text) fields.setProperty(key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty(key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty(key, "i" + number);
else if (value instanceof Double number && Double.isFinite(number)) fields.setProperty(key, "d" + number);
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
&& (text.equals("hotbar") || text.equals("armor"))))
fields.setProperty(key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
else throw new IllegalArgumentException("Unsupported feature intent value");
}
private EventInput read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
var fields = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
fields.load(new ByteArrayInputStream(bytes));
}
try {
var id = UUID.fromString(fields.getProperty("id"));
var action = UUID.fromString(fields.getProperty("action"));
var type = fields.getProperty("type");
if (!path.equals(directory.resolve(id + ".pending")) || !types.contains(type))
throw new IOException("Invalid character event intent identity");
EventInput.Location location = fields.containsKey("world")
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
fields.getProperty("quality")) : null;
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
throw new IOException("Unbound event context");
var context = fields.containsKey("player")
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
: null;
var data = new LinkedHashMap<String, Object>();
var nested = new LinkedHashMap<String, java.util.Map<String, Object>>();
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
var value = fields.getProperty(key);
if (value.isEmpty()) throw new IOException("Invalid character event value");
var decoded = switch (value.charAt(0)) {
case 's' -> value.substring(1);
case 'b' -> {
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
yield Boolean.valueOf(value.substring(1));
}
case 'i' -> Integer.valueOf(value.substring(1));
case 'd' -> {
var number = Double.valueOf(value.substring(1));
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
yield number;
}
case 'l' -> {
var encoded = value.substring(1);
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
if (!list.stream().allMatch(item -> item.equals("hotbar") || item.equals("armor")))
throw new IOException("Invalid slot class");
yield list;
}
default -> throw new IOException("Invalid feature event value type");
};
var name = key.substring(5);
var separator = name.indexOf('.');
if (separator < 0) data.put(name, decoded);
else {
if (separator == 0 || separator == name.length() - 1 || name.indexOf('.', separator + 1) >= 0)
throw new IOException("Invalid nested feature intent key");
nested.computeIfAbsent(name.substring(0, separator), ignored -> new LinkedHashMap<>())
.put(name.substring(separator + 1), decoded);
}
}
nested.forEach((key, value) -> {
if (data.putIfAbsent(key, value) != null) throw new IllegalArgumentException("Conflicting feature intent key");
});
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
}
private void publish(EventInput event) {
try { sink.apply(event).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
private void report(Throwable error) {
try { failures.accept(error); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
CompletionStage<Void> drained() { return drained; }
}
@@ -0,0 +1,462 @@
package games.dmg.fantasystick.events;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/** Independent local CloudEvents JSONL stream with recoverable per-event pending files. */
public final class LocalEventLog implements AutoCloseable {
public static final String TYPE_PREFIX = "games.dmg.minecraft.fantasystick.";
public enum Delivery { EXPORTED, DURABLE_PENDING, REJECTED }
public record RuntimeInfo(String serverName, String platformVersion, String gameVersion, String pluginVersion) {
public RuntimeInfo(String serverName,String gameVersion,String pluginVersion){this(serverName,gameVersion,gameVersion,pluginVersion);}
public RuntimeInfo {
Objects.requireNonNull(serverName);
Objects.requireNonNull(platformVersion);
Objects.requireNonNull(gameVersion);
Objects.requireNonNull(pluginVersion);
}
}
private final Path root;
private final Path pending;
private final Path activeBoot;
private final Clock clock;
private final RuntimeInfo runtime;
private final long maximumSegmentBytes;
private final Consumer<String> diagnostic;
private final UUID serverId;
private final UUID bootId = UUID.randomUUID();
private final ThreadPoolExecutor worker;
private final CompletableFuture<Delivery> ready = new CompletableFuture<>();
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private final UUID priorBoot;
private final FileChannel lockChannel;
private final java.nio.channels.FileLock writerLock;
private long sequence;
private Path segment;
private LocalDate segmentDate;
private long segmentBytes;
private int segmentNumber;
private volatile boolean closing;
private final java.util.concurrent.atomic.AtomicLong unacceptedHandoff = new java.util.concurrent.atomic.AtomicLong();
private final java.util.concurrent.atomic.AtomicLong lastDiagnostic = new java.util.concurrent.atomic.AtomicLong();
public static LocalEventLog open(Path dataFolder, Clock clock, RuntimeInfo runtime, int capacity,
long maximumSegmentBytes, Consumer<String> diagnostic) throws IOException {
return new LocalEventLog(dataFolder, clock, runtime, capacity, maximumSegmentBytes, diagnostic);
}
private LocalEventLog(Path dataFolder, Clock clock, RuntimeInfo runtime, int capacity,
long maximumSegmentBytes, Consumer<String> diagnostic) throws IOException {
if (capacity < 1 || maximumSegmentBytes < 2) throw new IllegalArgumentException("Invalid event-log bound");
this.clock = Objects.requireNonNull(clock);
this.runtime = Objects.requireNonNull(runtime);
this.maximumSegmentBytes = maximumSegmentBytes;
this.diagnostic = Objects.requireNonNull(diagnostic);
root = Objects.requireNonNull(dataFolder).resolve("events");
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(root)) {
throw new IOException("Fantasy Stick events path is a symbolic link");
}
Files.createDirectories(root);
pending = root.resolve("pending");
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS) && Files.isSymbolicLink(pending)) {
throw new IOException("Fantasy Stick pending-events path is a symbolic link");
}
Files.createDirectories(pending);
activeBoot = root.resolve("active-boot");
serverId = loadServerId();
var lockPath=root.resolve("writer.lock");if(Files.exists(lockPath,LinkOption.NOFOLLOW_LINKS)&&Files.isSymbolicLink(lockPath))throw new IOException("Fantasy Stick writer lock is a symbolic link");
lockChannel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
java.nio.channels.FileLock acquired;
try { acquired = lockChannel.tryLock(); }
catch (java.nio.channels.OverlappingFileLockException conflict) { acquired = null; }
if (acquired == null) { lockChannel.close(); throw new IOException("Fantasy Stick event writer is already active"); }
writerLock = acquired;
UUID previous;
try {
previous = loadPriorBoot();
Files.writeString(activeBoot, bootId + "\n", StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
} catch (IOException | RuntimeException failure) {
writerLock.release(); lockChannel.close(); throw failure;
}
priorBoot = previous;
worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(capacity), task -> {
var thread = new Thread(task, "fantasy-stick-events");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
}
public CompletionStage<Delivery> ready() {
if(started.compareAndSet(false,true))submitStartup(priorBoot).whenComplete((delivery,failure)->{if(failure!=null)ready.completeExceptionally(failure);else ready.complete(delivery);});
return ready.minimalCompletionStage();
}
public UUID serverId() { return serverId; }
public UUID bootId() { return bootId; }
public CompletionStage<Delivery> emit(EventInput input) {
Objects.requireNonNull(input);
if (closing) { rejectedHandoff(); return CompletableFuture.completedStage(Delivery.REJECTED); }
return submitInternal(input, true);
}
private CompletableFuture<Delivery> submitStartup(UUID priorBoot) {
var result = new CompletableFuture<Delivery>();
try {
worker.execute(() -> {
try {
boolean interruptedTail = recoverInterruptedTails();
int recoveredPending = recoverPending();
if (priorBoot != null || interruptedTail || recoveredPending > 0) {
var recovery = new LinkedHashMap<String, Object>();
recovery.put("outcome", "incomplete_prior_lifetime");
recovery.put("recovered_pending_count", Integer.toString(recoveredPending));
recovery.put("interrupted_tail", interruptedTail);
if (priorBoot != null) recovery.put("prior_boot_id", priorBoot.toString());
store(new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(),
"game.recovery_detected", null, recovery));
}
result.complete(store(lifecycle("game.started", "ready")));
} catch (Exception failure) {
diagnostic.accept("Fantasy Stick event startup failed: " + failure.getClass().getSimpleName());
result.complete(Delivery.REJECTED);
}
});
} catch (RejectedExecutionException rejected) {
result.complete(Delivery.REJECTED);
}
return result;
}
private CompletableFuture<Delivery> submitInternal(EventInput input, boolean countRejection) {
var result = new CompletableFuture<Delivery>();
try {
worker.execute(operation(input,result));
} catch (RejectedExecutionException rejected) {
if (countRejection) rejectedHandoff();
result.complete(Delivery.REJECTED);
}
return result;
}
private CompletableFuture<Delivery> submitClosing(EventInput input){var result=new CompletableFuture<Delivery>();try{if(!worker.getQueue().offer(operation(input,result),5,TimeUnit.SECONDS))result.complete(Delivery.REJECTED);}catch(InterruptedException interrupted){Thread.currentThread().interrupt();result.complete(Delivery.REJECTED);}return result;}
private Runnable operation(EventInput input,CompletableFuture<Delivery> result){return ()->{try{result.complete(store(input));}catch(Exception failure){diagnostic.accept("Fantasy Stick event write failed: "+failure.getClass().getSimpleName());result.complete(Delivery.REJECTED);}};}
private Delivery store(EventInput input) throws IOException {
recoverPending();
var rejected = unacceptedHandoff.getAndSet(0);
if (rejected > 0 && !"logging.gap".equals(input.type())) {
degradedDiagnostic("Fantasy Stick event queue rejected observations before durable acceptance");
storeOne(new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(), "logging.gap", null,
Map.of("unaccepted_count", Long.toString(rejected), "reason", "bounded_handoff_rejected",
"outcome", "uncertain")));
}
return storeOne(input);
}
private Delivery storeOne(EventInput input) throws IOException {
var line = encode(input, ++sequence);var bytes=(line+"\n").getBytes(StandardCharsets.UTF_8);var target=pending.resolve(input.id()+".pending");writeCreateAtomic(target,bytes);try{append(bytes);Files.deleteIfExists(target);return Delivery.EXPORTED;}catch(IOException failure){degradedDiagnostic("Fantasy Stick event export failed; durable pending work was retained");return Delivery.DURABLE_PENDING;}
}
private void rejectedHandoff(){unacceptedHandoff.incrementAndGet();}
private void degradedDiagnostic(String message){long now=System.nanoTime(),previous=lastDiagnostic.get();if((previous==0||now-previous>=Duration.ofSeconds(30).toNanos())&&lastDiagnostic.compareAndSet(previous,now))diagnostic.accept(message);}
private UUID loadPriorBoot() throws IOException {
if (!Files.exists(activeBoot, LinkOption.NOFOLLOW_LINKS)) return null;
if (Files.isSymbolicLink(activeBoot)) throw new IOException("Fantasy Stick active writer marker is a symbolic link");
try { return UUID.fromString(Files.readString(activeBoot, StandardCharsets.UTF_8).trim()); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid Fantasy Stick active writer marker", invalid); }
}
private boolean recoverInterruptedTails() throws IOException {
List<Path> files;
try (var stream = Files.walk(root)) {
files = stream.filter(path -> path.getFileName().toString().endsWith(".jsonl"))
.sorted().toList();
}
boolean recovered = false;
for (var path : files) {
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
var bytes = Files.readAllBytes(path);
int prefix=0,cursor=0;while(cursor<bytes.length){int end=cursor;while(end<bytes.length&&bytes[end]!='\n')end++;if(end==bytes.length)break;var line=java.util.Arrays.copyOfRange(bytes,cursor,end+1);if(!validJsonObjectLine(line))break;prefix=end+1;cursor=end+1;}if(prefix==bytes.length)continue;
recovered = true;
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
while (tail.hasRemaining()) channel.write(tail);
channel.force(true);
}
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
channel.truncate(prefix);
channel.force(true);
}
}
return recovered;
}
private int recoverPending() throws IOException {
List<Path> files;
try (var stream = Files.list(pending)) {
files = stream.filter(path -> path.getFileName().toString().endsWith(".pending"))
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
}
int recovered = 0;
for (var path : files) {
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick pending events");
var bytes = Files.readAllBytes(path);
if (!validJsonObjectLine(bytes)) {
Files.move(path,path.resolveSibling(path.getFileName()+".corrupt-"+UUID.randomUUID()));
continue;
}
append(bytes);
Files.delete(path);
recovered++;
}
return recovered;
}
private static boolean validJsonObjectLine(byte[] bytes){
if(bytes.length<3||bytes[bytes.length-1]!='\n')return false;String text;try{var decoder=StandardCharsets.UTF_8.newDecoder();decoder.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT);decoder.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT);text=decoder.decode(ByteBuffer.wrap(bytes,0,bytes.length-1)).toString();}catch(java.nio.charset.CharacterCodingException invalid){return false;}if(!text.startsWith("{")||!text.endsWith("}")||!text.contains("\"specversion\":\"1.0\"")||!text.contains("\"id\":\"")||!text.contains("\"data\":"))return false;var stack=new java.util.ArrayDeque<Character>();boolean string=false,escape=false;for(int index=0;index<text.length();index++){char value=text.charAt(index);if(string){if(escape){if(value=='u'){if(index+4>=text.length())return false;for(int digit=1;digit<=4;digit++)if(Character.digit(text.charAt(index+digit),16)<0)return false;index+=4;}else if("\"\\/bfnrt".indexOf(value)<0)return false;escape=false;}else if(value=='\\')escape=true;else if(value=='\"')string=false;else if(value<0x20)return false;continue;}if(value=='\"'){string=true;continue;}if(value=='{'||value=='[')stack.push(value);else if(value=='}'||value==']'){if(stack.isEmpty())return false;char opened=stack.pop();if(value=='}'&&opened!='{'||value==']'&&opened!='[')return false;}else if(value<0x20&&!Character.isWhitespace(value))return false;}return !string&&!escape&&stack.isEmpty();
}
private void append(byte[] line) throws IOException {
var date = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC);
if (segment == null || !date.equals(segmentDate)
|| segmentBytes > 0 && segmentBytes + line.length > maximumSegmentBytes) {
openSegment(date);
}
try (var channel = FileChannel.open(segment, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
var buffer = ByteBuffer.wrap(line);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
segmentBytes += line.length;
}
private void openSegment(LocalDate date) throws IOException {
var directory = root.resolve(date.toString());
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe Fantasy Stick event segment directory");
}
} else Files.createDirectory(directory);
if (!directory.toRealPath().getParent().equals(root.toRealPath())) {
throw new IOException("Fantasy Stick event segment escaped its root");
}
while (true) {
var candidate = directory.resolve("events-" + bootId + "-" + segmentNumber++ + ".jsonl");
try {
Files.createFile(candidate);
segment = candidate;
segmentDate = date;
segmentBytes = 0;
return;
} catch (java.nio.file.FileAlreadyExistsException collision) {
// A boot identifier collision cannot overwrite an existing segment.
}
}
}
private UUID loadServerId() throws IOException {
var path = root.resolve("server-id");
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(path)) throw new IOException("Fantasy Stick server identity is a symbolic link");
try { return UUID.fromString(Files.readString(path, StandardCharsets.UTF_8).trim()); }
catch (IllegalArgumentException invalid) { throw new IOException("Invalid Fantasy Stick server identity", invalid); }
}
var created = UUID.randomUUID();
Files.writeString(path, created + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
return created;
}
private void writeCreateAtomic(Path target, byte[] bytes) throws IOException {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return;
var temporary = pending.resolve(target.getFileName() + "." + UUID.randomUUID() + ".tmp");
try (var channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); }
catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { Files.move(temporary, target); }
finally { Files.deleteIfExists(temporary); }
}
private EventInput lifecycle(String type, String phase) {
return new EventInput(UUID.randomUUID(), UUID.randomUUID(), clock.instant(), type, null,
Map.of("phase", phase, "reason", "plugin_lifecycle"));
}
private String encode(EventInput input, long acceptedSequence) {
var data = new LinkedHashMap<String, Object>();
data.putAll(input.data());
data.put("schema_version", 1);
data.put("server_id", serverId.toString());
data.put("server_name", runtime.serverName());
data.put("game_version", runtime.gameVersion());
data.put("platform_version", runtime.platformVersion());
data.put("plugin_version", runtime.pluginVersion());
data.put("boot_id", bootId.toString());
data.put("sequence", Long.toString(acceptedSequence));
data.put("action_id", input.actionId().toString());
var context = input.context();
if (context != null) {
data.put("actor_kind", "player");
data.put("player_id", context.playerId().toString());
data.put("player_name", context.playerName());
if (context.characterId() != null) {
data.put("character_id", context.characterId().toString());
data.put("character_name", context.characterName());
data.put("session_id", context.sessionId().toString());
}
if (context.location() != null) data.put("location", location(context.location()));
} else {
var claimed = data.get("actor_kind");
data.put("actor_kind", "console".equals(claimed) || "command_block".equals(claimed)
? claimed : "system");
}
var envelope = new LinkedHashMap<String, Object>();
envelope.put("specversion", "1.0");
envelope.put("id", input.id().toString());
envelope.put("source", "urn:minecraft-server:" + serverId);
envelope.put("type", TYPE_PREFIX + input.type());
envelope.put("time", input.time().toString());
envelope.put("datacontenttype", "application/json");
envelope.put("serverid", serverId.toString());
envelope.put("servername", runtime.serverName());
envelope.put("gameversion", runtime.gameVersion());
if (context != null && context.playerId() != null) envelope.put("playerid", context.playerId().toString());
if (context != null && context.characterId() != null) envelope.put("characterid", context.characterId().toString());
envelope.put("data", data);
return Json.encode(envelope);
}
private static Map<String, Object> location(EventInput.Location location) {
var value = new LinkedHashMap<String, Object>();
value.put("world_id", location.worldId().toString());
value.put("world_name", location.worldName());
value.put("x", location.x());
value.put("y", location.y());
value.put("z", location.z());
value.put("captured_at", location.capturedAt().toString());
value.put("quality", location.quality());
return value;
}
@Override public void close() {
if (closing) return;
closing = true;
try {
if(!started.get()){worker.shutdown();if(worker.awaitTermination(5,TimeUnit.SECONDS)&&pendingEmpty())Files.deleteIfExists(activeBoot);return;}
var stopped = submitClosing(lifecycle("game.stopped", "stopped"));
worker.shutdown();
var delivery = stopped.get(5, TimeUnit.SECONDS);
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) diagnostic.accept("Fantasy Stick event shutdown timed out");
if(delivery!=Delivery.EXPORTED)degradedDiagnostic("Fantasy Stick stop event was not exported; incomplete lifetime evidence was retained");
if (delivery == Delivery.EXPORTED && pendingEmpty()) Files.deleteIfExists(activeBoot);
} catch (Exception failure) {
diagnostic.accept("Fantasy Stick event shutdown failed: " + failure.getClass().getSimpleName());
worker.shutdown();
} finally {
try { writerLock.release(); } catch (IOException ignored) { }
try { lockChannel.close(); } catch (IOException ignored) { }
}
}
private boolean pendingEmpty() throws IOException {
try (var files = Files.list(pending)) { return files.noneMatch(path -> path.getFileName().toString().endsWith(".pending")); }
}
/** Small protected JSON encoder; values are immutable event snapshots, never arbitrary objects. */
static final class Json {
private Json() { }
static String encode(Object value) {
var output = new StringBuilder();
append(output, value);
return output.toString();
}
private static void append(StringBuilder output, Object value) {
if (value == null) output.append("null");
else if (value instanceof String text) string(output, text);
else if (value instanceof UUID || value instanceof java.time.temporal.TemporalAccessor) string(output, value.toString());
else if (value instanceof Boolean bool) output.append(bool);
else if (value instanceof Number number) number(output, number);
else if (value instanceof Map<?, ?> map) object(output, map);
else if (value instanceof Collection<?> collection) array(output, collection);
else throw new IllegalArgumentException("Unsupported event value " + value.getClass().getName());
}
private static void object(StringBuilder output, Map<?, ?> map) {
output.append('{');
boolean comma = false;
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("Non-string event key");
if (comma) output.append(',');
string(output, key); output.append(':'); append(output, entry.getValue()); comma = true;
}
output.append('}');
}
private static void array(StringBuilder output, Collection<?> values) {
output.append('['); boolean comma = false;
for (var value : values) { if (comma) output.append(','); append(output, value); comma = true; }
output.append(']');
}
private static void number(StringBuilder output, Number number) {
if (number instanceof Double doubleValue && !Double.isFinite(doubleValue)
|| number instanceof Float floatValue && !Float.isFinite(floatValue)) {
throw new IllegalArgumentException("Non-finite event number");
}
output.append(number);
}
private static void string(StringBuilder output, String text) {
output.append('"');
for (int index = 0; index < text.length(); index++) {
char value = text.charAt(index);
switch (value) {
case '"' -> output.append("\\\"");
case '\\' -> output.append("\\\\");
case '\b' -> output.append("\\b");
case '\f' -> output.append("\\f");
case '\n' -> output.append("\\n");
case '\r' -> output.append("\\r");
case '\t' -> output.append("\\t");
default -> {
if (value < 0x20 || Character.isSurrogate(value)
&& (Character.isHighSurrogate(value)
? index + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(index + 1))
: index == 0 || !Character.isHighSurrogate(text.charAt(index - 1)))) {
output.append(String.format("\\u%04x", (int) value));
} else output.append(value);
}
}
}
output.append('"');
}
}
}
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.presentation.CharacterPresentation;
import io.papermc.paper.event.player.AsyncChatEvent;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
/** Observes supported character messages; no recipient/delivery claims or signed-body changes. */
public final class MessageEventBridge implements Listener {
private static final Set<String> PRIVATE = Set.of("msg", "message", "tell", "w", "whisper", "pm", "reply", "r");
private final Function<UUID, CharacterPresentation.View> views;
private final SessionEventBridge sessions;
private final java.util.function.Consumer<EventInput> sink;
private final Clock clock;
public MessageEventBridge(Function<UUID, CharacterPresentation.View> views, SessionEventBridge sessions,
java.util.function.Consumer<EventInput> sink, Clock clock) {
this.views=Objects.requireNonNull(views);this.sessions=Objects.requireNonNull(sessions);
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void chat(AsyncChatEvent event) {
try {
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var context=sessions.lastKnown(view.holder(),view.session(),view.name());
if(context==null)return;
var data=new LinkedHashMap<String,Object>();
data.put("channel","public");
data.put("message",event.signedMessage().message());
data.put("asynchronous",event.isAsynchronous());
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
data.put("location_quality","last_native_session_capture");
emit("character.chat",context,data);
} catch(RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void privateMessage(PlayerCommandPreprocessEvent event) {
try {
String command=event.getMessage();
if(command==null||!command.startsWith("/"))return;
int end=command.indexOf(' ');
String label=(end<0?command.substring(1):command.substring(1,end)).toLowerCase(Locale.ROOT);
label=label.substring(label.lastIndexOf(':')+1);
if(!PRIVATE.contains(label))return;
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var cached=sessions.lastKnown(view.holder(),view.session(),view.name());
if(cached==null)return;
var position=event.getPlayer().getLocation();
var context=new EventInput.Context(cached.playerId(),cached.playerName(),view.character(),view.name(),view.session(),
new EventInput.Location(position.getWorld().getUID(),position.getWorld().getName(),
position.getX(),position.getY(),position.getZ(),clock.instant(),"native_capture"));
boolean reply=label.equals("reply")||label.equals("r");
String remainder=end<0?"":command.substring(end+1);
var data=new LinkedHashMap<String,Object>();
data.put("command",label);data.put("reply",reply);
if(reply)data.put("message",remainder);
else {
int separator=remainder.indexOf(' ');
if(!remainder.isEmpty())data.put("recipient_token",separator<0?remainder:remainder.substring(0,separator));
data.put("message",separator<0?"":remainder.substring(separator+1));
}
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
emit("character.private_message",context,data);
} catch(RuntimeException ignored) { }
}
private void emit(String type,EventInput.Context context,java.util.Map<String,Object> data) {
var action=UUID.randomUUID();
sink.accept(new EventInput(action,action,clock.instant(),type,context,data));
}
}
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OrdinaryVitals;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captures nutrition at the native boundary; success follows an acknowledged session receipt. */
public final class NutritionEventBridge implements AutoCloseable {
public record Capture(EventInput.Context context, UUID session, UUID character, Instant time,
int food, float saturation, float exhaustion, String reason) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Clock clock;
public NutritionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "nutrition-event-intents",
Set.of("nutrition.suspended", "nutrition.restored"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Capture capture(Player player, UUID session, UUID character, String name, String reason) {
try {
var now = clock.instant();
var where = location(player.getLocation(), now);
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
name == null ? null : character, name, name == null ? null : session, where),
session, character, now, player.getFoodLevel(), player.getSaturation(), player.getExhaustion(), reason);
} catch (RuntimeException ignored) { return null; }
}
public void suspended(Player player, UUID session, CharacterRecord character, OrdinaryVitals ordinary,
BigInteger receiptRevision) {
if (ordinary == null || receiptRevision == null) return;
var captured = capture(player, session, character.id(), character.name(), "entry");
if (captured == null) return;
var data = fields(captured, receiptRevision);
data.put("ordinary_food", ordinary.food());
data.put("ordinary_saturation", (double) ordinary.saturation());
data.put("ordinary_exhaustion", (double) ordinary.exhaustion());
data.put("fantasy_food", captured.food());
data.put("fantasy_saturation", (double) captured.saturation());
data.put("fantasy_exhaustion", (double) captured.exhaustion());
data.put("outcome", "acknowledged");
durable(captured, "nutrition.suspended", "suspended", data);
}
public void restored(Capture before, Capture after, OrdinaryVitals ordinary, BigInteger receiptRevision) {
if (before == null || after == null || ordinary == null || receiptRevision == null
|| !before.session().equals(after.session())) return;
var data = fields(before, receiptRevision);
data.put("before_food", before.food());
data.put("before_saturation", (double) before.saturation());
data.put("before_exhaustion", (double) before.exhaustion());
data.put("restored_food", after.food());
data.put("restored_saturation", (double) after.saturation());
data.put("restored_exhaustion", (double) after.exhaustion());
data.put("outcome", "acknowledged");
durable(after, "nutrition.restored", "restored", data);
}
public void satiatedRefused(Player player, UUID session, UUID character, String name, double amount) {
var captured = capture(player, session, character, name, "satiated_regeneration");
if (captured == null) return;
var action = UUID.randomUUID();
for (var stage : new String[]{"observed", "resolved"}) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "satiated_regeneration");
data.put("session_id", session.toString());
data.put("character_id", character.toString());
data.put("requested_amount", amount);
data.put("regain_source", "SATIATED");
data.put("outcome", stage.equals("observed") ? "observed" : "cancelled");
if (stage.equals("resolved")) data.put("reason", "nutrition_policy");
try { sink.apply(new EventInput(id(action, stage), action, captured.time(),
"action." + stage, captured.context(), data)); }
catch (RuntimeException ignored) { /* Logging never changes a native cancellation. */ }
}
}
public void restorationRetained(Capture captured, String reason) {
if (captured == null) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "nutrition_restore");
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("outcome", "retained");
data.put("reason", reason);
try { sink.apply(new EventInput(id(action, "retained"), action, captured.time(),
"action.resolved", captured.context(), data)); }
catch (RuntimeException ignored) { }
}
/** Native event alone is not success; a later observed health change proves an applied effect. */
public void nativeHealed(Capture captured, double before, double after, String source) {
if (captured == null || !Double.isFinite(before) || !Double.isFinite(after) || after <= before) return;
var action = UUID.randomUUID();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("regain_source", source);
data.put("health_before", before);
data.put("health_after", after);
data.put("actual_amount", after - before);
data.put("persistence_result", "pending_session_health_save");
data.put("publication_result", "native_health_observed");
data.put("outcome", "applied");
try { sink.apply(new EventInput(id(action, "healed"), action, captured.time(),
"combat.healed", captured.context(), data)); }
catch (RuntimeException ignored) { /* An event never authorizes a health change. */ }
}
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", captured.session().toString());
data.put("character_id", captured.character().toString());
data.put("receipt_id", captured.session().toString());
data.put("receipt_revision", revision.toString());
data.put("reason", captured.reason());
return data;
}
private void durable(Capture captured, String type, String phase, LinkedHashMap<String, Object> data) {
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.time(),
type, captured.context(), data)); }
catch (RuntimeException ignored) { /* Audit failure cannot change nutrition custody. */ }
}
private EventInput.Location location(Location value, Instant now) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), now, "exact");
}
private static UUID id(UUID session, String phase) {
return UUID.nameUUIDFromBytes(("fantasystick:nutrition:" + session + ':' + phase)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CharacterParty;
import games.dmg.fantasystick.party.CharacterPartyDirectory;
import games.dmg.fantasystick.party.PartyRegistrar;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Failure-isolated Registrar and persistent character-party mutation records. */
public final class PartyEventBridge {
public record Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,
CharacterParty before,BigInteger charge,String reason){
public Action{Objects.requireNonNull(id);Objects.requireNonNull(context);Objects.requireNonNull(registrar);Objects.requireNonNull(kind);if(charge==null)charge=BigInteger.ZERO;if(reason==null)reason="requested";}
public Action(UUID id,EventInput.Context context,PartyRegistrar registrar,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge){this(id,context,registrar,kind,target,null,invitation,before,charge,"requested");}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PartyEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String characterName,PartyRegistrar registrar,String kind,UUID target,EventInput.Context targetContext,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){var action=new Action(operation==null?stableAction(active.character(),kind,target,invitation,before):operation,context(player,active,characterName),registrar,kind,target,targetContext,invitation,before,charge,reason);emitAction("action.observed",action,"observed",null,null,null);return action;}
public void failed(Action action,String reason){emitAction("action.resolved",action,"failed",reason,null,null);}
public void refused(Action action,String reason){emitAction("action.resolved",action,"refused",reason,null,null);}
public void resolved(Action action,CharacterPartyDirectory.Result result){if(result==null||result.status()!=CharacterPartyDirectory.Status.APPLIED){refused(action,result==null?"unavailable":result.status().name().toLowerCase(java.util.Locale.ROOT));return;}resolved(action,result.party().orElse(null),result.invitation().orElse(null));}
public void resolved(Action action,games.dmg.fantasystick.party.PartyManagement.Outcome outcome){if(outcome==null||outcome.status()!=games.dmg.fantasystick.party.PartyManagement.Status.APPLIED){refused(action,outcome==null?"unavailable":outcome.status().name().toLowerCase(java.util.Locale.ROOT));return;}var after=outcome.party().orElse(null);CharacterPartyDirectory.Offer offer=null;if(after!=null){if("invite".equals(action.kind()))offer=after.invitations().stream().filter(value->value.character().equals(action.target())&&(action.before()==null||action.before().invitations().stream().noneMatch(old->old.id().equals(value.id())))).findFirst().map(value->new CharacterPartyDirectory.Offer(value.id(),after.id(),value.character())).orElse(null);else if(action.invitation()!=null)offer=new CharacterPartyDirectory.Offer(action.invitation(),after.id(),action.target()==null?action.context().characterId():action.target());}resolved(action,after,offer);}
private void resolved(Action action,CharacterParty after,CharacterPartyDirectory.Offer offer){emitAction("action.resolved",action,"accepted","acknowledged",after,offer);var businessAfter="disband".equals(action.kind())||"leader_leave".equals(action.kind())?null:after;emitAction(type(action.kind(),action.before(),businessAfter),action,"accepted","acknowledged",businessAfter,offer);}
public void registrarPlaced(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.placed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registered_and_reconciled");}
public void registrarRemoved(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.removed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registration_removed");}
public void finalDefeat(UUID character,CharacterParty before,CharacterPartyDirectory.Defeat outcome){if(before==null||outcome==CharacterPartyDirectory.Defeat.UNCHANGED)return;var action=stable(before.id(),character,before.revision(),"final-death");var data=partyData(before,null);data.put("reason","final_death");data.put("subject_character_id",character.toString());data.put("popup_delivery",outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"active_former_member_delivery_queued":"not_applicable");data.put("outcome","acknowledged");data.put("operation_id",action.toString());emit(id(action,outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left"),action,clock.instant(),outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left",null,data);}
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
private void emitAction(String type,Action action,String outcome,String reason,CharacterParty after,CharacterPartyDirectory.Offer invitation){var data=partyData(action.before(),after);data.put("action_kind","party_"+action.kind());data.put("operation_id",action.id().toString());data.put("registrar_id",action.registrar().id().toString());data.put("registrar_location",registrarLocation(action.registrar()));data.put("actor_character_id",action.context().characterId().toString());data.put("actor_player_id",action.context().playerId().toString());data.put("actor_session_id",action.context().sessionId().toString());if(action.target()!=null)data.put("target_character_id",action.target().toString());if(action.targetContext()!=null){data.put("target_player_id",action.targetContext().playerId().toString());data.put("target_session_id",action.targetContext().sessionId().toString());if(action.targetContext().playerName()!=null)data.put("target_player_name",action.targetContext().playerName());if(action.targetContext().location()!=null)data.put("target_location",locationData(action.targetContext().location()));}if(action.invitation()!=null)data.put("invitation_id",action.invitation().toString());else if(invitation!=null)data.put("invitation_id",invitation.id().toString());if(action.charge().signum()>0){data.put("charged_coins",action.charge().toString());data.put("charge_receipt",action.id().toString());}data.put("reason",action.reason());data.put("popup_delivery",type.equals("party.disbanded")?"active_former_member_delivery_queued":"not_applicable");data.put("outcome",outcome);if(reason!=null)data.put(type.startsWith("party.")?"mutation_boundary":"reason",reason);emit(id(action.id(),type),action.id(),clock.instant(),type,action.context(),data);}
private static String type(String kind,CharacterParty before,CharacterParty after){return switch(kind){case "create"->"party.created";case "invite"->"party.invitation_created";case "cancel"->"party.invitation_cancelled";case "decline"->"party.invitation_declined";case "accept"->"party.joined";case "kick"->"party.member_kicked";case "transfer"->"party.leadership_transferred";case "disband","leader_leave"->"party.disbanded";case "leave"->before!=null&&before.leader().equals(before.members().stream().filter(id->after==null||!after.members().contains(id)).findFirst().orElse(null))?"party.disbanded":"party.left";default->"party.mutated";};}
private static LinkedHashMap<String,Object> partyData(CharacterParty before,CharacterParty after){var data=new LinkedHashMap<String,Object>();var party=after==null?before:after;if(party!=null){data.put("party_id",party.id().toString());data.put("party_display_name",party.displayName());data.put("party_canonical_name",party.canonicalName());}if(before!=null){data.put("before_revision",before.revision().toString());data.put("before_leader_character_id",before.leader().toString());data.put("before_member_character_ids",before.members().stream().map(UUID::toString).sorted().toList());}if(after!=null){data.put("after_revision",after.revision().toString());data.put("after_leader_character_id",after.leader().toString());data.put("after_member_character_ids",after.members().stream().map(UUID::toString).sorted().toList());}return data;}
private void registrar(String type,String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded,String outcome){var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(actorPlayer!=null)data.put("actor_player_id",actorPlayer.toString());if(actorName!=null)data.put("actor_player_name",actorName);data.put("registrar_id",registrar.id().toString());data.put("registrar_name",registrar.name());data.put("registrar_revision","0");data.put("operation_id",registrar.id().toString());data.put("registered_location",registrarLocation(registrar));if(entity!=null)data.put("entity_id",entity.toString());data.put("entity_status",loaded?"loaded":"unloaded");data.put("outcome",outcome);emit(id(registrar.id(),type),registrar.id(),clock.instant(),type,actorPlayer==null?null:new EventInput.Context(actorPlayer,actorName,null,null,null,null),data);}
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
private static Map<String,Object> locationData(EventInput.Location value){return Map.of("world_id",value.worldId().toString(),"world_name",value.worldName(),"x",value.x(),"y",value.y(),"z",value.z(),"captured_at",value.capturedAt().toString(),"quality",value.quality());}
private static Map<String,Object> registrarLocation(PartyRegistrar value){return Map.of("world_id",value.world().toString(),"x",Double.toString(value.x()),"y",Double.toString(value.y()),"z",Double.toString(value.z()),"yaw",Float.toString(value.yaw()),"pitch",Float.toString(value.pitch()),"quality","registered_exact");}
public EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID stableAction(UUID actor,String kind,UUID target,UUID invitation,CharacterParty before){String party=before==null?"none":before.id().toString(),revision=before==null?"none":before.revision().toString();return UUID.nameUUIDFromBytes(("fantasystick:party-action:"+actor+":"+kind+":"+party+":"+revision+":"+target+":"+invitation).getBytes(StandardCharsets.UTF_8));}
private static UUID stable(UUID party,UUID character,BigInteger revision,String kind){return UUID.nameUUIDFromBytes(("fantasystick:party:"+party+":"+character+":"+revision+":"+kind).getBytes(StandardCharsets.UTF_8));}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:party:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,150 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.party.PartyRoster;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Captures native protection decisions without changing the native damage/event authority. */
public final class PartyProtectionEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
CombatSources.Origin recipient, Player target,
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
try {
var id = UUID.randomUUID();
var time = clock.instant();
Entity causing = event.getDamageSource().getCausingEntity();
Entity direct = event.getDamageSource().getDirectEntity();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "party_damage_protection");
if (context == null) data.put("actor_kind", "system");
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("causing_entity_id", causing.getUniqueId().toString());
if (direct != null && direct.getUniqueId() != null)
data.put("direct_entity_id", direct.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
data.put("target_player_id", target.getUniqueId().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount", event.getDamage());
data.put("prior_cancelled", event.isCancelled());
data.put("proposed_reason", reason);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, time, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
PartyRoster.Party targetParty, String reason, String nativeKind) {
try {
var id = UUID.randomUUID();
var now = clock.instant();
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
null, null, null, location(source.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", kind);
if (context == null) data.put("actor_kind", "system");
data.put("native_kind", nativeKind);
data.put("source_player_id", origin.player().toString());
if (causing != null && causing.getUniqueId() != null)
data.put("direct_entity_id", causing.getUniqueId().toString());
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
if (recipient != null) {
data.put("target_player_id", recipient.player().toString());
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
} else data.put("target_status", "not_single_proved_player");
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
data.put("provenance_age_status", "unavailable");
data.put("requested_amount_status", "not_applicable");
data.put("prior_cancelled", false);
var sourceLocation = causing == null ? null : location(causing.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(id, now, context, Map.copyOf(data));
var observed = new LinkedHashMap<String, Object>(data);
observed.put("outcome", "observed");
emit(action, "action.observed", "observed", observed);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("final_cancelled", cancelled);
if (finalAmount != null) values.put("final_amount", finalAmount);
else values.put("final_amount_status", "not_applicable");
emit(action, "action.resolved", "resolved", values);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "exact");
}
private Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Map<String, Object> data) {
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), data)); }
catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,39 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.passive.PassiveDefinition;
import games.dmg.fantasystick.passive.PassiveEngine;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned acknowledged passive mutations and Quicker Regeneration configuration records. */
public final class PassiveEventBridge {
public record Action(UUID actionId,EventInput.Context context,String passive,boolean learn,
PassiveDefinition definition,CharacterRecord before){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(passive);Objects.requireNonNull(definition);Objects.requireNonNull(before);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public PassiveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(ControlInput input,CharacterRecord before,PassiveDefinition definition,boolean learn){var action=new Action(UUID.randomUUID(),context(input.player(),input.session(),before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);return action;}
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:passive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captured account preferences remain independent of gameplay and feedback delivery. */
public final class PreferenceEventBridge {
public record Action(UUID id, Instant time, EventInput.Context context, String key,
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final PlayerFeedbackPreferences preferences;
private final Consumer<Throwable> failures;
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
this.preferences = Objects.requireNonNull(preferences);
this.failures = Objects.requireNonNull(failures);
}
public Action begin(Player player, String key, boolean before, Boolean requested) {
Objects.requireNonNull(player);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
var where = player.getLocation();
EventInput.Location location = where == null || where.getWorld() == null ? null
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
where.getY(), where.getZ(), clock.instant(), "exact");
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location), key, before,
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
emit("action.observed", action, "observed", null, null, before, false);
return action;
}
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
boolean initial = !key.equals("player-aura-particles");
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
emit("action.observed", action, "observed", null, null, true, false);
emit("action.resolved", action, "refused", "player_required", null, true, false);
}
/** Written with the preference value in the same acknowledged file revision. */
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
var fields = new LinkedHashMap<String,String>();
fields.put("time", action.time().toString());
fields.put("account", action.context().playerId().toString());
fields.put("account_name", action.context().playerName());
fields.put("key", action.key());
fields.put("before", Boolean.toString(action.before()));
fields.put("saved_before", Boolean.toString(action.savedBefore()));
fields.put("requested", Boolean.toString(action.requested()));
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
if (action.context().characterId() != null) {
fields.put("character", action.context().characterId().toString());
fields.put("character_name", action.context().characterName());
fields.put("session", action.context().sessionId().toString());
}
var location = action.context().location();
if (location != null) {
fields.put("world", location.worldId().toString());
fields.put("world_name", location.worldName());
fields.put("x", Double.toString(location.x()));
fields.put("y", Double.toString(location.y()));
fields.put("z", Double.toString(location.z()));
fields.put("location_time", location.capturedAt().toString());
}
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
}
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
try {
var row = intent.captured();
UUID account = UUID.fromString(row.get("account"));
if (!"true".equals(row.get("published"))
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
== Boolean.parseBoolean(row.get("requested"))) return;
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
EventInput.Location location = row.containsKey("world")
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
Instant.parse(row.get("location_time")), "exact") : null;
var context = new EventInput.Context(account, row.get("account_name"), character,
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
row.getOrDefault("saved_before", row.get("before"))),
Boolean.parseBoolean(row.get("requested")), "player");
boolean after = Boolean.parseBoolean(row.get("requested"));
boolean effective = row.get("key").equals("player-aura-particles")
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
}
public void read(Action action) {
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
}
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
if (!published) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
return;
}
if (update.intent() == null || action.savedBefore() == action.requested()) {
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
return;
}
try {
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
});
} catch (RuntimeException failure) {
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
}
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, action.before(), false);
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, action.before(), false);
}
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
boolean effective, boolean acknowledgedIntent) {
if (action == null) return;
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
data.put("preference_key", action.key());
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
data.put("scope", "account");
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
data.put("before", Boolean.toString(action.before()));
data.put("saved_before", Boolean.toString(action.savedBefore()));
data.put("effective_before", Boolean.toString(action.before()));
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
if (action.context() != null) {
boolean latestSaved = type.equals("preference.changed") ? action.requested()
: preferenceSequence == null ? action.savedBefore()
: action.key().equals("player-aura-particles")
? preferences.savedPlayerAurasEnabled(action.context().playerId())
: action.key().equals("combat_feedback")
? preferences.savedEnabled(action.context().playerId())
: preferences.savedAllParticlesEnabled(action.context().playerId());
data.put("saved_value", Boolean.toString(latestSaved));
data.put("effective_value", Boolean.toString(effective));
data.put("effective_after", Boolean.toString(effective));
}
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
if (type.equals("preference.changed")) {
data.put("after", Boolean.toString(action.requested()));
data.put("preference_revision", Long.toString(preferenceSequence));
}
try {
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
: clock.instant(), type, action.context(), Map.copyOf(data));
var result = sink.apply(input);
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
preferences.acknowledgeEvent(action.id());
});
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
}
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
/** Feature-owned permanent-stat outcomes, isolated from purchase and native-health authority. */
public final class ProgressionEventBridge {
public record Action(UUID actionId, EventInput.Context context, Stat stat, BigInteger expectedRevision,
BigInteger statBefore, BigInteger pointsBefore, BigDecimal healthBefore,
BigDecimal maximumBefore, BigDecimal nativeLimit) {
public Action {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(stat);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(statBefore); Objects.requireNonNull(pointsBefore);
Objects.requireNonNull(healthBefore); Objects.requireNonNull(maximumBefore); Objects.requireNonNull(nativeLimit);
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ProgressionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
public Action begin(ControlInput input, CharacterRecord before, Stat stat, BigDecimal health,
BigDecimal maximum, BigDecimal nativeLimit) {
Objects.requireNonNull(input);Objects.requireNonNull(before);Objects.requireNonNull(stat);
var action=new Action(UUID.randomUUID(),context(input,before.name()),stat,before.revision(),
before.stats().get(stat),before.unspentPoints(),health,maximum.min(nativeLimit),nativeLimit);
emit("action.observed",action,"observed",null,null,null,null);
return action;
}
public void refused(Action action,String reason,CharacterRecord current){
emit("action.resolved",action,"refused",reason,current,null,null);
}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null);}
public void increased(Action action,CharacterRecord saved,String nativePublication,BigDecimal nativeHealthAfter){
emit("action.resolved",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
emit("character.stat_increased",action,"accepted","acknowledged",saved,nativePublication,nativeHealthAfter);
}
public void refused(org.bukkit.entity.Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current,Stat stat,BigDecimal nativeLimit,String reason){
var input=new ControlInput(player,active,0,"",ControlInput.Kind.BOOK,ControlInput.Gesture.USE,null,0,0,()->true);
var action=begin(input,current,stat,BigDecimal.valueOf(player.getHealth()),current.maximumHealth().min(nativeLimit),nativeLimit);
emit("action.resolved",action,"refused",reason,current,null,null);
}
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after,
String nativePublication,BigDecimal nativeHealthAfter){
var data=new LinkedHashMap<String,Object>();data.put("action_kind","character_stat_increase");
data.put("stat_id",action.stat().name());data.put("expected_revision",action.expectedRevision().toString());
data.put("before_revision",action.expectedRevision().toString());data.put("stat_before",action.statBefore().toString());
data.put("points_before",action.pointsBefore().toString());data.put("health_before",action.healthBefore().toPlainString());
data.put("maximum_health_before",action.maximumBefore().toPlainString());data.put("native_health_ceiling",action.nativeLimit().toPlainString());
data.put("outcome",outcome);if(reason!=null)data.put(type.equals("character.stat_increased")?"native_boundary":"reason",reason);
if(after!=null){data.put("after_revision",after.revision().toString());data.put("stat_after",after.stats().get(action.stat()).toString());data.put("points_after",after.unspentPoints().toString());data.put("health_after",(nativeHealthAfter==null?after.health():nativeHealthAfter).toPlainString());data.put("maximum_health_after",after.maximumHealth().min(action.nativeLimit()).toPlainString());}
if(nativePublication!=null)data.put("native_publication",nativePublication);
try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(ControlInput input,String characterName){var player=input.player();return new EventInput.Context(player.getUniqueId(),player.getName(),input.session().character(),characterName,input.session().session(),location(player.getLocation()));}
private EventInput.Location location(Location location){return new EventInput.Location(location.getWorld().getUID(),location.getWorld().getName(),location.getX(),location.getY(),location.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:progression:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,21 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.shop.RareCatalog;
import games.dmg.fantasystick.shop.RareProduct;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
/** Failure-isolated Phoenix Down catalog and acknowledged purchase event adapter. */
public final class RareEventBridge {
private final Consumer<EventInput> sink;private final Clock clock;
public RareEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public void catalogRefreshed(RareCatalog catalog){if(!catalog.available(RareProduct.PHOENIX_DOWN))return;var offer=catalog.offer(RareProduct.PHOENIX_DOWN);var data=new LinkedHashMap<String,Object>();data.put("actor_kind","system");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.revision().toString());data.put("catalog_day",catalog.day().toString());data.put("refresh_reason",catalog.reason().name().toLowerCase(java.util.Locale.ROOT));data.put("price",offer.price().toString());data.put("stock",Integer.toString(offer.remaining()));data.put("outcome","acknowledged");emit(stable(catalog.revision(),"phoenix-catalog"),catalog.revision(),catalog.refreshedAt(),"rare_catalog.refreshed",null,data);}
public void purchased(UUID operation,UUID catalog,UUID player,String playerName,UUID character,String characterName,UUID session,BigInteger price,int stockBefore,int stockAfter,BigInteger quantityBefore,BigInteger quantityAfter){var data=new LinkedHashMap<String,Object>();data.put("actor_kind","player");data.put("product_id",RareProduct.PHOENIX_DOWN.id());data.put("catalog_revision",catalog.toString());data.put("purchase_operation_id",operation.toString());data.put("price",price.toString());data.put("stock_before",Integer.toString(stockBefore));data.put("stock_after",Integer.toString(stockAfter));data.put("quantity_before",quantityBefore.toString());data.put("quantity_after",quantityAfter.toString());data.put("outcome","acknowledged");var context=new EventInput.Context(player,playerName,character,characterName,session,null);emit(stable(operation,"phoenix-purchased"),operation,clock.instant(),"rare_item.purchased",context,data);}
private void emit(UUID id,UUID action,java.time.Instant time,String type,EventInput.Context context,java.util.Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,data));}catch(RuntimeException ignored){}}
private static UUID stable(UUID authority,String kind){return UUID.nameUUIDFromBytes(("fantasystick:"+authority+':'+kind).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,223 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
/** An acknowledged native discovery has one recoverable, account-keyed event intent. */
public final class RecipeDiscoveryEventBridge implements AutoCloseable {
private static final String RECIPE = "fantasystick:character_sheet";
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-recipe-discovery-events"));
public RecipeDiscoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("recipe-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public void discovered(Player player, String trigger, int notifications) {
try {
var record = capture(player, trigger, notifications > 0);
worker.execute(() -> {
try { publish(write(record)); }
catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
/** Called only after the shared writer's startup recovery is ready. */
public void replay() {
try {
worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
});
} catch (RuntimeException failure) { report(failure); }
}
public void failed(Player player, String trigger, String reason) {
try {
var action = UUID.randomUUID();
var context = context(player);
emit(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + action + ":failed")
.getBytes(StandardCharsets.UTF_8)), action, clock.instant(), "action.resolved", context,
Map.of("action_kind", "recipe_discovery", "recipe_id", RECIPE, "trigger", trigger,
"outcome", "failed", "reason", reason)));
} catch (RuntimeException failure) { report(failure); }
}
private record Record(UUID account, UUID action, Instant time, String name, String trigger,
boolean notification, EventInput.Location location) { }
private Record capture(Player player, String trigger, boolean notification) {
var context = context(player);
return new Record(context.playerId(), id(context.playerId()), clock.instant(), context.playerName(),
trigger, notification, context.location());
}
private EventInput.Context context(Player player) {
var location = player.getLocation();
EventInput.Location where = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, where);
}
private void publish(Record record) {
var fields = new LinkedHashMap<String, Object>();
fields.put("action_kind", "recipe_discovery");
fields.put("recipe_id", RECIPE);
fields.put("recipe_category", "misc");
fields.put("trigger", record.trigger());
fields.put("outcome", "acknowledged");
fields.put("first_discovery_notification", record.notification());
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + record.action()
+ ":recipe.discovered").getBytes(StandardCharsets.UTF_8)), record.action(), record.time(),
"recipe.discovered", new EventInput.Context(record.account(), record.name(), null, null, null,
record.location()), fields);
emit(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try {
if (Files.deleteIfExists(file(record.account()))) forceDirectory();
} catch (IOException deleteFailure) { report(deleteFailure); }
});
}
private CompletionStage<LocalEventLog.Delivery> emit(EventInput input) {
try { return sink.apply(input); }
catch (RuntimeException failure) {
report(failure);
return java.util.concurrent.CompletableFuture.completedStage(LocalEventLog.Delivery.REJECTED);
}
}
private Path file(UUID account) { return pending.resolve(account + ".pending"); }
private Record write(Record record) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
Files.createDirectories(pending);
Path target = file(record.account());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
return read(target); // Preserve the first native acknowledgment and its original identity.
}
var values = new Properties();
values.setProperty("account", record.account().toString());
values.setProperty("action", record.action().toString());
values.setProperty("time", record.time().toString());
values.setProperty("name", record.name());
values.setProperty("trigger", record.trigger());
values.setProperty("notification", Boolean.toString(record.notification()));
if (record.location() != null) {
var location = record.location();
values.setProperty("world", location.worldId().toString());
values.setProperty("world_name", location.worldName());
values.setProperty("x", Double.toString(location.x()));
values.setProperty("y", Double.toString(location.y()));
values.setProperty("z", Double.toString(location.z()));
values.setProperty("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
values.store(output, "acknowledged native recipe discovery");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
Path temporary = Files.createTempFile(pending, ".recipe-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return record;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private Record read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
var values = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
values.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var account = UUID.fromString(values.getProperty("account"));
var action = UUID.fromString(values.getProperty("action"));
if (!path.equals(file(account)) || !action.equals(id(account))) throw new IOException("Invalid recipe event identity");
EventInput.Location location = values.containsKey("world")
? new EventInput.Location(UUID.fromString(values.getProperty("world")), values.getProperty("world_name"),
Double.parseDouble(values.getProperty("x")), Double.parseDouble(values.getProperty("y")),
Double.parseDouble(values.getProperty("z")), Instant.parse(values.getProperty("location_time")), "exact")
: null;
var trigger = values.getProperty("trigger");
if (!"join".equals(trigger) && !"enable_online".equals(trigger)) throw new IOException("Invalid recipe event trigger");
var notification = values.getProperty("notification");
if (!"true".equals(notification) && !"false".equals(notification)) throw new IOException("Invalid recipe notification");
return new Record(account, action, Instant.parse(values.getProperty("time")),
Objects.requireNonNull(values.getProperty("name")), trigger, Boolean.parseBoolean(notification), location);
} catch (RuntimeException invalid) { throw new IOException("Invalid recipe event intent", invalid); }
}
public static UUID id(UUID player) {
return UUID.nameUUIDFromBytes(("fantasystick:recipe:character_sheet:" + player)
.getBytes(StandardCharsets.UTF_8));
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Recipe event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
}
@@ -0,0 +1,40 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityExecution;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Learned Revive Tail-branch cast and consume-once restoration evidence. */
public final class ReviveEventBridge {
public record Cast(UUID actionId,EventInput.Context caster,UUID casterCharacter,String casterCharacterName,
UUID targetPlayer,String targetPlayerName,Map<String,Object> targetLocation,
Instant castAt,Instant cooldownUntil,double range){
public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);}
}
private final Consumer<EventInput> sink;private final Clock clock;
public ReviveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;}
public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);}
public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);}
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);}
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);}
private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap<String,Object>();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));}
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());}
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map<String,Object> extra){var data=new LinkedHashMap<String,Object>();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);}
private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
private EventInput.Location location(Location value){return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");}
private static Map<String,Object> locationData(Location value,Instant at){return Map.of("world_id",value.getWorld().getUID().toString(),"world_name",value.getWorld().getName(),"x",Double.toString(value.getX()),"y",Double.toString(value.getY()),"z",Double.toString(value.getZ()),"captured_at",at,"quality","exact");}
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:revive:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
private void emit(UUID id,UUID action,Instant time,String type,EventInput.Context context,Map<String,Object> data){try{sink.accept(new EventInput(id,action,time,type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}}
}
@@ -0,0 +1,99 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes acknowledged session boundaries; the log never authorizes a transition. */
public final class SessionEventBridge {
private final Consumer<EventInput> sink;
private final Clock clock;
private final java.util.concurrent.ConcurrentHashMap<UUID, EventInput.Context> current = new java.util.concurrent.ConcurrentHashMap<>();
public SessionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public void entered(Player player, UUID session, CharacterRecord record, Location entry,
boolean resumedDowned, boolean adventureMode) {
try {
var now = clock.instant();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", record.id().toString());
data.put("revision", record.revision().toString());
data.put("entry_location", locationMap(entry));
data.put("resumed_downed", resumedDowned);
data.put("adventure_mode_applied", adventureMode);
data.put("equipment_outcome", "packed");
data.put("session_receipt", "acknowledged");
data.put("outcome", "active");
var context = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
current.put(context.playerId(), context);
sink.accept(new EventInput(id(session, "entered"), session, now, "character.entered", context, data));
} catch (RuntimeException ignored) {
// An unavailable journal cannot change the acknowledged session.
}
}
/** Thread-safe last native capture, never a live Bukkit lookup from asynchronous chat. */
public EventInput.Context lastKnown(UUID player, UUID session, String characterName) {
var value = current.get(player);
if (value == null || !value.sessionId().equals(session)) return null;
return new EventInput.Context(value.playerId(), value.playerName(), value.characterId(), characterName,
session, value.location());
}
public void forget(UUID player, UUID session) {
var value=current.get(player);
if(value!=null&&value.sessionId().equals(session))current.remove(player,value);
}
public record ExitCapture(EventInput.Context context, java.util.Map<String, Object> entryLocation, String revision) { }
public ExitCapture captureExit(Player player, UUID session, CharacterRecord record, Location entry) {
try {
return new ExitCapture(new EventInput.Context(player.getUniqueId(), player.getName(), record.id(),
record.name(), session, location(player.getLocation())), locationMap(entry), record.revision().toString());
} catch (RuntimeException ignored) { return null; }
}
public void exited(UUID session, ExitCapture capture) {
if (capture == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", capture.context().characterId().toString());
data.put("revision", capture.revision());
data.put("entry_location", capture.entryLocation());
data.put("reason", "living_exit");
data.put("equipment_outcome", "restored");
data.put("session_receipt", "completed");
data.put("outcome", "acknowledged");
forget(capture.context().playerId(),session);
sink.accept(new EventInput(id(session, "exited"), session, clock.instant(), "character.exited", capture.context(), data));
} catch (RuntimeException ignored) { }
}
private java.util.Map<String, Object> locationMap(Location value) {
return java.util.Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "native_capture");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:session:" + session + ':' + stage).getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,200 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.AdventureSettings;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Captured administrative/session recovery observations; never authorizes native recovery. */
public final class SessionRecoveryEventBridge implements AutoCloseable {
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
String locationStatus, UUID target) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final FeatureEventOutbox outbox;
private final Consumer<Throwable> failures;
private final Clock clock;
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.failures = Objects.requireNonNull(failures);
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
Set.of("recovery.resolved", "config.changed"), sink, failures);
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
public Action capture(CommandSender sender, UUID target) {
try {
var now = clock.instant();
if (sender instanceof Player player) {
var location = player.getLocation();
EventInput.Location origin = location == null || location.getWorld() == null ? null
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
location.getX(), location.getY(), location.getZ(), now, "exact");
return new Action(UUID.randomUUID(), now,
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
"player", origin == null ? "unavailable" : null, target);
}
return new Action(UUID.randomUUID(), now, null,
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
"not_applicable", target);
} catch (RuntimeException ignored) { return null; }
}
public void observed(Action action, String kind) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", kind);
data.put("outcome", "observed");
emit(action, "action.observed", "observed", data);
}
public void resolved(Action action, String kind, String outcome, String reason) {
if (action == null) return;
var data = fields(action);
if (outcome.equals("refused")) data.remove("target_player_id");
data.put("action_kind", kind);
data.put("outcome", outcome);
data.put("reason", reason);
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
}
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
String nativePhase, boolean online) {
if (action == null) return;
var data = fields(action);
data.put("action_kind", "equipment_inspect");
data.put("outcome", "authorized");
var saved = state.orElse(null);
var receipt = saved == null ? null : saved.pending();
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
if (saved != null) data.put("receipt_revision", saved.revision().toString());
if (receipt != null) {
data.put("session_id", receipt.sessionId().toString());
data.put("character_id", receipt.characterId().toString());
}
data.put("native_phase", online ? nativePhase : "offline_unavailable");
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
emit(action, "recovery.inspected", "inspected", data);
}
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
String currentPhase, String outcome, String quality, String reason,
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
if (action == null) return;
var data = fields(action);
data.put("recovery_operation_id", action.id().toString());
if (session != null) data.put("session_id", session.toString());
if (character != null) data.put("character_id", character.toString());
data.put("prior_phase", priorPhase);
data.put("current_phase", currentPhase);
data.put("custody_quality", quality);
data.put("outcome", outcome);
data.put("reason", reason);
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
nativeLocation(data, "original", original);
nativeLocation(data, "current", current);
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
? "native_equipment_unacknowledged" : "not_independently_verified");
emit(action, "recovery.resolved", "reconciled", data);
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
}
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
public EventInput.Location captureLocation(Location value) {
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
}
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
if (location == null) {
data.put(name + "_location_status", "unavailable");
return;
}
data.put(name + "_world_id", location.worldId().toString());
data.put(name + "_world_name", location.worldName());
data.put(name + "_x", location.x());
data.put(name + "_y", location.y());
data.put(name + "_z", location.z());
data.put(name + "_captured_at", location.capturedAt().toString());
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
}
public Action system(Player player) {
try {
var now = clock.instant();
var location = player.getLocation();
var action = new Action(UUID.randomUUID(), now, null, "system",
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
player.getUniqueId());
return action;
} catch (RuntimeException ignored) { return null; }
}
public void configRead(Action action, boolean effective, Optional<String> revision) {
if (action == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("effective_value", Boolean.toString(effective));
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
else data.put("persistence_revision_status", "not_verified_at_read");
data.put("outcome", "authorized");
emit(action, "config.read", "read", data);
}
public void configChanged(Action action, AdventureSettings.Saved saved) {
if (action == null || saved == null) return;
var data = fields(action);
data.put("key", "adventure-mode");
data.put("before_value", Boolean.toString(saved.beforeValue()));
data.put("after_value", Boolean.toString(saved.value()));
data.put("effective_value", Boolean.toString(saved.value()));
data.put("persistence_revision", saved.revision());
data.put("application", "future_sessions");
data.put("outcome", "acknowledged");
emit(action, "config.changed", "changed", data);
}
private LinkedHashMap<String, Object> fields(Action action) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", action.actorKind());
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
if (action.target() != null) data.put("target_player_id", action.target().toString());
return data;
}
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
try {
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
else sink.apply(event);
}
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
}
}
@@ -0,0 +1,110 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.SwitchDelaySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Observes the registered administrator's acknowledged switch policy; never changes deadlines. */
public final class SwitchConfigEventBridge implements AutoCloseable {
public record Actor(String kind, EventInput.Context context) { }
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final EquipmentSessions sessions;
private final FeatureEventOutbox outbox;
public SwitchConfigEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, EquipmentSessions sessions,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "switch-config-event-intents", java.util.Set.of("config.changed"),
sink, failures);
}
public Actor actor(CommandSender sender) {
try {
var player = sender instanceof Player value ? value : null;
var kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
var active = player == null ? null : sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor(kind, player == null ? null : new EventInput.Context(player.getUniqueId(),
player.getName(), record == null ? null : record.id(),
record == null ? null : record.name(), record == null ? null : active.session(),
safeLocation(player)));
} catch (RuntimeException unavailable) { return null; }
}
public void read(Actor actor, String effective) {
if (actor == null) return;
var data = common(actor);
data.put("effective", effective);
data.put("outcome", "acknowledged_read");
var action = UUID.randomUUID();
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
"config.read", actor.context(), data)); }
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
}
public void changed(Actor actor, UUID operation, String before, String effective,
SwitchDelaySettings.Update saved) {
if (actor == null) return;
try {
var data = common(actor);
data.put("operation_id", operation.toString());
data.put("store_sequence", Long.toString(saved.sequence()));
data.put("before", before);
data.put("after", saved.value());
data.put("effective", effective);
data.put("zero_disabled", saved.delay().isZero());
data.put("outcome", "acknowledged");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), data));
} catch (RuntimeException ignored) { /* A failed audit cannot roll back an effective setting. */ }
}
private static Map<String, Object> common(Actor actor) {
var data = new LinkedHashMap<String, Object>();
data.put("key", SwitchDelaySettings.KEY);
data.put("default", SwitchDelaySettings.DEFAULT);
data.put("application_scope", "future_exit");
data.put("existing_deadlines_unchanged", true);
data.put("actor_kind", actor.kind());
if (actor.context() == null) data.put("location_status", "not_applicable");
else if (actor.context().location() == null) data.put("location_status", "unavailable");
return data;
}
private EventInput.Location safeLocation(Player player) {
try {
Location location = player.getLocation();
if (location == null || location.getWorld() == null) return null;
var world = location.getWorld();
return new EventInput.Location(world.getUID(), world.getName(), location.getX(),
location.getY(), location.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:switch-config:" + action + ":" + kind)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -0,0 +1,265 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes persisted switch deadlines and pre-pack admission without authorizing either. */
public final class SwitchDelayEventBridge implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-switch-delay-events"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SwitchDelayEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("switch-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public record ExitContext(UUID player, String playerName, UUID character, String characterName,
UUID session, EventInput.Location location, String reason) { }
public ExitContext exit(Player player, UUID session, CharacterRecord character, String reason) {
try {
return new ExitContext(player.getUniqueId(), player.getName(), character.id(), character.name(),
session, location(player.getLocation()), reason);
} catch (RuntimeException ignored) { return null; }
}
private record SavedStart(ExitContext capture, EquipmentSessionStore.Exit exit,
String revisionBefore, String revisionAfter, Instant time) { }
public void started(ExitContext captured, EquipmentSessionStore.State saved) {
if (captured == null || saved == null || saved.pending() != null || saved.lastExit() == null
|| !captured.session().equals(saved.lastExit().sessionId())) return;
var start = new SavedStart(captured, saved.lastExit(),
saved.revision().subtract(java.math.BigInteger.ONE).toString(), saved.revision().toString(),
saved.lastExit().exitedAt());
try { worker.execute(() -> {
try { publish(write(start)); }
catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private void publish(SavedStart start) {
var captured = start.capture();
var exit = start.exit();
var values = new LinkedHashMap<String, Object>();
values.put("outcome", "acknowledged");
values.put("exit_reason", captured.reason());
values.put("exit_operation_id", captured.session().toString());
values.put("character_id", exit.characterId().toString());
values.put("session_id", exit.sessionId().toString());
values.put("duration_seconds", Long.toString(Duration.between(exit.exitedAt(), exit.deadline()).getSeconds()));
values.put("exited_at", exit.exitedAt().toString());
values.put("deadline", exit.deadline().toString());
values.put("revision_before", start.revisionBefore());
values.put("revision_after", start.revisionAfter());
values.put("zero_duration", exit.deadline().equals(exit.exitedAt()));
var input = new EventInput(id(captured.session(), "started"), captured.session(), start.time(),
"character.switch_delay_started", new EventInput.Context(captured.player(), captured.playerName(),
captured.character(), captured.characterName(), captured.session(), captured.location()), values);
try { sink.apply(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(file(captured.session()))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
/** Replay only persisted, previously acknowledged exits, before any new native session observations. */
public void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private Path file(UUID session) { return pending.resolve(session + ".pending"); }
private SavedStart write(SavedStart value) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
Files.createDirectories(pending);
Path target = file(value.capture().session());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
var capture = value.capture(); var exit = value.exit();
fields.setProperty("player", capture.player().toString());
fields.setProperty("player_name", capture.playerName());
fields.setProperty("character", capture.character().toString());
fields.setProperty("character_name", capture.characterName());
fields.setProperty("session", capture.session().toString());
fields.setProperty("reason", capture.reason());
fields.setProperty("exited_at", exit.exitedAt().toString());
fields.setProperty("deadline", exit.deadline().toString());
fields.setProperty("revision_before", value.revisionBefore());
fields.setProperty("revision_after", value.revisionAfter());
fields.setProperty("time", value.time().toString());
if (capture.location() != null) {
var location = capture.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
fields.setProperty("x", Double.toString(location.x()));
fields.setProperty("y", Double.toString(location.y()));
fields.setProperty("z", Double.toString(location.z()));
fields.setProperty("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged switch deadline");
byte[] bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
Path temporary = Files.createTempFile(pending, ".switch-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return value;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private SavedStart read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intent");
var fields = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
fields.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var player = UUID.fromString(fields.getProperty("player"));
var character = UUID.fromString(fields.getProperty("character"));
var session = UUID.fromString(fields.getProperty("session"));
if (!path.equals(file(session))) throw new IOException("Switch event identity mismatch");
var reason = fields.getProperty("reason");
if (!java.util.Set.of("voluntary", "logout", "defeat", "shutdown").contains(reason))
throw new IOException("Invalid switch exit reason");
EventInput.Location location = fields.containsKey("world")
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("location_time")), "exact")
: null;
var before = fields.getProperty("revision_before"); var after = fields.getProperty("revision_after");
if (!before.matches("0|[1-9][0-9]*") || !after.matches("[1-9][0-9]*"))
throw new IOException("Invalid switch revision");
var exit = new EquipmentSessionStore.Exit(session, character,
Instant.parse(fields.getProperty("exited_at")), Instant.parse(fields.getProperty("deadline")));
return new SavedStart(new ExitContext(player, Objects.requireNonNull(fields.getProperty("player_name")),
character, Objects.requireNonNull(fields.getProperty("character_name")), session, location, reason),
exit, before, after, Instant.parse(fields.getProperty("time")));
} catch (RuntimeException invalid) { throw new IOException("Invalid switch event intent", invalid); }
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
/** Shutdown does not wait for file I/O on the Minecraft thread. */
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS))
report(new IOException("Switch event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
public CompletionStage<Void> drained() { return drained; }
public void evaluated(Player player, UUID requested, EquipmentSessionStore.State state,
Duration configured, Instant now, boolean allowed) {
try {
var action = UUID.randomUUID();
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_switch_preflight");
values.put("requested_character_id", requested.toString());
values.put("configured_duration_seconds", Long.toString(configured.getSeconds()));
values.put("preflight_order", "before_equipment_pack");
values.put("outcome", "observed");
var exit = state == null ? null : state.lastExit();
boolean same = exit != null && requested.equals(exit.characterId());
values.put("same_character", same);
values.put("last_exited_character_id", exit == null ? "none" : exit.characterId().toString());
if (exit != null) {
values.put("deadline", exit.deadline().toString());
var remaining = Duration.between(now, exit.deadline());
values.put("remaining_seconds", Long.toString(Math.max(0,
remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0))));
values.put("receipt_revision", state.revision().toString());
}
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
emit(new EventInput(id(action, "observed"), action, clock.instant(), "action.observed", context, values));
values.put("outcome", allowed ? "allowed" : "refused");
values.put("reason", allowed ? (same ? "same_character" : "prepack_allowed") : "cooldown");
emit(new EventInput(id(action, "resolved"), action, clock.instant(), "action.resolved", context, values));
} catch (RuntimeException ignored) { /* Logging cannot change entry admission. */ }
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private void emit(EventInput event) {
try { sink.apply(event); } catch (RuntimeException ignored) { }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:switch-delay:" + action + ':' + kind)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.math.BigInteger;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -17,6 +18,7 @@ public final class CharacterSheets {
public static final NamespacedKey MODEL = 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 NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation");
private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
@@ -28,6 +30,7 @@ public final class CharacterSheets {
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
data.set(GENERATION, PersistentDataType.STRING, character.sheetGeneration().toString());
meta.setItemModel(MODEL);
item.setItemMeta(meta);
return item;
@@ -65,6 +68,27 @@ public final class CharacterSheets {
return true;
}
public record Identity(UUID character, BigInteger generation, boolean legacy) { }
public Optional<Identity> identity(ItemStack item) {
var character = identify(item);
if (character.isEmpty()) return Optional.empty();
var data = item.getItemMeta().getPersistentDataContainer();
if (!data.has(GENERATION, PersistentDataType.STRING))
return Optional.of(new Identity(character.orElseThrow(), BigInteger.ZERO, true));
try {
var generation = new BigInteger(data.get(GENERATION, PersistentDataType.STRING));
if (generation.signum() < 0) return Optional.empty();
return Optional.of(new Identity(character.orElseThrow(), generation, false));
} catch (RuntimeException invalid) { return Optional.empty(); }
}
public boolean authorizes(ItemStack item, CharacterRecord record) {
return identity(item).filter(value -> value.character().equals(record.id())
&& value.generation().equals(record.sheetGeneration())
&& (!value.legacy() || record.sheetGeneration().signum() == 0)).isPresent();
}
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
public Optional<UUID> identify(ItemStack item) {
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
@@ -77,6 +77,41 @@ public final class EquipmentStorage {
return new PackPlan(PackResult.PACKED, item, available);
}
/** Bounded custody/slot-class proof; never exposes the box's items or metadata to event payloads. */
public record Snapshot(String boxSource, int boxSlot, int hotbarSlots, int armorSlots, boolean displacedHead) {
public int packedSlots() { return hotbarSlots + armorSlots; }
public java.util.List<String> slotClasses() {
var result = new java.util.ArrayList<String>(2);
if (hotbarSlots > 0) result.add("hotbar");
if (armorSlots > 0) result.add("armor");
return java.util.List.copyOf(result);
}
}
/** Read-only native proof at the packing/restoration boundary, including moved or cursor custody. */
public java.util.Optional<Snapshot> snapshot(PlayerInventory inventory, UUID owner, UUID session, ItemStack cursor) {
Snapshot found = null;
for (int slot = 9; slot <= 41; slot++) {
if (slot >= 36 && slot < 40) continue;
var item = slot == 41 ? cursor : inventory.getItem(slot);
if (!matches(item, owner, session)) continue;
if (found != null || item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|| !(meta.getBlockState() instanceof ShulkerBox box)) return java.util.Optional.empty();
var fields = meta.getPersistentDataContainer();
if (!fields.has(VERSION, PersistentDataType.INTEGER) || fields.get(VERSION, PersistentDataType.INTEGER) != 1
|| !Arrays.equals(EQUIPMENT_SLOTS, fields.get(SLOTS, PersistentDataType.INTEGER_ARRAY)))
return java.util.Optional.empty();
var contents = box.getInventory();
int hotbar = 0, armor = 0;
for (int index = 0; index < EQUIPMENT_SLOTS.length; index++) {
if (!empty(contents.getItem(index))) { if (index < 9) hotbar++; else armor++; }
}
found = new Snapshot(slot == 41 ? "cursor" : slot == 40 ? "offhand" : "main",
slot, hotbar, armor, !empty(inventory.getItem(39)));
}
return java.util.Optional.ofNullable(found);
}
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
@@ -145,7 +145,7 @@ public final class SessionControls {
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 || material == Material.COMPASS) && !data.has(ABILITY);
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS || material == Material.FISHING_ROD) && !data.has(ABILITY);
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
@@ -6,18 +6,20 @@ 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.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.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
@@ -63,6 +65,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
private final CombatSources sources;
private final CombatContributions contributions;
private final PartyRoster parties;
@@ -74,6 +77,9 @@ public final class CombatExperience implements Listener, AutoCloseable {
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
private org.bukkit.scheduler.BukkitTask retryTask;
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
private java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> managedOrigins=ignored->java.util.Optional.empty();
private Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managedSettlement=ignored->{};
private games.dmg.fantasystick.events.CombatRewardEventBridge events;
private boolean closed;
private final class PendingAward {
@@ -83,12 +89,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
final BigInteger coins;
final CombatRewardRules.KillPosition restricted;
final CombatRewardRules rewardRules;
BigInteger expectedRevision;
boolean running;
final games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture;
BigInteger expectedRevision;CharacterRecord before;
boolean running;final CompletableFuture<Void> settled=new CompletableFuture<>();
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules,
games.dmg.fantasystick.events.CombatRewardEventBridge.Capture capture) {
this.id = id; this.character = character; this.experience = experience;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;this.capture=Objects.requireNonNull(capture);
}
}
@@ -98,7 +106,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
var player = players.apply(id); return player != null && player.isOnline();
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
clock, nextTick, players, failures);
clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
@@ -106,12 +115,22 @@ public final class CombatExperience implements Listener, AutoCloseable {
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this(characters, progression, rewards, sessions, parties, coinRules, random, clock, nextTick, players, failures,
games.dmg.fantasystick.presentation.ParticleDelivery.legacyBroadcast());
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, PartyRoster parties,
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures,
games.dmg.fantasystick.presentation.ParticleDelivery particles) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
this.failures = Objects.requireNonNull(failures); this.particles = Objects.requireNonNull(particles);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
@@ -120,6 +139,12 @@ public final class CombatExperience implements Listener, AutoCloseable {
excluded = Objects.requireNonNull(predicate);
}
public void attachManagedOrigins(java.util.function.Function<LivingEntity,java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin>> classifier){
if(closed)throw new IllegalStateException("Combat experience is closed");managedOrigins=Objects.requireNonNull(classifier);
}
public void attachManagedSettlement(Consumer<games.dmg.fantasystick.progression.ManagedEncounterOrigin> settlement){if(closed)throw new IllegalStateException("Combat experience is closed");managedSettlement=Objects.requireNonNull(settlement);}
public void attachEvents(games.dmg.fantasystick.events.CombatRewardEventBridge value){if(closed||events!=null)throw new IllegalStateException("Combat reward events already attached or combat is closed");events=Objects.requireNonNull(value);}
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);
@@ -174,8 +199,25 @@ public final class CombatExperience implements Listener, AutoCloseable {
defeat.level(), defeat.active().character(), defeat.coins());
}
/** Consumed synchronously by the fantasy defeat transition before it persists the Tail state. */
public boolean consumePvpDefeat(UUID player) { return pvpDefeats.remove(player); }
/** Captures short-lived contributors before the downed wait; no reward is published yet. */
public EquipmentSessions.DefeatCapture captureDownedDefeat(UUID player){
var entry=defeats.entrySet().stream().filter(value->value.getValue().active().player().equals(player)).findFirst().orElse(null);
if(entry==null)return new EquipmentSessions.DefeatCapture(pvpDefeats.remove(player),List.of(),0,BigInteger.ZERO);
defeats.remove(entry.getKey());var defeat=entry.getValue();var direct=new ArrayList<>(contributions.consumeDirect(player,clock.instant()));direct.remove(defeat.active().character());boolean pvp=pvpDefeats.remove(player)||!direct.isEmpty();return new EquipmentSessions.DefeatCapture(pvp,List.copyOf(direct),defeat.level(),defeat.coins());
}
/** Backward-compatible test adapter; production captures the complete durable context. */
public boolean consumePvpDefeat(UUID player){return captureDownedDefeat(player).playerDefeat();}
/** Freeze final-time eligibility and exact shares before the durable death claim. */
public List<games.dmg.fantasystick.session.DownedEpisode.Reward> allocateDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
var attribution=episode.attribution();if(!attribution.playerDefeat()||attribution.contributors().isEmpty())return List.of();var units=rewardUnits(new ArrayList<>(attribution.contributors()));if(units.isEmpty())return List.of();var capturedRewards=rewards;var replaySafeRandom=new java.util.Random(episode.id().getMostSignificantBits()^episode.id().getLeastSignificantBits());var experiencePool=capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,attribution.victimLevel());var xp=games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool,units,replaySafeRandom);var coins=games.dmg.fantasystick.progression.RewardAllocator.allocate(attribution.victimCoins(),units,replaySafeRandom);return xp.keySet().stream().sorted().map(character->{var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();return new games.dmg.fantasystick.session.DownedEpisode.Reward(character,xp.get(character),coins.get(character),String.valueOf(unit.key()),units.size());}).toList();
}
/** Apply only the shares frozen in the winning durable final-death claim. */
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
@@ -193,27 +235,33 @@ public final class CombatExperience implements Listener, AutoCloseable {
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
BigInteger defeatedCoins) {
Instant now = clock.instant();
java.util.Optional<games.dmg.fantasystick.progression.ManagedEncounterOrigin> managed;try{managed=managedOrigins.apply(target);}catch(RuntimeException invalid){managed=java.util.Optional.empty();}
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
if (direct.isEmpty()) return;
if (direct.isEmpty()) {managed.ifPresent(this::settled);return;}
var units = rewardUnits(direct);
BigInteger coinPool = defeatedCharacter == null
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
if (units.isEmpty()) {managed.ifPresent(this::settled);return;} // The defeat transition has already persisted any PvP balance loss.
var capturedRewards = rewards;
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
var experiencePool=capturedRewards.pool(kind,level);
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(experiencePool, units, random);
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
CombatRewardRules.KillPosition restricted = managed.isPresent()||kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
String authority=managed.map(value->value.kind()+":"+value.source()).orElse("ordinary");
var settlements=new ArrayList<CompletableFuture<Void>>();
for (UUID character : xp.keySet()) {
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
capturedRewards);
pending.put(operation.id, operation);
attempt(operation);
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool);
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
}
managed.ifPresent(origin->CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new)).thenRun(()->settled(origin)));
}
private void settled(games.dmg.fantasystick.progression.ManagedEncounterOrigin origin){try{managedSettlement.accept(origin);}catch(RuntimeException ignored){}}
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
java.util.List<UUID> direct) {
@@ -261,8 +309,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
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();
if (record.isEmpty() || record.orElseThrow().dead()) { complete(operation); return; }
operation.before=record.orElseThrow();operation.expectedRevision = operation.before.revision();
attempt(operation);
});
return;
@@ -272,15 +320,16 @@ public final class CombatExperience implements Listener, AutoCloseable {
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;
operation.before=award.record().orElseThrow();operation.expectedRevision = operation.before.revision(); attempt(operation); return;
}
pending.remove(operation.id);
complete(operation);
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 complete(PendingAward operation){pending.remove(operation.id,operation);operation.settled.complete(null);}
private void fail(PendingAward operation, Throwable failure) {
report(operation.character, failure);
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
@@ -301,19 +350,17 @@ public final class CombatExperience implements Listener, AutoCloseable {
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());
boolean synchronizedSession=active==null||sessions.acknowledgeProgress(active,saved.revision().subtract(BigInteger.ONE),saved)||sessions.currentRecord(active).filter(saved::equals).isPresent();
Player player=active==null?null:players.apply(active.player());String feedback=feedback(operation,award,player,synchronizedSession);var bridge=events;if(bridge!=null)bridge.resolved(operation.id,operation.character,operation.experience,operation.coins,operation.restricted,operation.rewardRules,operation.capture,operation.before,award,active,player,synchronizedSession?active==null?"not_applicable_offline":"acknowledged":"failed",feedback);
if(!synchronizedSession){report(operation.character,new IllegalStateException("Saved combat progress could not synchronize with active session"));return;}
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " 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);
particles.world(org.bukkit.Particle.DUST, player.getLocation().add(0, 1, 0), 12,
0.3, 0.4, 0.3, 0, new org.bukkit.Particle.DustOptions(Color.BLUE, 1));
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
@@ -323,11 +370,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE
&& award.status() != CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("Earned " + operation.coins + " character coins.");
}
}
private static String feedback(PendingAward operation,CharacterStorage.CombatAward award,Player player,boolean synchronizedSession){if(!synchronizedSession)return "suppressed_native_sync_failed";if(player==null||!player.isOnline())return "not_delivered_offline";return switch(award.status()){case APPLIED->operation.experience.signum()>0&&operation.coins.signum()>0?"experience_and_coins":operation.experience.signum()>0?"experience":operation.coins.signum()>0?"coins":"none";case SPACING_BLOCKED->"spacing_refusal";case AT_CAP->operation.coins.signum()>0?"maximum_level_and_coins":"maximum_level";case UNCHANGED->"zero_reward";case DUPLICATE->"none_duplicate";case STALE,UNAVAILABLE->"none_unavailable";};}
public void configure(CombatRewardRules updated) {
if (closed) throw new IllegalStateException("Combat rewards are closed");
rewards = Objects.requireNonNull(updated);
@@ -5,6 +5,7 @@ import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import java.util.WeakHashMap;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
private boolean closed;
private final PartyRoster parties;
private final CombatSources sources;
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String reason) { }
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
String decision) { }
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
this.parties = Objects.requireNonNull(parties);
this.sources = new CombatSources(sessions);
}
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
protectionEvents = Objects.requireNonNull(events);
}
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);
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
@Override public void close() {
closed = true;
damageAudits.clear();
nativeAudits.clear();
if (cleanup != null) cleanup.cancel();
}
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
@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);
if (origin == null) return;
var target = event.getHitEntity() instanceof Player player ? player : null;
var reason = !sources.current(origin) ? "stale_source"
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
? protectionReason(origin, target) : null;
if (reason != null) {
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
reason, "projectile_impact");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
// 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);
if (origin != null && !sources.current(origin)) {
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
.map(Player.class::cast).toList();
captureNative(event, "party_splash_protection", event.getEntity(),
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
resolveNative(event, event);
}
@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);
if (origin != null && !sources.current(origin)) {
captureNative(event, "party_lingering_protection", event.getEntity(), null,
origin, "stale_source", "lingering_splash");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedLingering(LingeringPotionSplashEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
}
@EventHandler(priority = EventPriority.LOWEST)
public void observeDamage(EntityDamageEvent event) {
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
var origin = sources.source(event);
if (origin == null) return;
var ongoing = OngoingDamage.read(target, event.getCause());
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(origin, target);
if (reason == null) return;
captureDamage(event, target, origin, reason);
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedDamage(EntityDamageEvent event) {
if (protectionEvents == null) return;
var audit = damageAudits.remove(event);
if (audit == null) return;
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
String reason = prior && event.isCancelled() ? "prior_cancelled"
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
: event.isCancelled() && audit.decision() != null ? audit.decision()
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
? "cancelled" : "refused";
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
event.isCancelled() ? 0 : event.getFinalDamage());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
var reason = !sources.current(ongoing.target()) ? "stale_target"
: protectionReason(ongoing.source(), target);
captureDamage(event, target, ongoing.source(), reason);
markDamageDecision(event, reason);
event.setCancelled(true); return;
}
}
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
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);
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
if (event.getEntity() instanceof Player target) {
var reason = protectionReason(origin, target);
captureDamage(event, target, origin, reason);
markDamageDecision(event, reason);
}
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
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);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_effect_application_protection", event.getSource(), target,
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
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);
if (origin != null && blocked(origin, target)) {
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
origin, protectionReason(origin, target), "combust");
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
resolveNative(event, event);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
}
private boolean blocked(CombatSources.Origin origin, Player target) {
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
if (protectionEvents == null || reason == null) return;
var recipient = target == null ? null : sources.player(target.getUniqueId());
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
parties.partyOf(origin.player()).orElse(null),
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
reason, nativeKind);
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
}
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
if (protectionEvents == null) return;
var audit = nativeAudits.remove(event);
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
}
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
var recipient = sources.player(target.getUniqueId());
var action = protectionEvents.damage(event, origin, recipient, target,
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
}
private void markDamageDecision(EntityDamageEvent event, String reason) {
var audit = damageAudits.get(event);
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
}
private String protectionReason(CombatSources.Origin origin, Player target) {
if (!sources.current(origin)) return "stale_source";
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();
if (fantasy != (recipient.session() != null)) return "mixed_mode";
if (fantasy && !origin.player().equals(recipient.player())
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
return "same_party";
return null;
}
private boolean blocked(CombatSources.Origin origin, Player target) {
return protectionReason(origin, target) != null;
}
}
@@ -9,19 +9,25 @@ import org.bukkit.entity.Player;
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
public final class PartyManagement implements AutoCloseable {
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED,ERROR}
public record Outcome(Status status,Optional<CharacterParty> party){}
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private games.dmg.fantasystick.events.PartyEventBridge events;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
public void attachEvents(games.dmg.fantasystick.events.PartyEventBridge value){if(closed||events!=null)throw new IllegalStateException("Party events already attached or management closed");events=Objects.requireNonNull(value);}
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var before=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"create",null,null,before,BigInteger.valueOf(prices.registrationPrice()),operation);var audited=wrap(audit,result);var parsed=PartyNames.parse(name);if(parsed.isEmpty()){audited.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}if(before!=null){audited.accept(new Outcome(Status.ALREADY_MEMBER,Optional.of(before)));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){audited.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,audited);}
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var operation=UUID.randomUUID();var audit=audit(player,captured,"transfer",target,null,party,BigInteger.valueOf(prices.leaderChangePrice()),operation);var audited=wrap(audit,result);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(operation,PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,audited);}
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);var audit=audit(player,captured,"invite",target,null,party,BigInteger.ZERO,null);var audited=wrap(audit,result);var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()||party==null||!party.leader().equals(captured.record().id())){audited.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(captured.record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,audited));}
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"accept",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.accept(value.record().id(),invitation),wrap(audit,result));}
public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=partyForInvitation(invitation);var audit=c.map(value->audit(player,value,"decline",value.record().id(),invitation,before,BigInteger.ZERO,null)).orElse(null);mutate(player,registrarId,value->parties.decline(value.record().id(),invitation),wrap(audit,result));}
public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var invited=before==null?null:before.invitations().stream().filter(value->value.id().equals(invitation)).map(CharacterParty.Invitation::character).findFirst().orElse(null);var audit=c.map(value->audit(player,value,"cancel",invited,invitation,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.cancel(value.record().id(),p.id(),invitation),wrap(audit,result));}
public void leave(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);boolean leaderLeave=before!=null&&c.isPresent()&&before.leader().equals(c.orElseThrow().record().id());String kind=leaderLeave?"leader_leave":"leave";var audit=c.map(value->audit(player,value,kind,value.record().id(),null,before,BigInteger.ZERO,null,leaderLeave?"leader_leave":"requested")).orElse(null);mutateParty(player,registrarId,(value,p)->parties.leave(value.record().id(),p.id()),wrap(audit,result));}
public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"kick",target,null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.kick(value.record().id(),p.id(),target),wrap(audit,result));}
public void disband(Player player,UUID registrarId,Consumer<Outcome> result){var c=context(player,registrarId);var before=c.flatMap(value->parties.partyOf(value.record().id())).orElse(null);var audit=c.map(value->audit(player,value,"disband",value.record().id(),null,before,BigInteger.ZERO,null)).orElse(null);mutateParty(player,registrarId,(value,p)->parties.disband(value.record().id(),p.id()),wrap(audit,result));}
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
@@ -29,7 +35,12 @@ public final class PartyManagement implements AutoCloseable {
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation,String reason){return events==null?null:events.begin(player,context.active(),context.record().name(),context.registrar(),kind,target,targetContext(player,context,target),invitation,before,charge,operation,reason);}
private games.dmg.fantasystick.events.EventInput.Context targetContext(Player actor,Context context,UUID target){if(target==null||events==null)return null;if(target.equals(context.record().id()))return events.context(actor,context.active(),context.record().name());var active=sessions.activeCharacter(target).orElse(null);if(active==null)return null;var player=sessions.currentPlayer(active).orElse(null);var record=sessions.currentRecord(active).orElse(null);return player==null||record==null?null:events.context(player,active,record.name());}
private Consumer<Outcome> wrap(games.dmg.fantasystick.events.PartyEventBridge.Action audit,Consumer<Outcome> target){return outcome->{if(audit!=null){if(outcome.status()==Status.ERROR)events.failed(audit,"mutation_failed_or_uncertain");else events.resolved(audit,outcome);}target.accept(outcome);};}
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.ERROR,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
@Override public void close(){closed=true;operations.close();}
@@ -27,6 +27,7 @@ public final class PartyRegistrarController implements Listener, AutoCloseable {
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
public java.util.Optional<UUID> loadedEntity(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))return java.util.Optional.ofNullable(entity.getUniqueId());}return java.util.Optional.empty();}
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
@@ -20,6 +20,7 @@ public final class PartyRoster implements AutoCloseable {
public int maximumSize(){int value=maximumSize.getAsInt();if(value<1)throw new IllegalStateException("Party maximum must be positive");return value;}
public Optional<Party> create(UUID player){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}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){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
public Optional<Party> partyOfCharacter(UUID character){if(durable!=null)return durable.ready()?durable.partyOf(character).flatMap(this::project):Optional.empty();return Optional.ofNullable(membership.get(character));}
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!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);}
@@ -20,21 +20,31 @@ public final class AdventureQuestSettings implements AutoCloseable {
CAMP_ENEMIES="adventure-camp-enemies",
CAMP_REWARD_XP="adventure-camp-reward-xp", CAMP_REWARD_COINS="adventure-camp-reward-coins",
CHAMPION_HEALTH="adventure-champion-health", CHAMPION_DAMAGE="adventure-champion-damage",
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins";
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins",
FISHING_CATCH_XP_MIN="fishing-catch-xp-min", FISHING_CATCH_XP_MAX="fishing-catch-xp-max";
private static final Map<String,String> DEFAULTS=Map.ofEntries(
Map.entry(DAILY_COUNT,"3"),Map.entry(RADIUS,"16"),Map.entry(RESET_SECONDS,"30"),
Map.entry(DUNGEON_ROOM_LINK_DISTANCE,"10"),Map.entry(CAMP_ENEMIES,"8"),
Map.entry(CAMP_REWARD_XP,"100"),Map.entry(CAMP_REWARD_COINS,"20"),Map.entry(CHAMPION_HEALTH,"100"),
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"));
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"),
Map.entry(FISHING_CATCH_XP_MIN,"1"),Map.entry(FISHING_CATCH_XP_MAX,"3"));
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
public record Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance,
BigInteger fishingCatchXpMin,BigInteger fishingCatchXpMax) {
public Rules {Objects.requireNonNull(fishingCatchXpMin);Objects.requireNonNull(fishingCatchXpMax);if(fishingCatchXpMin.signum()<0||fishingCatchXpMax.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0||fishingCatchXpMin.compareTo(fishingCatchXpMax)>0)throw new IllegalArgumentException("Invalid fishing catch XP range");}
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
BigInteger championRewardXp,BigInteger championRewardCoins) {
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
championDamage,championRewardXp,championRewardCoins,10);
championDamage,championRewardXp,championRewardCoins,10,BigInteger.ONE,BigInteger.valueOf(3));
}
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
championDamage,championRewardXp,championRewardCoins,dungeonRoomLinkDistance,BigInteger.ONE,BigInteger.valueOf(3));
}
}
public record Update(String key,String value,Map<String,String> values,long sequence) {
@@ -64,7 +74,8 @@ public final class AdventureQuestSettings implements AutoCloseable {
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)),
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256));}
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256),whole(values.getOrDefault(FISHING_CATCH_XP_MIN,DEFAULTS.get(FISHING_CATCH_XP_MIN))),
whole(values.getOrDefault(FISHING_CATCH_XP_MAX,DEFAULTS.get(FISHING_CATCH_XP_MAX))));}
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
}
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-configuration-persistence"));
private volatile boolean enabled;
private volatile String revision;
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
public record Saved(boolean beforeValue, boolean value, String revision) { }
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
public boolean enabled() { return enabled; }
public synchronized CompletionStage<Boolean> set(boolean value) {
public CompletionStage<Boolean> set(boolean value) {
return setRecorded(value).thenApply(Saved::value);
}
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
public CompletionStage<Saved> setRecorded(boolean value) {
return setRecorded(value, ignored -> { });
}
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
Objects.requireNonNull(onSaved);
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) {
try {
boolean before = enabled;
var revision = save(value);
enabled = value;
this.revision = revision;
var saved = new Saved(before, value, revision);
try { onSaved.accept(saved); }
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
return saved;
} 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"));
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
}
}
private void save(boolean value) throws IOException {
private String 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");
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
String revision;
try {
revision = "sha256:" + java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (java.security.NoSuchAlgorithmException missing) {
throw new IllegalStateException("SHA-256 unavailable", missing);
}
replacement.move(temporary, file);
return revision;
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public ChainLightning.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -19,7 +19,9 @@ public record CharacterRecord(
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt,
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,
CharacterGear gear, BigInteger sheetGeneration) {
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,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow,CharacterGear gear){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,gear,BigInteger.ZERO);}
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,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
@@ -55,6 +57,16 @@ public record CharacterRecord(
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
}
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, List<String> passives, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt, BigInteger sheetGeneration) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of(), BigInteger.ZERO,
CharacterGear.none(), sheetGeneration);
}
public CharacterRecord {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
@@ -65,6 +77,7 @@ public record CharacterRecord(
Objects.requireNonNull(coins);
Objects.requireNonNull(pvpCoinEscrow);
Objects.requireNonNull(gear);
Objects.requireNonNull(sheetGeneration);
stats = Map.copyOf(stats);
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
@@ -85,7 +98,8 @@ public record CharacterRecord(
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || sheetGeneration.signum() < 0
|| !coinsInitialized && coins.signum() != 0
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
@@ -0,0 +1,167 @@
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.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/** Durable fail-closed authority for one explicitly confirmed saved-sheet recovery. */
final class CharacterRecoveryStore {
private static final int MAX_FILE_BYTES = 16_384;
enum Phase { PREPARED, COMMITTED, DELIVERED }
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
Operation {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
}
Operation phase(Phase value) {
return new Operation(operation, character, administrator, recipient, expectedRevision,
expectedGeneration, replacementGeneration, revive, death, value);
}
}
private final Path directory;
CharacterRecoveryStore(Path pluginDataDirectory) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("character-recoveries").toAbsolutePath();
}
Optional<Operation> find(UUID character) throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
safeDirectory();
Path path = file(character);
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Recovery record is unsafe");
return Optional.of(decode(Files.readString(path, StandardCharsets.UTF_8)));
}
/** Mutation predicate: corrupt or unreadable proof is unresolved and therefore blocks. */
boolean blocks(UUID character) {
try { return find(character).map(operation -> operation.phase() != Phase.DELIVERED).orElse(false); }
catch (IOException unsafe) { return true; }
}
void prepare(Operation operation) throws IOException {
withLock(() -> {
var existing = find(operation.character());
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
throw new IOException("Character recovery is unresolved");
write(operation);
});
}
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
withLock(() -> {
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!current.operation().equals(operation) || current.phase() != expected)
throw new IOException("Recovery operation changed");
write(current.phase(replacement));
});
}
void cancelPrepared(UUID character, UUID operation) throws IOException {
withLock(() -> {
var current = find(character);
if (current.isEmpty()) return;
if (!current.orElseThrow().operation().equals(operation)
|| current.orElseThrow().phase() != Phase.PREPARED)
throw new IOException("Recovery operation cannot be cancelled");
Files.delete(file(character));
DurableFiles.forceDirectory(directory);
});
}
private void write(Operation operation) throws IOException {
safeDirectory();
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, file(operation.character()), StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
} finally { Files.deleteIfExists(temporary); }
}
private void withLock(IoAction action) throws IOException {
safeDirectory();
Path lockPath = directory.resolve(".recoveries.lock");
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Recovery store busy");
action.run();
} catch (OverlappingFileLockException failure) { throw new IOException("Recovery store busy", failure); }
}
private void safeDirectory() throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
throw new IOException("Recovery directory is unsafe");
} else Files.createDirectories(directory);
}
private Path file(UUID character) { return directory.resolve(Objects.requireNonNull(character) + ".yml"); }
private static String encode(Operation value) {
return "schema-version: 1\n"
+ "operation: " + value.operation() + "\n"
+ "character: " + value.character() + "\n"
+ "administrator: " + value.administrator() + "\n"
+ "recipient: " + value.recipient() + "\n"
+ "expected-revision: " + value.expectedRevision() + "\n"
+ "expected-generation: " + value.expectedGeneration() + "\n"
+ "replacement-generation: " + value.replacementGeneration() + "\n"
+ "revive: " + value.revive() + "\n"
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
+ "phase: " + value.phase() + "\n";
}
private static Operation decode(String text) throws IOException {
var values = new LinkedHashMap<String, String>();
for (String line : text.split("\\n", -1)) {
if (line.isEmpty()) continue;
int split = line.indexOf(": ");
if (split < 1 || values.putIfAbsent(line.substring(0, split), line.substring(split + 2)) != null)
throw new IOException("Invalid recovery record");
}
try {
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
"administrator", "recipient", "expected-revision", "expected-generation",
"replacement-generation", "revive", "death", "phase"))
|| !values.get("schema-version").equals("1")) throw new IllegalArgumentException();
boolean revive = switch (values.get("revive")) { case "true" -> true; case "false" -> false;
default -> throw new IllegalArgumentException(); };
String death = values.get("death");
return new Operation(UUID.fromString(values.get("operation")), UUID.fromString(values.get("character")),
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
new BigInteger(values.get("replacement-generation")), revive,
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
}
@FunctionalInterface private interface IoAction { void run() throws IOException; }
}
@@ -26,6 +26,8 @@ public final class CharacterStorage implements AutoCloseable {
private final Clock clock;
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
@@ -41,9 +43,12 @@ public final class CharacterStorage implements AutoCloseable {
this.clock = Objects.requireNonNull(clock);
this.coinRules = Objects.requireNonNull(coinRules);
this.coinRandom = Objects.requireNonNull(coinRandom);
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
repository.attachMutationUnavailable(recoveries::blocks);
}
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge value){if(worker.isShutdown()||coinEvents!=null)throw new IllegalStateException("Coin events already attached or storage closed");coinEvents=Objects.requireNonNull(value);}
public CompletableFuture<Void> create(CharacterRecord character) {
return submit(() -> {
@@ -57,7 +62,8 @@ public final class CharacterStorage implements AutoCloseable {
* starting points and retains the returned handle through success, cancellation and disable.
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
*/
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
public CraftPreparation prepareCraft(List<CharacterRecord> records) {return prepareCraft(records,"craft");}
public CraftPreparation prepareCraft(List<CharacterRecord> records,String source) {
var snapshot = List.copyOf(records);
var ids = new HashSet<UUID>();
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
@@ -72,17 +78,19 @@ public final class CharacterStorage implements AutoCloseable {
throw new IllegalArgumentException("Craft batch requires distinct new characters");
}
}
return new CraftPreparation(snapshot);
return new CraftPreparation(snapshot,Objects.requireNonNull(source),coinRules.get());
}
public final class CraftPreparation {
private final List<CharacterRecord> requested;
private final CompletableFuture<CraftBatch> completion;
private final String source;
private final games.dmg.fantasystick.progression.CoinRules configuredRules;
private volatile boolean abandoned;
private CompletableFuture<CraftCleanup> cleanup;
private CraftPreparation(List<CharacterRecord> records) {
requested = records;
private CraftPreparation(List<CharacterRecord> records,String source,games.dmg.fantasystick.progression.CoinRules configuredRules) {
requested = records;this.source=source;this.configuredRules=Objects.requireNonNull(configuredRules);
completion = writeCraftBatch(records, this);
}
@@ -170,6 +178,7 @@ public final class CharacterStorage implements AutoCloseable {
if (resolved || owner.abandoned) return Optional.empty();
resolved = true;
if (worker.isShutdown()) return Optional.empty();
var bridge=coinEvents;if(bridge!=null)for(var record:records)bridge.initialized(record,owner.source,owner.configuredRules);
return Optional.of(records);
}
@@ -205,37 +214,185 @@ public final class CharacterStorage implements AutoCloseable {
return new CraftCleanup(removed, retained);
}
public record Catalog(List<CharacterRecord> records, int unreadable) {
public Catalog { records = List.copyOf(records); }
public boolean complete() { return unreadable == 0; }
}
public CompletableFuture<Catalog> catalog() {
return submit(() -> {
var catalog = repository.catalog();
return new Catalog(catalog.records(), catalog.unreadable());
});
}
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
return submit(() -> repository.load(Objects.requireNonNull(id)));
}
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
return submit(() -> load(id));
}
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
public RecoveryRequest {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(nativeLimit);
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| nativeLimit.signum() <= 0 || revive != (death != null))
throw new IllegalArgumentException("Invalid character recovery request");
}
}
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
Objects.requireNonNull(request);
return submit(() -> {
var found = repository.load(request.character());
if (found.isEmpty()) return new RecoveryCommit(RecoveryStatus.UNAVAILABLE, Optional.empty(), request.operation());
var old = found.orElseThrow();
reconcileRecovery(old);
if (!old.revision().equals(request.expectedRevision())
|| !old.sheetGeneration().equals(request.expectedGeneration()))
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
if (request.revive()) {
if (!old.dead() || !Objects.equals(old.deathId(), request.death()))
return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
} else if (old.dead()) return new RecoveryCommit(RecoveryStatus.STALE, Optional.of(old), request.operation());
var existing = recoveries.find(old.id());
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
recoveries.prepare(operation);
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
var replacement = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
old.gear(), nextGeneration);
try { repository.replaceForRecovery(replacement, old.revision()); }
catch (IOException failure) {
try { recoveries.cancelPrepared(old.id(), request.operation()); }
catch (IOException retained) { failure.addSuppressed(retained); }
throw failure;
}
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
CharacterRecoveryStore.Phase.COMMITTED);
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
});
}
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
Objects.requireNonNull(character); Objects.requireNonNull(operation);
return submit(() -> {
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
var proof = recoveries.find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|| !replacementMatches(record, proof))
throw new IOException("Recovery completion proof conflicts with saved character");
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
CharacterRecoveryStore.Phase.DELIVERED);
return true;
});
}
/** Persist a legacy character's one-time configured migration roll before exposing it. */
private Optional<CharacterRecord> load(UUID id) throws IOException {
var found = repository.load(id);
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
if (found.isEmpty()) {
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
throw new IOException("Recovery proof references a missing character");
return found;
}
reconcileRecovery(found.orElseThrow());
if (found.orElseThrow().coinsInitialized()) return found;
var old = found.orElseThrow();
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
var configuredRules=coinRules.get();var migrated = 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(), old.passives(),
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
repository.replace(migrated, old.revision());
configuredRules.starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(migrated, old.revision());var bridge=coinEvents;if(bridge!=null)bridge.initialized(migrated,"legacy_migration",configuredRules);
return Optional.of(migrated);
}
private void reconcileRecovery(CharacterRecord record) throws IOException {
var found = recoveries.find(record.id());
if (found.isEmpty()) return;
var operation = found.orElseThrow();
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
if (expectedMatches(record, operation)) {
recoveries.cancelPrepared(record.id(), operation.operation());
return;
}
if (replacementMatches(record, operation)) {
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED,
CharacterRecoveryStore.Phase.COMMITTED);
throw new IOException("Recovery committed before acknowledgment; custody is unresolved");
}
throw new IOException("Prepared recovery proof conflicts with saved character");
}
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
if (!replacementMatches(record, operation))
throw new IOException("Committed recovery proof conflicts with saved character");
throw new IOException("Character has unresolved committed recovery custody");
}
if (!record.sheetGeneration().equals(operation.replacementGeneration())
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0)
throw new IOException("Delivered recovery proof conflicts with saved character");
}
private static boolean expectedMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
return record.revision().equals(operation.expectedRevision())
&& record.sheetGeneration().equals(operation.expectedGeneration())
&& (operation.revive() ? record.dead() && Objects.equals(record.deathId(), operation.death()) : !record.dead());
}
private static boolean replacementMatches(CharacterRecord record, CharacterRecoveryStore.Operation operation) {
return record.revision().equals(operation.expectedRevision().add(BigInteger.ONE))
&& record.sheetGeneration().equals(operation.replacementGeneration())
&& !record.dead() && (!operation.revive() || Objects.equals(record.deathId(), operation.death()));
}
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
return renameCharacter(characterId, name, null);
}
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
return renameCharacterObserved(characterId, name, expectedSheetGeneration).thenApply(RenameOutcome::record);
}
/** Capture the authoritative before/after pair in the same serialized save, not from stale item text. */
public record RenameOutcome(Optional<CharacterRecord> record, Optional<CharacterRecord> before, String status) { }
public CompletableFuture<RenameOutcome> renameCharacterObserved(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(name);
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
if (found.isEmpty() || found.orElseThrow().dead())
return new RenameOutcome(Optional.empty(), Optional.empty(), "unavailable");
var old = found.orElseThrow();
if (old.name().equals(name)) return Optional.of(old);
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
return new RenameOutcome(Optional.empty(), Optional.of(old), "stale_generation");
if (old.name().equals(name)) return new RenameOutcome(Optional.of(old), Optional.of(old), "unchanged");
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.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
return new RenameOutcome(Optional.of(renamed), Optional.of(old), "renamed");
});
}
@@ -273,7 +430,7 @@ public final class CharacterStorage implements AutoCloseable {
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.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
@@ -309,7 +466,7 @@ public final class CharacterStorage implements AutoCloseable {
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(),
old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
});
@@ -318,12 +475,27 @@ public final class CharacterStorage implements AutoCloseable {
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);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(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);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(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);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(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target),old.sheetGeneration());repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
public enum RarePurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,STALE,UNAVAILABLE }
public record RarePurchase(RarePurchaseStatus status,Optional<CharacterRecord> record){}
/** One character-file transaction owns a rare purchase's exact idempotent coin debit. */
public CompletableFuture<RarePurchase> purchaseRare(UUID characterId,BigInteger expectedRevision,UUID operationId,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(price);if(price.signum()<=0)throw new IllegalArgumentException("Invalid rare purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new RarePurchase(RarePurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new RarePurchase(RarePurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new RarePurchase(RarePurchaseStatus.STALE,Optional.of(old));if(old.coins().compareTo(price)<0)return new RarePurchase(RarePurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);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(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new RarePurchase(RarePurchaseStatus.APPLIED,Optional.of(saved));});}
public enum MagicCoreStatus { APPLIED,DUPLICATE,STALE,UNAVAILABLE }
public record MagicCore(MagicCoreStatus status,Optional<CharacterRecord> record){}
/** Credit one ordinary arbitrary-precision point with stable consume proof. */
public CompletableFuture<MagicCore> consumeMagicCore(UUID characterId,BigInteger expectedRevision,UUID operationId){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new MagicCore(MagicCoreStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new MagicCore(MagicCoreStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new MagicCore(MagicCoreStatus.STALE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints().add(BigInteger.ONE),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new MagicCore(MagicCoreStatus.APPLIED,Optional.of(saved));});}
public enum ReskillStatus { APPLIED,DUPLICATE,NOTHING_TO_RESET,STALE,UNAVAILABLE }
public record Reskill(ReskillStatus status,Optional<CharacterRecord> record){}
/** Reset every one-point character investment and preserve identity/progress/deadlines in one revision. */
public CompletableFuture<Reskill> reskill(UUID characterId,BigInteger expectedRevision,UUID operationId,java.math.BigDecimal nativeHealthLimit){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(nativeHealthLimit);if(nativeHealthLimit.signum()<=0)throw new IllegalArgumentException("Invalid native health limit");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new Reskill(ReskillStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new Reskill(ReskillStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new Reskill(ReskillStatus.STALE,Optional.of(old));BigInteger refund=old.stats().values().stream().reduce(BigInteger.ZERO,BigInteger::add).add(BigInteger.valueOf(old.learned().size())).add(BigInteger.valueOf(old.passives().stream().filter(id->!id.isEmpty()).distinct().count()));if(refund.signum()==0)return new Reskill(ReskillStatus.NOTHING_TO_RESET,Optional.of(old));var stats=new java.util.EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var health=old.health().min(java.math.BigDecimal.TEN.min(nativeHealthLimit));var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),stats,old.level(),old.experience(),old.unspentPoints().add(refund),health,Set.of(),List.of("","","","","","",""),List.of("","",""),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins(),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());repository.replace(saved,old.revision());return new Reskill(ReskillStatus.APPLIED,Optional.of(saved));});}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
@@ -378,7 +550,7 @@ public final class CharacterStorage implements AutoCloseable {
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.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
old.coins().add(eligible ? coinReward : BigInteger.ZERO), true, receipts,old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
@@ -418,7 +590,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
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(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -446,7 +618,7 @@ public final class CharacterStorage implements AutoCloseable {
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), old.learned(), old.loadout(), passives,
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
});
@@ -462,7 +634,7 @@ public final class CharacterStorage implements AutoCloseable {
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)) {
if (ability.isBlank() || !cost.equals(BigInteger.ONE) || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
throw new IllegalArgumentException("Invalid ability unlock");
}
return submit(() -> {
@@ -481,7 +653,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, 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, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -501,7 +673,7 @@ public final class CharacterStorage implements AutoCloseable {
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -524,7 +696,7 @@ public final class CharacterStorage implements AutoCloseable {
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.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -548,7 +720,7 @@ public final class CharacterStorage implements AutoCloseable {
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(), old.passives(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
@@ -593,7 +765,7 @@ public final class CharacterStorage implements AutoCloseable {
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.passives(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
@@ -619,7 +791,7 @@ public final class CharacterStorage implements AutoCloseable {
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.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -643,6 +815,9 @@ public final class CharacterStorage implements AutoCloseable {
return death(characterId, null, loseCoins);
}
/** Finalize one claimed downed outcome with its stable death identity and original resolution time. */
public CompletableFuture<Optional<CharacterRecord>> recordFinalDeath(UUID characterId,BigInteger expectedRevision,UUID deathId,Instant diedAt,boolean loseCoins){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(deathId);Objects.requireNonNull(diedAt);return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return Optional.empty();var old=found.orElseThrow();if(old.dead())return old.deathId().equals(deathId)&&old.diedAt().equals(diedAt)?Optional.of(old):Optional.empty();if(!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.passives(),old.cooldowns(),old.restrictedKills(),true,deathId,diedAt,loseCoins?BigInteger.ZERO:old.coins(),old.coinsInitialized(),old.rewardReceipts(),loseCoins?old.coins():BigInteger.ZERO,old.gear(),old.sheetGeneration());repository.replace(dead,old.revision());var bridge=coinEvents;if(bridge!=null&&loseCoins&&old.coins().signum()>0)bridge.lost(old,dead,deathId,diedAt);return Optional.of(dead);});}
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
Objects.requireNonNull(characterId);
Instant diedAt = clock.instant();
@@ -656,7 +831,7 @@ public final class CharacterStorage implements AutoCloseable {
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
loseCoins ? old.coins() : BigInteger.ZERO,old.gear(),old.sheetGeneration());
repository.replace(dead, old.revision());
return Optional.of(dead);
});
@@ -693,7 +868,7 @@ public final class CharacterStorage implements AutoCloseable {
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear(),old.sheetGeneration());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -716,6 +891,19 @@ public final class CharacterStorage implements AutoCloseable {
}
}
/** Observe accepted save callbacks without waiting for file I/O on the native thread. */
public java.util.concurrent.CompletionStage<Void> drained() {
return java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS))
throw new java.util.concurrent.CompletionException(new java.io.IOException("Character storage did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new java.util.concurrent.CompletionException(interrupted);
}
});
}
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
@Override
public void close() {
@@ -33,6 +33,7 @@ public final class CharacterYaml {
root.put("character-id", record.id().toString());
root.put("name", record.name());
root.put("revision", record.revision().toString());
root.put("sheet-generation", record.sheetGeneration().toString());
Map<String, String> stats = new LinkedHashMap<>();
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
root.put("stats", stats);
@@ -108,7 +109,8 @@ public final class CharacterYaml {
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none(),
root.containsKey("sheet-generation") ? number(root.get("sheet-generation")) : BigInteger.ZERO);
}
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
@@ -0,0 +1,28 @@
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.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Save-before-publication downed countdown and Revive protection policy. */
public final class DownedSettings implements AutoCloseable{
public static final String TIMEOUT="downed-timeout-seconds",PROTECTION="revive-downed-invulnerability-seconds";private static final Map<String,String> DEFAULTS=Map.of(TIMEOUT,"300",PROTECTION,"5");
public record Update(String key,String value,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
private final Path directory;private final ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-downed-configuration"));private volatile Map<String,String> effective;private Map<String,String> persisted;private long saved,published;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public DownedSettings(Path directory,Map<String,String> initial){this.directory=directory.toAbsolutePath();var values=new LinkedHashMap<String,String>();for(String key:keys())values.put(key,initial.getOrDefault(key,DEFAULTS.get(key)));validate(values);effective=Map.copyOf(values);persisted=effective;}
public static List<String> keys(){return List.of(TIMEOUT,PROTECTION);}public static String defaultValue(String key){return DEFAULTS.get(key);}public String value(String key){return effective.get(key);}public Duration timeout(){return Duration.ofSeconds(Long.parseLong(effective.get(TIMEOUT)));}public Duration protection(){return Duration.ofSeconds(Long.parseLong(effective.get(PROTECTION)));}
public CompletionStage<Update> set(String key,String raw){if(!DEFAULTS.containsKey(key))throw new IllegalArgumentException("Unknown downed setting");String value=Long.toString(seconds(raw,key.equals(TIMEOUT)));var preview=new LinkedHashMap<>(effective);preview.put(key,value);validate(preview);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,value);save(key,value);persisted=Map.copyOf(next);return new Update(key,value,persisted,++saved);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
public synchronized boolean publish(Update update){if(update.sequence()<=published)return false;validate(update.values());effective=update.values();published=update.sequence();return true;}
private static void validate(Map<String,String> values){seconds(values.get(TIMEOUT),true);seconds(values.get(PROTECTION),false);}private static long seconds(String raw,boolean positive){var value=new java.math.BigDecimal(Objects.requireNonNull(raw));long seconds=value.longValueExact();if(seconds<(positive?1:0)||seconds>Duration.ofDays(365).toSeconds())throw new IllegalArgumentException("Invalid duration");return seconds;}
private void save(String key,String value)throws IOException{Files.createDirectories(directory);Path file=directory.resolve("config.yml"),lockFile=directory.resolve(".config.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");Map<String,Object> document=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS)||Files.isSymbolicLink(file)||Files.size(file)>1_048_576)throw new IOException("Unsafe configuration");Object loaded=yaml().load(Files.readString(file,StandardCharsets.UTF_8));if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String name))throw new IOException("Invalid key");document.put(name,entry.getValue());}}document.put(key,Long.parseLong(value));Path temporary=Files.createTempFile(directory,".config-downed-",".tmp");try{byte[] bytes=yaml().dump(document).getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);try(var parent=FileChannel.open(directory,StandardOpenOption.READ)){parent.force(true);}}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}}
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(1_048_576);return new Yaml(new SafeConstructor(options));}private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -146,6 +146,9 @@ public final class EquipmentSessionStore implements AutoCloseable {
}));
}
/** Reopen only the exact completed session retained by durable downed custody. */
public CompletionStage<State> resume(UUID player,UUID session,UUID character,String previousGameMode,OrdinaryVitals ordinaryVitals){Objects.requireNonNull(player);Objects.requireNonNull(session);Objects.requireNonNull(character);var receipt=new Receipt(session,character,clock.instant(),previousGameMode,ordinaryVitals);return submit(()->withLock(()->{var old=load(player).orElseThrow(()->new IOException("Completed equipment session not found"));if(old.pending()!=null||old.lastExit()==null||!session.equals(old.lastExit().sessionId())||!character.equals(old.lastExit().characterId()))throw new IOException("Completed equipment session does not match downed custody");return write(new State(player,old.revision().add(BigInteger.ONE),receipt,old.lastExit()));}));}
/** Capture the actual exit request time, never queue completion time. The receipt remains pending. */
public CompletionStage<State> recordExit(UUID player, UUID session, Duration delay) {
Objects.requireNonNull(player);
@@ -71,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public FrostNova.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -76,11 +76,12 @@ public final class FrostShardSettings implements AutoCloseable {
public FrostShard.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -0,0 +1,159 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.GreatSword;
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.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
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;
/** Serialized Great Sword configuration; the caller publishes acknowledged changes on the server thread. */
public final class GreatSwordSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "great-sword-base-damage";
public static final String RECOVERY = "great-sword-recovery-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "7", RECOVERY, "0.75");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, GreatSword.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-great-sword-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public GreatSwordSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
GreatSwordSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public GreatSword.Settings settings() { return parse(effectiveValues); }
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Great Sword configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static GreatSword.Settings parse(Map<String, String> values) {
try {
var seconds = new java.math.BigDecimal(values.get(RECOVERY));
Duration recovery = Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new GreatSword.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), recovery);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported numeric values", invalid);
}
}
private void save(String key, String 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> document = 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 savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).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(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -70,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public GuardianBond.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -72,11 +72,12 @@ public final class HexSettings implements AutoCloseable {
public Hex.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -72,11 +72,12 @@ public final class InspireSettings implements AutoCloseable {
public Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more