Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fcb1e7e9c | ||
|
|
5d574dc6e9 | ||
|
|
a71262f115 | ||
|
|
8119673c9f | ||
|
|
cc5300bb03 | ||
|
|
293d28ca29 | ||
|
|
c530576262 | ||
|
|
e850e33975 | ||
|
|
20abd2fa6e | ||
|
|
e054a78762 | ||
|
|
63da22e37d | ||
|
|
b2e8da536a | ||
|
|
c77246321c | ||
|
|
438d6acfa4 | ||
|
|
e74f51cc58 | ||
|
|
1361ba3fb2 | ||
|
|
27f38818ae | ||
|
|
624deb938d | ||
|
|
232b2a190d | ||
|
|
74e717c6ca | ||
|
|
644b343fc9 | ||
|
|
ba69a55124 | ||
|
|
db35c28f0b | ||
|
|
636ff9f076 | ||
|
|
7158817726 | ||
|
|
8ee0d98599 | ||
|
|
539900eb14 | ||
|
|
9828590009 | ||
|
|
c85b5e91cc | ||
|
|
bda7419b8b | ||
|
|
6ac4c4bfde | ||
|
|
6f42082383 | ||
|
|
9ddc139544 | ||
|
|
58d5ea82f1 | ||
|
|
40e41d6b7a | ||
|
|
b493cfe192 | ||
|
|
3d045ff99b | ||
|
|
f7d01381ec | ||
|
|
db87505142 | ||
|
|
9771d1e7c3 | ||
|
|
259cd8cf29 | ||
|
|
1e7cdf888a | ||
|
|
e7912d03ce | ||
|
|
fcf93fa966 | ||
|
|
14fc0dbde0 | ||
|
|
d10e804820 | ||
|
|
7cf91baca4 | ||
|
|
3215697e3f | ||
|
|
f4cb7a3914 | ||
|
|
6aa7ea469b |
No files matched your search
@@ -2,6 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
tags-ignore: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -96,9 +96,11 @@ jobs:
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
with:
|
||||
name: purpur-fantasy-stick-${{ steps.release.outputs.version }}
|
||||
path: build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
|
||||
path: |
|
||||
build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
|
||||
build/distributions/purpur-fantasy-stick-${{ steps.release.outputs.version }}-resource-pack.zip
|
||||
if-no-files-found: error
|
||||
- name: Publish verified JAR through a draft release
|
||||
- name: Publish verified JAR and resource pack through a draft release
|
||||
if: steps.release.outputs.created == 'true'
|
||||
env:
|
||||
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Fantasy Stick
|
||||
|
||||
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
|
||||
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), native session health/defeat (US-006), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. None is yet wired into playable crafting, health, session or spell interactions.
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. Crafting, native-health offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; active food overrides, stat/ability book actions, combat progression and spells remain incomplete. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. Saved-sheet crafting and accepted anvil-name persistence are wired. Stat purchases and spell execution remain incomplete.
|
||||
|
||||
## Build and verify
|
||||
|
||||
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
|
||||
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
|
||||
- Node.js 20 or later and npm for the TypeScript release-tool tests.
|
||||
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled. Install the official [server distribution](https://github.com/dmulloy2/ProtocolLib/releases/tag/5.4.0), not the Maven library JAR (which lacks bundled runtime dependencies).
|
||||
|
||||
```sh
|
||||
./gradlew clean check jar
|
||||
@@ -17,7 +18,7 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime and loads vanilla registries/components **without starting a listening server or creating a gameplay world**. The item tests decode signed written books with Minecraft's real codec, without a plugin instance or YAML lookup. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Bootstrap registers no commands, permissions, recipes, sessions or gameplay listeners.
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
|
||||
|
||||
## Character storage and keepsakes
|
||||
|
||||
@@ -27,15 +28,93 @@ The file adapter uses safe YAML construction, duplicate-key rejection, bounded r
|
||||
|
||||
`CharacterStorage` queues disk operations on its persistence worker and acknowledges successful writes only after commit. Its backend restoration checks the saved dead flag, matching current death ID and death age strictly under 24 hours at the request/selection time. It preserves progress/cooldowns and restores full character health; old books do not become eligible after a later death. This method is **not** a complete Revive spell: the engine must still validate the caster and native dialog, spend its cooldown, recheck item possession and perform physical conversion safely.
|
||||
|
||||
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The factory does not yet deliver books from a death event.
|
||||
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The equipment coordinator now intercepts active-session native death, restores gear and normal health, attempts the entry-location return, and saves authoritative death before guarded one-book conversion. Stale/offline/uncertain delivery is reported without reissue; this is not the complete Revive spell. Native health/session integration is described below.
|
||||
|
||||
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or player/session persistence.
|
||||
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. Player/session receipts and proof-based equipment reconciliation are implemented below, not an administrator lock, automatic overflow drops or guaranteed cross-save recovery.
|
||||
|
||||
## Craft preparation and conservative cleanup
|
||||
|
||||
`CharacterStorage.prepareCraft` accepts an immutable batch of distinct fresh character records and returns a process-local `CraftPreparation` handle. Allocation/configured starting points remain the caller's responsibility. Retain this handle until delivery or abandonment: `ready()` returns an observation copy, and cancelling/timing out that future does **not** cancel the owned operation. Use `abandon()` to invalidate the attempt, including while writes are queued/in progress. Writes acknowledge the complete prepared batch; `claimForDelivery()` exposes it once, immediately before native delivery. Claiming permanently rules out cleanup, even if later delivery throws.
|
||||
|
||||
Abandonment skips remaining queued writes and schedules cleanup for acknowledged, never-issued creations. Cleanup checks the original file identity/creation and modification attributes and exact bytes under the repository lock. Changed, replaced, corrupt, symlinked, busy or unidentifiable files are retained. Partial-save failure cleans only acknowledged creations and reports uncertain targets without deleting them. Shutdown rejection preserves records rather than inventing successful cleanup. Repeated cleanup cannot delete a later file. Restart never reconstructs cleanup authority from revision zero or scans records for automatic deletion/reissue.
|
||||
|
||||
Thirteen craft-persistence tests cover these backend boundaries, including worker timing, partial/post-rename failures, ownership changes, restart, shutdown, claim/abandon races, and mutated/cancelled observer futures. This backend alone is **not native inventory delivery**: the assembled controller separately contains vanilla consumption, validates current view/lifecycle/content and commits actual items. Filesystem locks coordinate cooperating writers; concurrent external filesystem mutation must be excluded/coordinated. File flush/atomic rename does not establish directory power-loss durability or Minecraft/plugin-save atomicity. All create/update/cleanup mutations now share one fail-fast `characters/.records.lock` coordination file, so abandoning new characters does not accumulate one lock file per ID. Read-only atomic-file loads remain unlocked. The file stays present but its OS lock is held only for an operation, not an administrator-unlock flag. Never unlink it while writers may use it. Old per-ID lock files are left intact; no new ones are created.
|
||||
|
||||
**Upgrade boundary:** stop all older per-ID-protocol writers before opening the same data directory with this shared-lock implementation. Mixing versions or hot-reloading while old worker writes remain queued is unsafe. YAML format and record filenames are unchanged; no deployment or existing-server data migration has been performed. Contention between cooperating repository instances fails without mutation and can be retried, rather than blocking a server tick or deleting coordination files.
|
||||
|
||||
## Paper sheet and recipe definition
|
||||
|
||||
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
|
||||
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and three separate lore lines: `Character sheet:`, `Place in off hand`, `to enter fantasy world.` Recipe previews and acknowledged crafted sheets use the same layout; existing items are not bulk-rewritten. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
|
||||
|
||||
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Its preview has no character metadata and allocates no identity/name. It is deliberately **not registered** yet: actual completion, bulk crafting, cancellation/save handling and anvil rename persistence still need their event adapters. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The assembled craft-event controller now invokes this allocator only for accepted current attempts.
|
||||
|
||||
The `fantasystick:character_sheet` shapeless recipe definition contains one stick, paper, feather and ink sac. Character creation is **manual player crafting only** (inventory grid or crafting table). `ManualCraftingGuard` cancels this recipe's automated `CrafterCraftEvent` before native consumption/ejection, without touching other recipes or reversing cancellations. Startup installs this guard and the manual controller before exposing the recipe. Its preview has no character metadata and allocates no identity/name. Manual completion, bulk crafting, cancellation/save handling and accepted anvil-name persistence are registered. Presentation edits preserving metadata are tested, not a claim that complete anvil transactions work.
|
||||
|
||||
## Manual craft planning (not delivery)
|
||||
|
||||
`ManualCraftPlan` reads real crafting inventories without mutation. It calculates one result for cursor/number/offhand clicks, distinct empty destination slots for shift-click, and single/bulk intent for Q/Ctrl-Q. It bounds quantity by ingredients and storage capacity, preserves native reverse storage order, rejects cancelled/non-craft/wrong-recipe/malformed input, and snapshots full item data rather than retaining mutable native stack mirrors. Returned leftovers are detached copies; `matches` detects changed inputs, result, recipe, cursor or planned destinations without overwriting intervening items.
|
||||
|
||||
This is **not a transaction commit**. Eleven native planner/composition tests use actual 2×2/3×3 crafting containers, player storage, menu-carried stacks and recipe holders, with the absent player/network boundary stubbed. They do not execute packet dispatch or vanilla result consumption. The assembled controller separately supplies one-use lifecycle tokens (a native menu may be reused after close/reopen), allocation/persistence wiring, reentrant update handling and delivery. Drop intent is not proof that a cancellable native drop succeeds. No recipe/listener registration, recovery policy or cross-save atomicity is added by this planner.
|
||||
|
||||
## Fixed-destination craft delivery
|
||||
|
||||
`ManualCraftDelivery` can apply a validated plan and exact-sized saved batch to the cursor, selected hotbar/offhand slot or bulk storage destinations. It rechecks current contents before claiming delivery, constructs all sheets first, then debits fresh native input mirrors and places all outputs before triggering one matrix recomputation. This deliberately relies on the pinned Purpur 2618 mirror/count/setter behavior. No further adapter-planned writes occur after the prepare-callback boundary. Existing native setters may send packets; this is not packet-silent or exception/crash-atomic.
|
||||
|
||||
Nine native delivery tests cover normal/right-click, bulk and exact-slot delivery, partial ingredient metadata, empty first matrix cells, stale context refusal, mismatched/abandoned/reused batches, reentrancy, callback item changes, new-menu cursor safety and post-callback exceptions. The fixture uses actual containers/cursor stacks and intercepts `slotsChanged`; it does not execute a listening server's recipe manager or event dispatch. A mismatch refuses before consuming cleanup authority; an exception after claiming delivery conservatively retains records rather than replaying or rolling back.
|
||||
|
||||
The caller still must contain vanilla consumption, validate a one-use lifecycle/attempt token, schedule on the server thread, pass the player's actual current view and synchronize the appropriate client menu afterward. World-drop plans return `UNSUPPORTED_ROUTE` unchanged in this component; that is not a registered gameplay policy. The registered controller uses this component only for fixed destinations and routes Q/Ctrl-Q separately.
|
||||
|
||||
## Q/Ctrl-Q craft delivery
|
||||
|
||||
Q/Ctrl-Q remain supported scope; no shortcut restriction was accepted or enabled. `ManualDropDelivery` executes saved drop plans one unit at a time, checking original contents minus completed debits before every next unit. `PlayerSheetDrops` creates an unspawned item, dispatches `PlayerDropItemEvent`, revalidates, and uses native `Item.spawnAt` for the cancellable `ItemSpawnEvent` path. It never uses inventory-source dropping or cancellation's return-to-inventory fallback. A successful unit consumes one ingredient set, then recomputes; cancellation stops the suffix without charging it.
|
||||
|
||||
Thirteen additional tests cover sequential/bulk/cancelled drops, exact remaining-input validation, stale context, changed/removed entities, same-batch reentry and exceptions. Tests combine real native matrices, serialized sheets, YAML and Paper's event dispatcher; world insertion itself remains an explicit API boundary double, supplemented by pinned-runtime bytecode inspection. No live world or packet dispatch is claimed.
|
||||
|
||||
After public callbacks, records are conservatively retained—including unused batch records. Repeated late cancellations can therefore accumulate records. A spawn callback can change inventory after an output escapes but before a safe debit; `UNCONFIRMED` means stop/report without speculative charging, deletion, rollback or replay. This is not arbitrary-plugin/cross-save atomicity. The controller must hold a per-attempt guard across callbacks (including against newly started attempts), revalidate actual current view/lifecycle, and synchronize the client. The registered controller supplies these guards and has native lifecycle coverage as described below.
|
||||
|
||||
## Manual craft controller
|
||||
|
||||
`ManualCraftingController` contains vanilla handling before deferred work, allocates/saves accepted plans, returns delivery to an always-deferred server-thread callback and synchronizes the player's actual current inventory afterward. It uses attempt-object identity plus a delivery reservation that survives close/reopen and remains held through client-sync callbacks. It invalidates on close, accepted open, quit and disable; stale callbacks cannot clear a newer attempt, and rejected scheduling abandons owned unissued records without worker-side player access.
|
||||
|
||||
Twelve native controller tests cover saved deferred delivery, Q/Ctrl-Q routing with full storage, replay/reentrancy, menu reuse, client-sync reentry, pre-existing cancellation/unrelated recipes, rejected intents, allocation/save/scheduler failures, postclaim exceptions and retained-file reporting. Review reproduced a planning exception escaping before vanilla cancellation; capture now always cancels in `finally`. Async diagnostics are collected and asserted on the test thread rather than lost inside discarded futures. These tests invoke controller callbacks with real native grids/items/YAML and explicit player/scheduler/world boundaries, not a listening server. The controller checkpoint passed all 153 tests, audit and static analysis. The newer registration assembly is described below.
|
||||
|
||||
Scheduler injection must always enqueue (never run inline) or throw on rejection. Diagnostic callbacks must be nonthrowing, thread-safe and avoid native player access. The existing cancellation boolean cannot distinguish this handler's containment from a later plugin's independent veto; prior cancellations are preserved. No new Q/Ctrl-Q restriction, global inventory lock or automatic uncertain-delivery replay is introduced.
|
||||
|
||||
## Startup, configuration and shutdown
|
||||
|
||||
The packaged `FantasyStickPlugin` now wires storage, allocation, deferred scheduling, native drop-event dispatch and logger diagnostics. Both protective listeners are installed before `fantasystick:character_sheet` is exposed. Recipe-key collisions and partial registration failures clean up this enable attempt without replacing an existing recipe. Shutdown invalidates attempts before closing storage, unregisters its listeners and removes its recipe; removal exceptions are reported while remaining cleanup continues.
|
||||
|
||||
An optional `<plugin data>/config.yml` setting controls **new** characters' initial points:
|
||||
|
||||
```yaml
|
||||
starting-points: 40
|
||||
```
|
||||
|
||||
The default is 40; nonnegative arbitrary-precision integers are supported. Invalid values fail before gameplay registration. Existing saved characters are not rewritten. Configuration is read on enable; no reload command or automatic data migration is supplied.
|
||||
|
||||
Nine native lifecycle tests load the actual packaged entrypoint through a test configured classloader and native `JavaPlugin.setEnabled`, then register/dispatch through Paper. They cover registration ordering, configured points, all craft routes, distinct persisted sheets, installed Crafter cancellation, failed startup and shutdown/queued-callback cleanup. Server registry/scheduler/player/world insertion are explicit test ports; this is not a listening world/client test. The assembled checkpoint passes clean/incremental packaging, all 162 tests, audit and static analysis; remote publication is tracked separately. This historical crafting checkpoint predates the equipment-session integration below; the remaining RPG is not complete.
|
||||
|
||||
## Accepted anvil names
|
||||
|
||||
`CharacterAnvilNames` observes Purpur's non-cancellable accepted-take event, not anvil previews. Matching input/result character identities and changed displayed text are captured on the server thread; `CharacterStorage.renameCharacter` then changes only name/revision on the persistence worker against the latest live record. It preserves stats, XP, levels, points, health, loadout, cooldowns and historical death identity. Same-name requests do not rewrite; missing/dead records cannot be created or revived by renamed items. Clearing the custom name uses the native item-factory name, including an explicit item-name component where present.
|
||||
|
||||
Vanilla retains responsibility for item movement, XP charging and anvil damage. The inspected event fires at the start of native accepted-take processing, before that continuation; the observer does not make native completion contingent on YAML acknowledgment. Failure/crash may leave item presentation ahead of the saved name. The original saved progress is retained and captured IDs are logged; there is no automatic item/XP rollback, reissue or guaranteed retry.
|
||||
|
||||
Six backend tests, seven native observer tests and an added packaged-entrypoint registration test cover preservation, sequencing, failures, shutdown, preview/take separation, name fallbacks and later serialized sheet/Tail rendering. Native XP/damage execution and live clients are not exercised by these fixtures. Active overhead/chat/tab presentation now refreshes after acknowledged saves, including renaming a copied reference held by someone else; stale session/revision callbacks cannot rename a replacement holder. This slice passes 176 tests, clean/incremental packaging, three additional native-suite repeats, audit and static analysis. A controller test's leftover retry was explicitly drained before fixture reuse (12 focused stress runs pass); production shutdown remains nonblocking. Remote publication is tracked separately.
|
||||
|
||||
## Native health ceiling (historical core checkpoint)
|
||||
|
||||
`CharacterProgress` now limits base maximum health to `min(10 + CON, supplied native limit)` without limiting CON purchases. A purchase adds only the actual maximum-health gain to current health: fractional gain when crossing the ceiling, zero gain once capped. Injury and revision/idempotency protections remain intact. The two-argument initializer uses the pinned runtime's default 1,024 HP; runtime adapters must pass their actual ceiling explicitly.
|
||||
|
||||
`NativeHealthLimit.read()` reads the current native ranged attribute through the pinned runtime's public Craft/native accessors, without setting attributes or changing configuration. It must run on the server thread after registry initialization. Incompatible accessors or nonpositive/nonfinite/unrepresentable native ranges fail explicitly instead of guessing. This is not a refusal based on CON. Tests cover core purchases and actual native attribute compatibility at 10.5, 1,024 and 2,048 HP; native tests restore their temporary in-process range changes in `finally`.
|
||||
|
||||
At this historical checkpoint, no virtual health, global configuration change, stored-data migration or active-player health adapter was introduced. The persisted schema's raw `10 + CON` validation envelope remains unchanged for compatibility. The later native session integration is described below; a complete Revive spell remains unfinished. Current-health gain is calculated against the core instance's fixed supplied limit; callers must obtain/revalidate the applicable limit at their native operation boundary. This slice passes 186 tests, clean/incremental packaging, audit and static analysis. Remote publication is tracked separately.
|
||||
|
||||
## Session claims
|
||||
|
||||
`SessionClaims` supplies process-local exclusion for pending/active character sessions. A single shared registry atomically prevents simultaneous claims for either the same character identity or the same player's active mode. This does not limit how many sheets a player may own or permanently bind a character to a holder. Released characters can transfer; a stale token cannot release a replacement claim. Private claim objects carry distinct session UUIDs for future equipment/callback correlation. Dead/zero-health DTOs refuse unchanged; shutdown invalidates all claims and refuses new ones.
|
||||
|
||||
Six unit tests cover exclusion, transfer/switch release, stale callbacks, invalid input, shutdown/restart boundaries, same-name identities, uncapped values and 24 rounds of 16 competing callbacks. The registry does no persistence or native work and does not impose a native-health cap. It must be shared across entry routes; separate processes do not coordinate. Callers still must load current authoritative state, validate sheet possession/context/deadlines, preflight native equipment/health, retain/release claims across their lifecycle and reconcile interrupted state. `isCurrent()` is a lifetime check, not saved-state or effect authorization. The equipment coordinator now uses one shared registry for its entry/exit routes. The historical foundation passed 182 tests, clean/incremental packaging, audit and static analysis; remote publication is tracked separately.
|
||||
|
||||
## Native equipment adapter
|
||||
|
||||
@@ -43,7 +122,81 @@ The `fantasystick:character_sheet` shapeless recipe definition contains one stic
|
||||
|
||||
Version-1 box metadata uses `fantasystick:storage_version`, `storage_owner_id`, `storage_session_id` and `storage_slots`. Box positions 0–8 contain original hotbar slots 0–8; positions 9–12 contain boots, leggings, chest and head from player slots 36–39. These metadata fields are for identity/restoration, not the only copy of the items. Restoration searches main slots 9–35, so sorting relocation does not lose the box. Repeated requests, wrong identity, duplicate/malformed boxes, unexpected extra contents or occupied restoration slots do not overwrite/delete items. These are adapter error results, not a persistent administrator lock or completed recovery policy.
|
||||
|
||||
The caller must remove authenticated fantasy controls before restoration. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The adapter does not yet route sheet/cursor events, emit entry warnings, manage character/player snapshots or register a playable session. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
The coordinator supplies authenticated control ownership to the adapter, which validates all destinations before replacing controls. One new head stack uses the slot freed by removing the box, even with a full main inventory; there is no drop fallback. The equipment coordinator/event bridge now routes sheet/cursor events, emits entry warnings and registers sessions. Native health/vitals integration is described below; active nutrition overrides remain unfinished. Standalone display projection is implemented below. Native tests use real player-slot storage with only the absent network recipient stubbed; client packets and live gameplay remain unverified.
|
||||
|
||||
## Equipment sessions and safe box movement
|
||||
|
||||
The historical US-002 completion checkpoint passed all acceptance criteria and **359 automated tests** (315 Java/native and 44 release-tool tests), clean/incremental packaging, dependency audit and static analysis. The new equipment path loads authoritative characters, claims player/character identity, and acknowledges `<plugin data>/sessions/<player UUID>.yml` before packing. Preparation failure leaves gear unchanged. Nine typed bound controls occupy the hotbar; book/ability actions were not yet implemented at that checkpoint. Current inspection and party actions are described below; basic/learned attacks remain unfinished.
|
||||
|
||||
The genuine **Fantasy Storage Box** contains actual original items. Picking it up for a move leaves it in main storage and issues a harmless bound cursor move-control. Confirming a valid main-storage destination exchanges the real box and that destination's contents; closing the inventory cancels the move. Sorting, stale callbacks and lifecycle changes cannot confer movement authority on a copied control. Full storage needs no extra space or overflow drops. Ordinary sheet movements, including Q/Ctrl-Q, remain supported.
|
||||
|
||||
Exit removes controls and restores original slots once, locating the genuine box by owner/session rather than original index. A new head uses the freed box slot. Native player phase markers and the persisted receipt must agree for recovery; ambiguous state is preserved/reported, never guessed into replacement gear. Shutdown is best-effort and nonblocking; accepted cleanup drains before its receipt worker closes. Equipment stays vanilla-readable without this plugin.
|
||||
|
||||
Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the player-bound restriction. Existing absolute deadlines survive configuration changes; only a new actual exit starts a new deadline, and repeated cleanup does not extend it. The switch-delay setting is read on enable; there is no general reload command or deployed configuration change.
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
|
||||
Tests use actual native items, inventories, serialization, Paper event dispatch and menu-close behavior, with explicit scheduler/player/world ports. Drag continuation ordering includes a model based on pinned bytecode. The packaged lifecycle fixture isolates the entrypoint but delegates helpers parent-first. Live clients, production PluginClassLoader execution and arbitrary-plugin/crash atomicity are not claimed verified. No deployment or existing-data migration is authorized by story completion.
|
||||
|
||||
## Standalone character presentation
|
||||
|
||||
Saved character identity/progress transfers between holders; ordinary equipment, vitals, return location and switch deadline do not. Native reference/session tests cover live/dead/invalid/copied identities, concurrent exclusion, orderly switches and legitimate backend revival. The complete Revive spell remains unfinished.
|
||||
|
||||
ProtocolLib-backed presentation shows the full literal saved name above the player, in chat and in the player list, without changing the server account name/UUID/profile, scoreboard membership, ordinary display/list fields or authoritative name-tag-distance attribute. A detached client-only TextDisplay replaces the native overhead label; display-name-only player-info packets and the chat renderer's sender-name argument cover the other surfaces. No signed message-body or recipient rewrite is introduced.
|
||||
|
||||
Output resources belong to a player connection/session, not the sheet. Exit/defeat/lifecycle cleanup withdraws them independently of successful gear restoration, using current ordinary server state rather than replaying stale fantasy names. Renames refresh only after authoritative file acknowledgment; future sessions also reload saved names. Reconnect/restart discards client-only labels, not ordinary identity data.
|
||||
|
||||
**Cross-plugin display compatibility is unsupported**, as explicitly requested. There is no Stealth/Leaf/Ignis bridge or nickname/concealment guarantee. Native/platform visibility and ordinary cancellations are covered; supplemental presentation-owned packets bypass ProtocolLib filters to avoid recursive projection. Names can therefore conflict with other plugins' presentation or packet-only concealment. This does not disable ordinary protection or inventory safeguards.
|
||||
|
||||
Tests exercise actual native codecs, renderer inputs and ProtocolLib dispatch with explicit transport/server/player ports. They do not prove live-client appearance or client delivery acknowledgments. Unknown transport outcomes are reported without speculative spawn replay; cleanup remains best-effort. Label reconciliation is tick-driven; live-client/network-load observation remains a follow-up. Optional Adventure mode is described below; its verification does not complete the remaining fantasy health, nutrition or combat features.
|
||||
|
||||
## Optional Adventure mode
|
||||
|
||||
`adventure-mode: true` is the default. A new entry captures this policy; enabled entries save the holder's original game mode in the player/session receipt before changing it to Adventure. Disabled entries leave the mode untouched, as do holders already in Adventure. This state never transfers with the character.
|
||||
|
||||
The admin command saves on a worker, preserving unrelated configuration values, before publishing its effective value. Invalid values and failed writes leave that value unchanged. Already active sessions keep their captured policy. Accepted writes drain without blocking shutdown; immediate re-enable defers startup asynchronously until those writes finish, then reads the saved policy.
|
||||
|
||||
Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Adventure change. Observed later admin changes relinquish ownership, including an observed change away and back. Native cancellation refuses entry without packing; a cancelled restoration retains its receipt for inspection/retry. Interrupted or mismatched player markers are not replay permission. Mode cleanup is independent of uncertain equipment cleanup.
|
||||
|
||||
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
|
||||
|
||||
## Native fantasy health, exit and defeat
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
|
||||
|
||||
An intercepted fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Ordinary health is restored before health-scale setters can send or queue client health packets: cancelled defeat must not deliver zero health and strand an otherwise alive player on a death screen. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. New Tails group ordinary-sized name/level/XP/points/stats on a summary page, then give each learned ability its own section with saved ID, assigned ability slots and cooldown deadline. Unknown IDs, saved unlearned cooldowns, empty abilities and death time stay explicit; long content continues onto additional pages without truncation. No ability descriptions are invented and existing books are not rewritten. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
|
||||
|
||||
Interrupted recovery requires matching player/session receipt and native proof. It preserves current native modifier ownership and does not resurrect expired effects. Repeated/stale recovery cannot replay ordinary health or recreate an already released claim. This is **not crash-atomic** across player, inventory and plugin files; unresolved native setter phases and unacknowledged progress may require inspection. Legacy equipment-only receipts remain readable but do not grant health-restoration authority. Older binaries do not understand the new optional ordinary-vitals receipt field; do not downgrade across unresolved newer receipts.
|
||||
|
||||
Active hunger/exhaustion suppression and food-regeneration discrimination remain US-026 work. Book stat purchases, XP delivery, basic/learned attacks and Revive remain separate unfinished stories. The preceding equipment/progression checkpoint sections are historical evidence, not the current health scope.
|
||||
|
||||
## Character inspection and inventory safety
|
||||
|
||||
The first-hotbar-slot book reads the active character's authoritative name, six stats, health, level, XP/MAX, points and learned identities. Inspection does not change character data or inventory. Plain-text pages wrap conservatively for the vanilla font without splitting Unicode code points. Large supported records use volumes of at most 80 inspection pages, leaving room for navigation and party controls while bounding each opened view's payload. This is a plugin presentation bound, not Minecraft's 100-page writable-book cap; signed books use a different codec. `[Next pages]` / `[Previous pages]` reread current data; unknown ability IDs and arbitrarily large saved numbers are not silently discarded.
|
||||
|
||||
Deferred reads and single-use navigation validate the player, session, character and held control. Superseded requests, ended sessions, quit/disable and invalid client tokens cannot open a stale view. Read failures preserve data and allow reopening. Native tests cover overflow, multiple volumes, rename, transfer, reload and unchanged inventory/data; actual client rendering and custom resource-pack fonts are not claimed as tested.
|
||||
|
||||
All nine hotbar controls remain bound, with seven selectable placeholders on a new character. Native containment tests cover export/consumption, hidden swaps, recipe autofill, armor/cosmetics, cursor custody and sorting relocation. The packaged inventory journey proves that these guards still permit the real book action and that removing the transferable offhand sheet restores ordinary equipment. Future attack/ability consumers must retain these same safeguards.
|
||||
|
||||
## Character-book parties and combat protection
|
||||
|
||||
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
|
||||
|
||||
A disconnected leader remains leader while another member is online. All-offline parties, invitations and book views are discarded on shutdown/restart. This does not persist or restore party rosters.
|
||||
|
||||
Native damage protection blocks friendly fire between active fantasy party members and player-attributable mixed-mode damage in both directions. Ordinary-versus-ordinary and environmental/mob combat retain their native rules and prior cancellations. Projectiles, TNT, fangs, clouds, fireworks and pet attacks retain player attribution; delayed fantasy sources cannot borrow a new session after exit, transfer or reconnect. Valid native healing is unchanged; stale projectile impacts are rejected before execution. Full RPG attacks and support/XP abilities remain separate unfinished work.
|
||||
|
||||
Attributable poison, wither and burning retain bounded source/recipient provenance in native entity/player PDC (`combat_origin_v1`, `combat_dot_*_v1`). Each damage tick checks current membership, source/recipient session identity and native effect state. Poison/wither hidden layers keep their own source rather than inheriting a covering effect's origin. Removal, expiry and unrelated replacement clear only the matching provenance; cleanup does not remove native effects or change health, equipment, character YAML or XP. A tick-driven cleanup checks only the three relevant player metadata keys and stops on disable. Native PDC serialization retains source proofs across reload; ended fantasy sessions remain invalid, while parties themselves never survive restart. These are native save guarantees, not cross-save atomicity or dedicated cross-plugin integration.
|
||||
|
||||
Automated party tests exercise actual packaged listeners, native book custom-click payloads, native event dispatch, effect-merging semantics and PDC serialization. Player/network/world execution remains represented by explicit test ports; live multiplayer testing and deployment are separate.
|
||||
|
||||
## CI and publication
|
||||
|
||||
@@ -61,4 +214,4 @@ The release credential must be a repository-write credential authorized for `dmg
|
||||
|
||||
Stories, design, open decisions and evidence are in the private [SoMC wiki project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md). See [AGENTS.md](AGENTS.md) for the shared process. Source builds do not need wiki access.
|
||||
|
||||
No inventory recovery guarantees, gameplay balance, multiplayer behavior, or production compatibility have been verified by this bootstrap. Production rollout and GitOps changes remain separately approved; the development cycle never pushes GitOps.
|
||||
The documented crafting/cleanup boundaries are automated-test evidence, not cross-save atomicity, complete RPG balance, live multiplayer or production-compatibility verification. Production rollout and GitOps changes remain separately approved; the development cycle never pushes GitOps.
|
||||
+45
-5
@@ -1,5 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import org.gradle.api.tasks.bundling.Zip
|
||||
|
||||
plugins {
|
||||
java
|
||||
@@ -7,6 +8,7 @@ plugins {
|
||||
|
||||
group = "games.dmg"
|
||||
version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get()
|
||||
val pluginVersion = version.toString()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -25,17 +27,51 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
val resourcePackInput = layout.buildDirectory.dir("generated/resource-pack-input")
|
||||
val prepareResourcePack = tasks.register<Copy>("prepareResourcePack") {
|
||||
from("src/resourcePack")
|
||||
into(resourcePackInput)
|
||||
filesMatching("pack.mcmeta") { expand("version" to pluginVersion) }
|
||||
}
|
||||
val sheetResourcePack = tasks.register<Zip>("sheetResourcePack") {
|
||||
dependsOn(prepareResourcePack)
|
||||
from(resourcePackInput)
|
||||
archiveFileName.set("purpur-fantasy-stick-$pluginVersion-resource-pack.zip")
|
||||
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
|
||||
isPreserveFileTimestamps = false
|
||||
isReproducibleFileOrder = true
|
||||
}
|
||||
val resourcePackMetadata = layout.buildDirectory.dir("generated/resource-pack-metadata")
|
||||
val generateResourcePackMetadata = tasks.register("generateResourcePackMetadata") {
|
||||
dependsOn(sheetResourcePack)
|
||||
inputs.file(sheetResourcePack.flatMap { it.archiveFile })
|
||||
inputs.property("pluginVersion", pluginVersion)
|
||||
outputs.file(resourcePackMetadata.map { it.file("sheet-resource-pack.properties") })
|
||||
doLast {
|
||||
val pack = sheetResourcePack.get().archiveFile.get().asFile
|
||||
val sha1 = MessageDigest.getInstance("SHA-1").digest(pack.readBytes()).joinToString("") { "%02x".format(it) }
|
||||
val version = pluginVersion
|
||||
val output = resourcePackMetadata.get().file("sheet-resource-pack.properties").asFile
|
||||
output.parentFile.mkdirs()
|
||||
output.writeText("url=https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v$version/purpur-fantasy-stick-$version-resource-pack.zip\nsha1=$sha1\n")
|
||||
}
|
||||
}
|
||||
sourceSets.main.get().resources.srcDir(resourcePackMetadata)
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
dependsOn(tasks.jar, sheetResourcePack)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
systemProperty("distribution.version", pluginVersion)
|
||||
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
|
||||
}
|
||||
|
||||
// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base.
|
||||
@@ -71,6 +107,8 @@ val nativeTest = sourceSets.create("nativeTest")
|
||||
dependencies {
|
||||
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
|
||||
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
|
||||
add(nativeTest.implementationConfigurationName, "net.dmulloy2:ProtocolLib:5.4.0")
|
||||
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
|
||||
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
|
||||
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
|
||||
@@ -78,17 +116,20 @@ dependencies {
|
||||
val nativeRuntimeJars = files(fileTree(nativeRuntime) {
|
||||
include("versions/**/*.jar", "libraries/**/*.jar")
|
||||
}).builtBy(prepareNativeRuntime)
|
||||
// Pinned output-only packet codec; native classes remain provided by the server, never bundled.
|
||||
sourceSets.main.get().compileClasspath += nativeRuntimeJars
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
|
||||
description = "Verifies packaged metadata and native item serialization without starting a server"
|
||||
dependsOn(tasks.jar)
|
||||
dependsOn(tasks.jar, sheetResourcePack)
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "1G"
|
||||
workingDir(nativeRuntime)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
|
||||
}
|
||||
val installReleaseTools = tasks.register<Exec>("installReleaseTools") {
|
||||
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
|
||||
@@ -99,9 +140,8 @@ val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
|
||||
}
|
||||
tasks.check { dependsOn(nativeCompatibilityTest, releaseToolCheck) }
|
||||
|
||||
val pluginVersion = version
|
||||
|
||||
tasks.processResources {
|
||||
dependsOn(generateResourcePackMetadata)
|
||||
inputs.property("pluginVersion", pluginVersion)
|
||||
filesMatching("plugin.yml") {
|
||||
expand("version" to pluginVersion)
|
||||
|
||||
@@ -9,8 +9,9 @@ try {
|
||||
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|
||||
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input");
|
||||
const jar = await readFile(`build/libs/purpur-fantasy-stick-${version}.jar`);
|
||||
const resourcePack = await readFile(`build/distributions/purpur-fantasy-stick-${version}-resource-pack.zip`);
|
||||
await publishRelease({
|
||||
credential, version, commit, jar,
|
||||
credential, version, commit, jar, resourcePack,
|
||||
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
|
||||
}, fetch);
|
||||
console.log(`Published purpur-fantasy-stick v${version}.`);
|
||||
|
||||
@@ -8,12 +8,15 @@ const input: ReleaseInput = {
|
||||
commit: "a".repeat(40),
|
||||
notes: "Verified changes",
|
||||
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
|
||||
resourcePack: new Uint8Array([0x50, 0x4b, 0x03, 0x05]),
|
||||
};
|
||||
|
||||
function success(init: RequestInit): Response {
|
||||
function success(init: RequestInit, url = ""): Response {
|
||||
const pack = url.includes("resource-pack.zip");
|
||||
return Response.json({
|
||||
id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3",
|
||||
name: "purpur-fantasy-stick-1.2.3.jar", size: input.jar.length,
|
||||
name: pack ? "purpur-fantasy-stick-1.2.3-resource-pack.zip" : "purpur-fantasy-stick-1.2.3.jar",
|
||||
size: pack ? input.resourcePack.length : input.jar.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +24,7 @@ test("creates a draft on the fixed repository and exact source commit, without f
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const id = await publishRelease(input, async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return success(init);
|
||||
return success(init, url);
|
||||
});
|
||||
assert.ok(calls.length >= 1);
|
||||
const first = calls[0]!;
|
||||
@@ -40,17 +43,21 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
await publishRelease(input, async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return success(init);
|
||||
return success(init, url);
|
||||
});
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.equal(calls[1]!.url,
|
||||
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3.jar");
|
||||
assert.equal(calls[1]!.init.method, "POST");
|
||||
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
|
||||
assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream");
|
||||
assert.equal(calls[2]!.init.method, "PATCH");
|
||||
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
|
||||
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
|
||||
assert.equal(calls[2]!.url,
|
||||
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3-resource-pack.zip");
|
||||
assert.equal(calls[2]!.init.method, "POST");
|
||||
assert.deepEqual(new Uint8Array(await new Response(calls[2]!.init.body).arrayBuffer()), input.resourcePack);
|
||||
assert.equal(calls[3]!.init.method, "PATCH");
|
||||
assert.equal(calls[3]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
|
||||
assert.deepEqual(JSON.parse(String(calls[3]!.init.body)), { draft: false });
|
||||
for (const call of calls) {
|
||||
assert.equal(call.init.redirect, "error");
|
||||
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
|
||||
@@ -58,14 +65,14 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
|
||||
}
|
||||
});
|
||||
|
||||
for (const failureStep of [1, 2, 3]) {
|
||||
for (const failureStep of [1, 2, 3, 4]) {
|
||||
for (const status of [302, 401, 409, 500]) {
|
||||
test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(publishRelease(input, async (_url, init) => {
|
||||
calls++;
|
||||
if (calls === failureStep) return new Response(input.credential, { status });
|
||||
return success(init);
|
||||
return success(init, _url);
|
||||
}), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error.message.includes(String(status)));
|
||||
@@ -85,7 +92,7 @@ test("sanitizes transport errors and does not follow a redirect with the credent
|
||||
|
||||
for (const invalid of [
|
||||
{ credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" },
|
||||
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() },
|
||||
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() }, { resourcePack: new Uint8Array() },
|
||||
]) {
|
||||
test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => {
|
||||
let calls = 0;
|
||||
@@ -112,16 +119,18 @@ for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringi
|
||||
for (const [failureStep, body] of [
|
||||
[2, {}], [2, { name: "other.jar", size: 4 }],
|
||||
[2, { name: "purpur-fantasy-stick-1.2.3.jar", size: 0 }],
|
||||
[3, { id: 42, draft: true, tag_name: "v1.2.3" }],
|
||||
[3, { id: 77, draft: false, tag_name: "v1.2.3" }],
|
||||
[3, { id: 42, draft: false, tag_name: "v8.8.8" }],
|
||||
[3, {}], [3, { name: "other.zip", size: 4 }],
|
||||
[3, { name: "purpur-fantasy-stick-1.2.3-resource-pack.zip", size: 0 }],
|
||||
[4, { id: 42, draft: true, tag_name: "v1.2.3" }],
|
||||
[4, { id: 77, draft: false, tag_name: "v1.2.3" }],
|
||||
[4, { id: 42, draft: false, tag_name: "v8.8.8" }],
|
||||
] as const) {
|
||||
test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(publishRelease(input, async (_url, init) => {
|
||||
calls++;
|
||||
return calls === failureStep ? Response.json(body) : success(init);
|
||||
}), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." });
|
||||
return calls === failureStep ? Response.json(body) : success(init, _url);
|
||||
}), { message: failureStep === 2 || failureStep === 3 ? "Invalid release asset response." : "Invalid published release response." });
|
||||
assert.equal(calls, failureStep);
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface ReleaseInput {
|
||||
commit: string;
|
||||
notes: string;
|
||||
jar: Uint8Array;
|
||||
resourcePack: Uint8Array;
|
||||
}
|
||||
|
||||
export type Request = (url: string, init: RequestInit) => Promise<Response>;
|
||||
@@ -13,7 +14,7 @@ const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/r
|
||||
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
|
||||
if (!/^[\x21-\x7e]+$/.test(input.credential)
|
||||
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|
||||
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) {
|
||||
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0 || input.resourcePack.length === 0) {
|
||||
throw new Error("Invalid release input.");
|
||||
}
|
||||
const send: Request = async (url, init) => {
|
||||
@@ -49,6 +50,16 @@ export async function publishRelease(input: ReleaseInput, request: Request): Pro
|
||||
if (asset.name !== `purpur-fantasy-stick-${input.version}.jar` || asset.size !== input.jar.length) {
|
||||
throw new Error("Invalid release asset response.");
|
||||
}
|
||||
const packName = `purpur-fantasy-stick-${input.version}-resource-pack.zip`;
|
||||
const packResponse = await send(`${releases}/${release.id}/assets?name=${packName}`, {
|
||||
method: "POST", redirect: "error",
|
||||
headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" },
|
||||
body: new Uint8Array(input.resourcePack).buffer,
|
||||
});
|
||||
const pack = await readObject(packResponse, "Invalid release asset response.");
|
||||
if (pack.name !== packName || pack.size !== input.resourcePack.length) {
|
||||
throw new Error("Invalid release asset response.");
|
||||
}
|
||||
const publishedResponse = await send(`${releases}/${release.id}`, {
|
||||
method: "PATCH", redirect: "error",
|
||||
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
|
||||
|
||||
@@ -1,7 +1,655 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation;
|
||||
import games.dmg.fantasystick.presentation.CharacterChatNames;
|
||||
import games.dmg.fantasystick.presentation.ProtocolPresentation;
|
||||
import games.dmg.fantasystick.crafting.CharacterFactory;
|
||||
import games.dmg.fantasystick.crafting.CharacterAnvilNames;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingController;
|
||||
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import games.dmg.fantasystick.session.EquipmentAdminCommand;
|
||||
import games.dmg.fantasystick.session.EquipmentSessionEvents;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Build foundation only; gameplay is not yet registered or ready for deployment. */
|
||||
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
|
||||
public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private CharacterStorage characters;
|
||||
private ManualCraftingController crafting;
|
||||
private ManualCraftingGuard guard;
|
||||
private CharacterAnvilNames anvilNames;
|
||||
private boolean recipeRegistered;
|
||||
private EquipmentSessionStore receipts;
|
||||
private EquipmentSessions sessions;
|
||||
private EquipmentSessionEvents sessionEvents;
|
||||
private games.dmg.fantasystick.book.CharacterBookController books;
|
||||
private games.dmg.fantasystick.party.PartyRoster parties;
|
||||
private games.dmg.fantasystick.party.PartyEvents partyEvents;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private volatile Object lifetime;
|
||||
private CharacterPresentation presentation;
|
||||
private ProtocolPresentation protocolPresentation;
|
||||
private CharacterChatNames chatNames;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings adventureSettings;
|
||||
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
|
||||
private games.dmg.fantasystick.session.SessionVitals vitals;
|
||||
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
|
||||
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
|
||||
private games.dmg.fantasystick.party.CombatExperience combatExperience;
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
|
||||
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private games.dmg.fantasystick.ability.Fireball fireball;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
|
||||
private games.dmg.fantasystick.ability.OutgoingDamageMitigation outgoingDamageMitigation;
|
||||
private games.dmg.fantasystick.ability.Hex hex;
|
||||
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
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.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
try {
|
||||
Object generation = new Object();
|
||||
lifetime = generation;
|
||||
if (!configurationDrain.toCompletableFuture().isDone()) {
|
||||
configurationDrain.whenComplete((ignored, failure) -> {
|
||||
if (lifetime != generation) return;
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (lifetime == generation && isEnabled()) onEnable();
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
getLogger().log(Level.WARNING, "Configuration startup continuation unavailable", rejected);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
reloadConfig();
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
|
||||
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
|
||||
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
|
||||
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var fireballSettings = fireballSettings();
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
var longbowConfiguration = longbowConfiguration();
|
||||
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
|
||||
var quickstepConfiguration = quickstepConfiguration();
|
||||
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
|
||||
var secondWindConfiguration = secondWindConfiguration();
|
||||
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
|
||||
var stoneSkinConfiguration = stoneSkinConfiguration();
|
||||
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
|
||||
var shieldBashConfiguration = shieldBashConfiguration();
|
||||
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
|
||||
var hexConfiguration = hexConfiguration();
|
||||
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
|
||||
var inspireConfiguration = inspireConfiguration();
|
||||
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
task -> getServer().getScheduler().runTask(this, task),
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
|
||||
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
|
||||
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
|
||||
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.
|
||||
var localPresentation = new java.util.concurrent.atomic.AtomicReference<CharacterPresentation>();
|
||||
var installationFailure = new java.util.concurrent.atomic.AtomicReference<Throwable>();
|
||||
protocolPresentation = new ProtocolPresentation(this, ProtocolLibrary.getProtocolManager(),
|
||||
() -> localPresentation.get().snapshot(), problem -> {
|
||||
installationFailure.compareAndSet(null, problem);
|
||||
presentationFailures.accept(problem);
|
||||
});
|
||||
presentation = new CharacterPresentation(protocolPresentation, nextTick,
|
||||
() -> getServer().isPrimaryThread(), presentationFailures, characters::find);
|
||||
localPresentation.set(presentation);
|
||||
chatNames = new CharacterChatNames(presentation::name);
|
||||
protocolPresentation.install();
|
||||
if (installationFailure.get() != null) {
|
||||
throw new IllegalStateException("Could not install character presentation", installationFailure.get());
|
||||
}
|
||||
guard = new ManualCraftingGuard();
|
||||
anvilNames = new CharacterAnvilNames(characters,
|
||||
problem -> getLogger().log(Level.WARNING, "Character name update was refused or failed for player "
|
||||
+ problem.player() + "; character " + problem.character(), problem.cause()), getServer().getItemFactory(),
|
||||
presentation::acknowledged);
|
||||
receipts = new EquipmentSessionStore(getDataFolder().toPath(), Clock.systemUTC());
|
||||
java.util.function.Consumer<EquipmentSessions.Failure> diagnostics = problem ->
|
||||
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
|
||||
+ "; session " + problem.session(), problem.cause());
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
|
||||
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
|
||||
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
|
||||
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
|
||||
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
sessions.attachSwitchDelay(switchDelaySettings::delay);
|
||||
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
}, partySettings::maximum);
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
getDataFolder().toPath(), frostShardConfiguration);
|
||||
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
|
||||
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
|
||||
getDataFolder().toPath(), longbowConfiguration);
|
||||
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
|
||||
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
|
||||
getDataFolder().toPath(), quickstepConfiguration);
|
||||
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
|
||||
event -> getServer().getPluginManager().callEvent(event));
|
||||
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);
|
||||
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
|
||||
getDataFolder().toPath(), stoneSkinConfiguration);
|
||||
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
|
||||
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
|
||||
getDataFolder().toPath(), shieldBashConfiguration);
|
||||
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
|
||||
shieldBashInitial,
|
||||
event -> getServer().getPluginManager().callEvent(event));
|
||||
outgoingDamageMitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
hexSettings = new games.dmg.fantasystick.persistence.HexSettings(getDataFolder().toPath(), hexConfiguration);
|
||||
hex = new games.dmg.fantasystick.ability.Hex(hexInitial, outgoingDamageMitigation);
|
||||
temporaryHealth = new games.dmg.fantasystick.ability.TemporaryHealth(sessions, parties, nextTick,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
|
||||
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
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));
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
abilityRegistry.register(longbow.definition());
|
||||
abilityRegistry.register(quickstep.definition());
|
||||
abilityRegistry.register(secondWind.definition());
|
||||
abilityRegistry.register(stoneSkin.definition());
|
||||
abilityRegistry.register(shieldBash.definition());
|
||||
abilityRegistry.register(hex.definition());
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
|
||||
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
|
||||
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
|
||||
return task::cancel;
|
||||
};
|
||||
adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout);
|
||||
adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
|
||||
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
|
||||
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
|
||||
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
|
||||
+ "; ability " + problem.ability(), problem.cause()));
|
||||
abilityEngine.install(this);
|
||||
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
}, abilityRegistry, abilityEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
localBasicAttack.accept(input);
|
||||
abilityEngine.accept(input);
|
||||
});
|
||||
sessionEvents.attachAbilityRegistry(abilityRegistry);
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
frostShardSettings, frostShard, longbowSettings, longbow,
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
|
||||
() -> 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);
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
getServer().getPluginManager().registerEvents(sessionEvents, this);
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
getServer().getPluginManager().registerEvents(combatMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(shieldBash, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(adminRevival, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(getServer().getOnlinePlayers());
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
lifetime = null;
|
||||
// Detach this enable's resources before any native callback can reenter disable.
|
||||
var closingPresentation = presentation; presentation = null;
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
var closingParties = parties; parties = null;
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
var closingSessions = sessions; sessions = null;
|
||||
var closingReceipts = receipts; receipts = null;
|
||||
var closingCharacters = characters; characters = null;
|
||||
var closingSettings = adventureSettings; adventureSettings = null;
|
||||
var closingModes = gameModes; gameModes = null;
|
||||
var closingVitals = vitals; vitals = null;
|
||||
var closingVisuals = sessionVisuals; sessionVisuals = null;
|
||||
var closingBasicAttack = basicAttack; basicAttack = null;
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
var closingLongbowSettings = longbowSettings; longbowSettings = null;
|
||||
var closingQuickstep = quickstep; quickstep = null;
|
||||
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
|
||||
secondWind = null;
|
||||
var closingStoneSkin = stoneSkin; stoneSkin = null;
|
||||
var closingShieldBash = shieldBash; shieldBash = null;
|
||||
var closingHex = hex; hex = null;
|
||||
var closingInspire = inspire; inspire = null;
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
var closingHexSettings = hexSettings; hexSettings = null;
|
||||
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
|
||||
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
|
||||
var closingCombatMitigation = combatMitigation; combatMitigation = null;
|
||||
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
|
||||
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
|
||||
var closingGreatSword = greatSword; greatSword = null;
|
||||
var closingRevive = revive; revive = null;
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
// Retire client resources independently of successful ordinary-equipment restoration.
|
||||
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
|
||||
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
|
||||
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::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);
|
||||
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
|
||||
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
|
||||
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
|
||||
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingLongbowSettings.drained().toCompletableFuture(),
|
||||
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickstepSettings.drained().toCompletableFuture(),
|
||||
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSecondWindSettings.drained().toCompletableFuture(),
|
||||
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingStoneSkinSettings.drained().toCompletableFuture(),
|
||||
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingShieldBashSettings.drained().toCompletableFuture(),
|
||||
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHexSettings.drained().toCompletableFuture(),
|
||||
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained().toCompletableFuture(),
|
||||
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRoleplaySettings.drained().toCompletableFuture(),
|
||||
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartySettings.drained().toCompletableFuture(),
|
||||
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSheetResourcePackSettings.drained().toCompletableFuture());
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
|
||||
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
|
||||
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
|
||||
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
|
||||
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
|
||||
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
|
||||
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
|
||||
if (closingHex != null) cleanup("Hex", closingHex::close);
|
||||
if (closingInspire != null) cleanup("Inspire", closingInspire::close);
|
||||
if (closingTemporaryHealth != null) cleanup("temporary health", closingTemporaryHealth::close);
|
||||
if (closingOutgoingDamageMitigation != null) cleanup("outgoing damage mitigation", closingOutgoingDamageMitigation::close);
|
||||
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
|
||||
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
unregister(closingQuickstep);
|
||||
unregister(closingCombatMitigation);
|
||||
unregister(closingShieldBash);
|
||||
unregister(closingOutgoingDamageMitigation);
|
||||
unregister(closingTemporaryHealth);
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
unregister(closingAnvil);
|
||||
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
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", () -> {
|
||||
if (closingSessions == null) closingReceipts.close();
|
||||
else closingSessions.drained().whenComplete((ignored, failure) -> closingReceipts.close());
|
||||
});
|
||||
}
|
||||
|
||||
private void unregister(org.bukkit.event.Listener listener) {
|
||||
if (listener != null) cleanup("listener " + listener.getClass().getSimpleName(),
|
||||
() -> org.bukkit.event.HandlerList.unregisterAll(listener));
|
||||
}
|
||||
|
||||
private void cleanup(String resource, Runnable action) {
|
||||
try { action.run(); }
|
||||
catch (RuntimeException | LinkageError failure) {
|
||||
try { getLogger().log(Level.WARNING, "Could not close " + resource + "; continuing shutdown cleanup", failure); }
|
||||
catch (RuntimeException | LinkageError ignored) { /* Diagnostics must not prevent remaining cleanup. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private ProgressionRules progressionRules() {
|
||||
try {
|
||||
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
|
||||
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
|
||||
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
|
||||
return new ProgressionRules(starting, threshold, maximum);
|
||||
} catch (ArithmeticException invalid) {
|
||||
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
|
||||
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
|
||||
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
|
||||
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
|
||||
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostShardConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> longbowConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> quickstepConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> secondWindConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> stoneSkinConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> shieldBashConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> hexConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.HexSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.HexSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.HexSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> inspireConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.InspireSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.InspireSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.InspireSettings.parse(values);
|
||||
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", "1.2"));
|
||||
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 void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
|
||||
Runnable delivery = () -> {
|
||||
if (lifetime != generation) return;
|
||||
var player = getServer().getPlayer(notice.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
String message = switch (notice.code()) {
|
||||
case "NO_SPACE" -> "Equipment entry refused: free one slot in your main inventory for the Fantasy Storage Box.";
|
||||
case "INELIGIBLE_EQUIPMENT" -> "Equipment entry refused: a hotbar or armor item cannot fit in a vanilla shulker box. Move it before retrying.";
|
||||
default -> notice.message();
|
||||
};
|
||||
player.sendMessage(message);
|
||||
if (notice.code().equals("DEFEAT")) games.dmg.fantasystick.session.SessionVisuals.burst(player, org.bukkit.Color.RED, 12);
|
||||
else player.spawnParticle(org.bukkit.Particle.ANGRY_VILLAGER, player.getLocation().add(0, 1, 0), 8, 0.3, 0.4, 0.3, 0);
|
||||
};
|
||||
if (lifetime != generation) return;
|
||||
if (getServer().isPrimaryThread()) delivery.run();
|
||||
else getServer().getScheduler().runTask(this, delivery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
|
||||
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
|
||||
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
|
||||
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
|
||||
TargetPolicy targetPolicy, AbilityEffect effect) {
|
||||
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
|
||||
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
|
||||
EXCEPTIONAL_NONCOMBAT_SELECTION }
|
||||
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
|
||||
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
|
||||
|
||||
public AbilityDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
|
||||
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
|
||||
Objects.requireNonNull(effect);
|
||||
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
|
||||
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|
||||
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|
||||
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|
||||
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
|
||||
throw new IllegalArgumentException("Invalid ability definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityEffect {
|
||||
/** Effect-specific initial eligibility checked before the shared cooldown commit. */
|
||||
default boolean accepts(games.dmg.fantasystick.session.ControlInput input) { return true; }
|
||||
void activate(AbilityExecution execution);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Shared character mutation, cooldown, target, execution, report and cleanup boundary. */
|
||||
public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseable {
|
||||
public record Failure(UUID character, String ability, Throwable cause) { }
|
||||
public record Mutation(CharacterStorage.AbilityMutationStatus status, CharacterRecord record) { }
|
||||
private record Key(UUID character, String ability) { }
|
||||
|
||||
private final AbilityRegistry registry;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AbilityScheduler scheduler;
|
||||
private final Consumer<AbilityReport> reports;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<Key, Object> pending = new HashMap<>();
|
||||
private final Map<Key, Long> generations = new HashMap<>();
|
||||
private final Map<Key, Set<Runtime>> active = new HashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Ability engine is already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> purchase(ControlInput input, String ability, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
var definition = registry.find(ability).orElse(null);
|
||||
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.unlockAbility(input.session().character(), expectedRevision, ability,
|
||||
definition.requirements(), definition.pointCost()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> assign(ControlInput input, String ability, int slot, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
|
||||
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutation(input, ability, expectedRevision,
|
||||
() -> characters.unlearnAbility(input.session().character(), expectedRevision, ability))
|
||||
.thenApply(mutation -> {
|
||||
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
|
||||
remove(input.session().character(), ability);
|
||||
}
|
||||
return mutation;
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
CompletableFuture<CharacterStorage.AbilityMutation> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return result; }
|
||||
work.whenComplete((mutation, failure) -> schedule(input.session().character(), ability, result, () -> {
|
||||
if (failure != null) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return; }
|
||||
var record = mutation.record().orElse(null);
|
||||
if (mutation.status() == CharacterStorage.AbilityMutationStatus.APPLIED) {
|
||||
try {
|
||||
if (record == null || !input.stillCurrent()
|
||||
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record)) {
|
||||
result.complete(new Mutation(CharacterStorage.AbilityMutationStatus.STALE, record)); return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input.session().character(), ability, nativeFailure);
|
||||
result.completeExceptionally(nativeFailure); return;
|
||||
}
|
||||
}
|
||||
result.complete(new Mutation(mutation.status(), record));
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || input.kind() != ControlInput.Kind.ABILITY || !input.stillCurrent()) return;
|
||||
var definition = registry.find(input.ability()).orElse(null);
|
||||
if (definition == null || definition.gesture() != input.gesture()) {
|
||||
input.player().sendMessage("That ability is unavailable."); return;
|
||||
}
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null || !record.learned().contains(definition.id())
|
||||
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
|
||||
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
Instant existing = record.cooldowns().get(definition.id());
|
||||
if (existing != null && now.isBefore(existing)) {
|
||||
input.player().sendMessage("" + definition.name() + " is ready in "
|
||||
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
|
||||
}
|
||||
boolean accepted;
|
||||
try { accepted = eligible(definition.targetPolicy(), input, input.target()) && definition.effect().accepts(input); }
|
||||
catch (RuntimeException invalid) {
|
||||
report(input.session().character(), definition.id(), invalid);
|
||||
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
|
||||
}
|
||||
if (!accepted) {
|
||||
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
|
||||
}
|
||||
var key = new Key(input.session().character(), definition.id());
|
||||
Object token = new Object();
|
||||
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
|
||||
long generation = generations.getOrDefault(key, 0L);
|
||||
Instant readyAt;
|
||||
try { readyAt = now.plus(definition.cooldown()); }
|
||||
catch (DateTimeException | ArithmeticException invalid) {
|
||||
pending.remove(key, token); report(key.character(), key.ability(), invalid);
|
||||
input.player().sendMessage("That ability's cooldown is invalid."); return;
|
||||
}
|
||||
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
|
||||
.whenComplete((mutation, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
|
||||
key, token, generation, mutation, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(key, token); report(key.character(), key.ability(), rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
|
||||
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
|
||||
if (!pending.remove(key, token)) return;
|
||||
if (closed) return;
|
||||
if (failure != null) {
|
||||
report(key.character(), key.ability(), failure);
|
||||
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
|
||||
}
|
||||
boolean unchangedDeadline = mutation.status() == CharacterStorage.AbilityMutationStatus.UNCHANGED
|
||||
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
|
||||
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|
||||
|| mutation.record().isEmpty()) {
|
||||
input.player().sendMessage(switch (mutation.status()) {
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case STALE -> "That character changed; retry the cast.";
|
||||
case NOT_LEARNED, UNAVAILABLE -> "That ability is no longer available to this character.";
|
||||
default -> "The cast was refused without applying an effect.";
|
||||
});
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
|
||||
if (!synchronizedProgress) {
|
||||
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
|
||||
}
|
||||
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|
||||
|| !eligible(definition.targetPolicy(), input, input.target())) {
|
||||
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
|
||||
}
|
||||
var runtime = new Runtime(key, input, definition, generation);
|
||||
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
|
||||
try {
|
||||
definition.effect().activate(runtime);
|
||||
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
|
||||
} catch (RuntimeException effectFailure) {
|
||||
runtime.close(); report(key.character(), key.ability(), effectFailure);
|
||||
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Future refund/removal paths invalidate pending callbacks and close only this ability's owned work. */
|
||||
public void remove(UUID character, String ability) {
|
||||
var key = new Key(Objects.requireNonNull(character), Objects.requireNonNull(ability));
|
||||
generations.put(key, generations.getOrDefault(key, 0L) + 1);
|
||||
pending.remove(key);
|
||||
for (var runtime : List.copyOf(active.getOrDefault(key, Set.of()))) runtime.close();
|
||||
}
|
||||
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
if (!runtime.stillCurrent()) runtime.close();
|
||||
if (++checked == 64) break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
|
||||
return switch (policy) {
|
||||
case SELF -> target == null || target == input.player();
|
||||
case HOSTILE -> hostile(input, target);
|
||||
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
|
||||
case ALLY -> target instanceof Player player && fantasy(player);
|
||||
case PARTY -> target instanceof Player player
|
||||
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
|
||||
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
|
||||
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
|
||||
case EXCEPTIONAL_NONCOMBAT_SELECTION -> target == null || target instanceof Player && target != input.player();
|
||||
};
|
||||
}
|
||||
|
||||
private boolean hostile(ControlInput input, Entity target) {
|
||||
return target instanceof LivingEntity && target != input.player()
|
||||
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
|
||||
}
|
||||
|
||||
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
|
||||
}
|
||||
|
||||
private <T> void schedule(UUID character, String ability, CompletableFuture<T> result, Runnable action) {
|
||||
try { nextTick.accept(action); }
|
||||
catch (RuntimeException rejected) {
|
||||
report(character, ability, rejected);
|
||||
if (result != null) result.completeExceptionally(rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void report(UUID character, String ability, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, ability, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never authorize or replay an effect. */ }
|
||||
}
|
||||
|
||||
private final class Runtime implements AbilityExecution {
|
||||
private final Key key;
|
||||
private final ControlInput input;
|
||||
private final AbilityDefinition definition;
|
||||
private final long generation;
|
||||
private final Set<AutoCloseable> owned = new HashSet<>();
|
||||
private boolean finished;
|
||||
private int scheduled;
|
||||
|
||||
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
|
||||
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
|
||||
}
|
||||
@Override public AbilityDefinition definition() { return definition; }
|
||||
@Override public ControlInput input() { return input; }
|
||||
@Override public CharacterRecord character() {
|
||||
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
|
||||
}
|
||||
@Override public boolean stillCurrent() {
|
||||
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
|
||||
&& generations.getOrDefault(key, 0L) == generation
|
||||
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
|
||||
}
|
||||
@Override public List<Entity> eligible(Collection<? extends Entity> candidates, int maximum) {
|
||||
Objects.requireNonNull(candidates);
|
||||
if (maximum < 1 || maximum > 64) throw new IllegalArgumentException("Recipient bound must be 1..64");
|
||||
var result = new ArrayList<Entity>(); int inspected = 0;
|
||||
for (var candidate : candidates) {
|
||||
if (++inspected > 256 || result.size() == maximum) break;
|
||||
if (stillCurrent() && AbilityEngine.this.eligible(definition.targetPolicy(), input, candidate)) result.add(candidate);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
@Override public AbilityScheduler.Task later(Duration delay, Runnable action) {
|
||||
Objects.requireNonNull(delay); Objects.requireNonNull(action);
|
||||
if (delay.isNegative() || delay.compareTo(Duration.ofDays(365)) > 0 || ++scheduled > 64) {
|
||||
throw new IllegalArgumentException("Delayed effect is outside the bounded execution contract");
|
||||
}
|
||||
var task = scheduler.later(delay, () -> {
|
||||
if (!stillCurrent() || !AbilityEngine.this.eligible(definition.targetPolicy(), input, input.target())) {
|
||||
close(); return;
|
||||
}
|
||||
try { action.run(); }
|
||||
catch (RuntimeException failure) {
|
||||
close(); AbilityEngine.this.report(key.character(), key.ability(), failure);
|
||||
}
|
||||
});
|
||||
own(task); return task;
|
||||
}
|
||||
@Override public void own(AutoCloseable resource) {
|
||||
Objects.requireNonNull(resource);
|
||||
if (finished || owned.size() >= 64) {
|
||||
try { resource.close(); } catch (Exception ignored) { }
|
||||
if (!finished) throw new IllegalStateException("Ability owns too many resources");
|
||||
return;
|
||||
}
|
||||
owned.add(resource);
|
||||
}
|
||||
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
|
||||
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|
||||
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|
||||
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
|
||||
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
|
||||
}
|
||||
@Override public void finish() { close(); }
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
var resources = List.copyOf(owned); owned.clear();
|
||||
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
var runtimes = active.get(key);
|
||||
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
pending.clear();
|
||||
for (var runtimes : List.copyOf(active.values())) for (var runtime : List.copyOf(runtimes)) runtime.close();
|
||||
active.clear(); generations.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
|
||||
public interface AbilityExecution extends AutoCloseable {
|
||||
AbilityDefinition definition();
|
||||
ControlInput input();
|
||||
games.dmg.fantasystick.persistence.CharacterRecord character();
|
||||
boolean stillCurrent();
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
|
||||
void finish();
|
||||
@Override void close();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Process-local validated definitions; saved unknown IDs remain character data, not executable definitions. */
|
||||
public final class AbilityRegistry {
|
||||
private final ConcurrentHashMap<String, AbilityDefinition> definitions = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(AbilityDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (definitions.putIfAbsent(definition.id(), definition) != null) {
|
||||
throw new IllegalArgumentException("Duplicate ability ID: " + definition.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace one registered definition after an acknowledged runtime configuration save. */
|
||||
public void replace(AbilityDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
definitions.compute(definition.id(), (id, existing) -> {
|
||||
if (existing == null) throw new IllegalArgumentException("Unknown ability ID: " + id);
|
||||
return definition;
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<AbilityDefinition> find(String id) {
|
||||
return id == null ? Optional.empty() : Optional.ofNullable(definitions.get(id));
|
||||
}
|
||||
|
||||
public List<AbilityDefinition> definitions() {
|
||||
return definitions.values().stream().sorted(Comparator.comparing(AbilityDefinition::id)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Evidence of an actual accepted effect; consumers decide rewards separately. */
|
||||
public record AbilityReport(Type type, UUID sourceCharacter, UUID sourceSession, UUID target,
|
||||
BigDecimal amount, Instant occurredAt) {
|
||||
public enum Type { DAMAGE, HEALING, CLEANSING, PREVENTION, BOOST_USED }
|
||||
public AbilityReport {
|
||||
Objects.requireNonNull(type); Objects.requireNonNull(sourceCharacter); Objects.requireNonNull(sourceSession);
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(amount); Objects.requireNonNull(occurredAt);
|
||||
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) {
|
||||
throw new IllegalArgumentException("An effective report requires a positive finite amount");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Server-thread delayed-work boundary; returned tasks cancel only their owned callback. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityScheduler {
|
||||
Task later(Duration delay, Runnable action);
|
||||
|
||||
interface Task extends AutoCloseable {
|
||||
@Override void close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** 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; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
this.execution = execution; this.player = player; this.percent = percent;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
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);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void reduce(EntityDamageByEntityEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof Player player)) return;
|
||||
Grant strongest = null;
|
||||
var owned = grants.get(player.getUniqueId());
|
||||
if (owned == null) return;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
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);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.execution.stillCurrent()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
|
||||
grants.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Snowball;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
|
||||
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final double SPEED_PER_TICK = 1.0;
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Fireball settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final Map<UUID, State> projectiles = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
LivingEntity target;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
projectiles.remove(projectile.getUniqueId(), this);
|
||||
projectile.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
|
||||
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
|
||||
"Use the equipped snowball; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
|
||||
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
|
||||
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(6), intelligence);
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
|
||||
BigInteger delta = intelligence.subtract(BigInteger.TEN);
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|
||||
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
|
||||
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
|
||||
projectile.setVisualFire(true);
|
||||
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
|
||||
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
|
||||
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
|
||||
execution.later(settings.lifetime(), execution::finish);
|
||||
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = projectiles.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target;
|
||||
damaging = state;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null && causing != state.execution.input().player()) return;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
|
||||
projectiles.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Snowball;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Frost Shard's bounded direct projectile damage and owned Slowness adapter. */
|
||||
public final class FrostShard implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime,
|
||||
double speedPerTick, int slownessAmplifier, Duration slownessDuration) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
|
||||
Objects.requireNonNull(slownessDuration);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64
|
||||
|| slownessAmplifier < 0 || slownessAmplifier > 255
|
||||
|| slownessDuration.isZero() || slownessDuration.isNegative()
|
||||
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Frost Shard settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
|
||||
1, 0, Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
this.intelligence = intelligence; this.settings = settings;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true; projectiles.remove(projectile.getUniqueId(), this); projectile.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
private final LivingEntity target;
|
||||
private final PotionEffect previous;
|
||||
private final PotionEffect applied;
|
||||
private final long started = System.nanoTime();
|
||||
private boolean owned;
|
||||
private boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
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;
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> projectiles = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
|
||||
List.of("Launch a bounded frost projectile.",
|
||||
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
|
||||
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
|
||||
"Use the equipped shard; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"lifetime", seconds(current.lifetime()), "projectile-speed", BigDecimal.valueOf(current.speedPerTick()),
|
||||
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
|
||||
"slowness-duration", seconds(current.slownessDuration())),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(4), 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 Frost Shard damage input");
|
||||
BigInteger delta = intelligence.subtract(BigInteger.TEN);
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = settings;
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|
||||
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
|
||||
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
|
||||
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
|
||||
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
|
||||
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = projectiles.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
boolean retainExecution = false;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
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;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
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));
|
||||
if (lease.apply()) {
|
||||
state.execution.own(lease);
|
||||
state.execution.later(current.slownessDuration(), state.execution::finish);
|
||||
retainExecution = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.close();
|
||||
if (!retainExecution) state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
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;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
state.accepted = true;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
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";
|
||||
default -> Integer.toString(amplifier + 1);
|
||||
};
|
||||
}
|
||||
|
||||
private static BigDecimal seconds(Duration duration) {
|
||||
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9))
|
||||
.stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
|
||||
projectiles.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */
|
||||
public final class GreatSword implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration recovery) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery);
|
||||
if (baseDamage.signum() < 0 || recovery.isNegative()
|
||||
|| recovery.compareTo(Duration.ofDays(365)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Great Sword settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;
|
||||
final LivingEntity target;
|
||||
boolean reported;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) {
|
||||
this.execution = execution; this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public GreatSword(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.",
|
||||
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
|
||||
"Attack with the equipped sword; partial charge follows the native attack curve.",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, this);
|
||||
}
|
||||
|
||||
public static BigDecimal damage(BigInteger strength, float charge) {
|
||||
return damage(BigInteger.valueOf(7), strength, charge);
|
||||
}
|
||||
|
||||
static BigDecimal damage(BigInteger base, BigInteger strength, float charge) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(strength);
|
||||
if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) {
|
||||
throw new IllegalArgumentException("Invalid Great Sword damage input");
|
||||
}
|
||||
BigInteger delta = strength.subtract(BigInteger.TEN);
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
BigDecimal full = new BigDecimal(base.add(modifier));
|
||||
BigDecimal nativeCharge = new BigDecimal(Float.toString(charge));
|
||||
BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge)));
|
||||
return full.multiply(curve).stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() instanceof LivingEntity;
|
||||
}
|
||||
|
||||
@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());
|
||||
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : amount.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
throw new IllegalStateException("Great Sword damage is outside the native finite range");
|
||||
}
|
||||
var player = execution.input().player();
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
|
||||
.withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target);
|
||||
damaging = attempt;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
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;
|
||||
attempt.reported = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
|
||||
/** Hex's aimed, owned outgoing combat-damage penalty. */
|
||||
public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
public record Settings(Duration cooldown,double range,Duration duration,BigDecimal reductionPercent){
|
||||
public Settings{Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);Objects.requireNonNull(reductionPercent);
|
||||
if(cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64
|
||||
||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0
|
||||
||reductionPercent.signum()<0||reductionPercent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid Hex settings");}
|
||||
public static Settings defaults(){return new Settings(Duration.ofSeconds(10),12,Duration.ofSeconds(4),BigDecimal.valueOf(20));}}
|
||||
private final class State implements AutoCloseable{final AbilityExecution execution;final LivingEntity target;final AutoCloseable grant;boolean closed;
|
||||
State(AbilityExecution execution,LivingEntity target,AutoCloseable grant){this.execution=execution;this.target=target;this.grant=grant;}
|
||||
@Override public void close(){if(closed)return;closed=true;hexes.remove(target.getUniqueId(),this);try{grant.close();}catch(Exception failure){throw new IllegalStateException(failure);}}}
|
||||
private volatile Settings settings;private final OutgoingDamageMitigation mitigation;private final Map<UUID,State> hexes=new java.util.HashMap<>();private boolean closed;
|
||||
public Hex(Settings settings,OutgoingDamageMitigation mitigation){this.settings=Objects.requireNonNull(settings);this.mitigation=Objects.requireNonNull(mitigation);}
|
||||
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
|
||||
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
|
||||
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
|
||||
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
|
||||
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"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.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);}
|
||||
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();}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
import games.dmg.fantasystick.progression.Stat;import games.dmg.fantasystick.session.ControlInput;import java.math.*;import java.time.Duration;import java.util.*;import org.bukkit.*;import org.bukkit.entity.Player;
|
||||
/** Inspire's self-or-aimed fantasy-player temporary health grant. */
|
||||
public final class Inspire implements AbilityEffect,AutoCloseable{
|
||||
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
|
||||
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
|
||||
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
|
||||
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.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&÷d[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);}
|
||||
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();}
|
||||
@Override public void close(){closed=true;}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.MathContext;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.AbstractArrow;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.ProjectileHitEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Longbow's ammunition-free native draw and bounded custom-arrow damage adapter. */
|
||||
public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final BigInteger MAX_NATIVE_BASE = BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger();
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration recovery, int minimumDrawTicks, int fullDrawTicks,
|
||||
double range, Duration lifetime, double fullSpeedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || baseDamage.compareTo(MAX_NATIVE_BASE) > 0
|
||||
|| recovery.compareTo(Duration.ofSeconds(1)) < 0
|
||||
|| recovery.compareTo(Duration.ofDays(365)) > 0
|
||||
|| minimumDrawTicks < 1 || minimumDrawTicks > 72_000
|
||||
|| fullDrawTicks < minimumDrawTicks || fullDrawTicks > 72_000
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|
||||
|| !Double.isFinite(fullSpeedPerTick) || fullSpeedPerTick < 0.01 || fullSpeedPerTick > 64) {
|
||||
throw new IllegalArgumentException("Invalid Longbow settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(1), 3, 20, 64,
|
||||
Duration.ofSeconds(10), 3);
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Arrow arrow;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
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;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true; arrows.remove(arrow.getUniqueId(), this); arrow.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> arrows = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
public Longbow(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("longbow", "Longbow", Material.BOW,
|
||||
List.of("Draw and release an ammunition-free owned arrow.",
|
||||
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
|
||||
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
|
||||
"full-draw-ticks", BigDecimal.valueOf(current.fullDrawTicks()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"lifetime", seconds(current.lifetime()),
|
||||
"full-projectile-speed", BigDecimal.valueOf(current.fullSpeedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return input.gesture() == ControlInput.Gesture.DRAW_RELEASE && input.heldTicks() >= settings.minimumDrawTicks();
|
||||
}
|
||||
|
||||
public static BigDecimal power(int heldTicks, int fullDrawTicks) {
|
||||
if (heldTicks < 0 || fullDrawTicks < 1) throw new IllegalArgumentException("Invalid Longbow draw");
|
||||
var x = BigDecimal.valueOf(heldTicks).divide(BigDecimal.valueOf(fullDrawTicks), MathContext.DECIMAL128);
|
||||
var power = x.multiply(x, MathContext.DECIMAL128).add(x.multiply(BigDecimal.TWO), MathContext.DECIMAL128)
|
||||
.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128);
|
||||
return power.min(BigDecimal.ONE);
|
||||
}
|
||||
|
||||
public static BigInteger fullDamage(BigInteger dexterity) {
|
||||
return fullDamage(BigInteger.valueOf(5), dexterity);
|
||||
}
|
||||
|
||||
static BigInteger fullDamage(BigInteger base, BigInteger dexterity) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(dexterity);
|
||||
if (base.signum() < 0 || dexterity.signum() < 0) throw new IllegalArgumentException("Invalid Longbow damage input");
|
||||
BigInteger delta = dexterity.subtract(BigInteger.TEN);
|
||||
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
Settings shot = from(execution.definition());
|
||||
BigDecimal power = power(execution.input().heldTicks(), shot.fullDrawTicks());
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|
||||
|| direction.lengthSquared() == 0) throw new IllegalStateException("Longbow direction is unavailable");
|
||||
double speed = BigDecimal.valueOf(shot.fullSpeedPerTick()).multiply(power, MathContext.DECIMAL128).doubleValue();
|
||||
var arrow = player.launchProjectile(Arrow.class, direction.normalize().multiply(speed));
|
||||
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
arrow.setDamage(0); arrow.setKnockbackStrength(0); arrow.setPierceLevel(0); arrow.setCritical(false);
|
||||
var damage = new BigDecimal(fullDamage(shot.baseDamage(), execution.character().stats().get(Stat.DEX)))
|
||||
.multiply(power, MathContext.DECIMAL128);
|
||||
var state = new State(arrow, execution, origin, shot, damage);
|
||||
if (arrows.putIfAbsent(arrow.getUniqueId(), state) != null) {
|
||||
arrow.remove(); throw new IllegalStateException("Duplicate Longbow arrow identity");
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void containNativeDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Arrow arrow)) return;
|
||||
var state = arrows.get(arrow.getUniqueId());
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) 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;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
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;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
state.accepted = true;
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
p.get("minimum-draw-ticks").intValueExact(), p.get("full-draw-ticks").intValueExact(),
|
||||
p.get("range").doubleValue(), duration(p.get("lifetime")),
|
||||
p.get("full-projectile-speed").doubleValue());
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
private static BigDecimal seconds(Duration duration) {
|
||||
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(arrows.values())) state.execution.finish();
|
||||
arrows.clear(); damaging = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
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.projectiles.ProjectileSource;
|
||||
|
||||
/** Internal pre-recipient strongest outgoing-damage reduction stage. */
|
||||
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) {
|
||||
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
|
||||
}
|
||||
@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) { }
|
||||
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;}
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
if(closed)return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
|
||||
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
|
||||
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
|
||||
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));
|
||||
}
|
||||
|
||||
@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 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));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){
|
||||
Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;
|
||||
if(event.getDamager() instanceof Projectile projectile){ProjectileSource shooter=projectile.getShooter();if(shooter instanceof Entity entity)return entity;}
|
||||
return event.getDamager();
|
||||
}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var owned:List.copyOf(grants.values()))for(var grant:List.copyOf(owned))grant.close();grants.clear();pending.clear();}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
|
||||
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double distance, double speedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
|
||||
throw new IllegalArgumentException("Invalid Quickstep settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player player;
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
double magnitude = horizontalCurrent.length();
|
||||
if (magnitude < 1.0e-6) return true;
|
||||
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
|
||||
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
player.setVelocity(new Vector(0, current.getY(), 0));
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Consumer<PlayerVelocityEvent> velocityEvents;
|
||||
private final Map<UUID, State> dashes = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
|
||||
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
|
||||
List.of("Dash forward with native horizontal movement.",
|
||||
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
|
||||
"Use while grounded, on foot, and not flying or gliding.",
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
|
||||
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var player = input.player();
|
||||
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
|
||||
return finite(direction) && direction.lengthSquared() > 1.0e-12;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); 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);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
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);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void moved(PlayerMoveEvent event) {
|
||||
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; }
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); 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();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
|
||||
definition.parameters().get("speed").doubleValue());
|
||||
}
|
||||
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var state : List.copyOf(dashes.values())) state.execution.finish();
|
||||
dashes.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Revive target selection, contained popup and proof-based Tail conversion. */
|
||||
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
public interface PopupFactory { Inventory create(int size, Component title); }
|
||||
private static final Duration DEATH_AGE = Duration.ofHours(24);
|
||||
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
|
||||
private static final double RANGE = 12;
|
||||
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
|
||||
private record Choice(Candidate candidate, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final RevivalStore reservations;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final TailBooks tails = new TailBooks();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
|
||||
private final Map<UUID, View> targets = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player target;
|
||||
final Inventory inventory;
|
||||
final Map<Integer, Choice> choices;
|
||||
boolean selecting;
|
||||
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;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
|
||||
inventory.clear();
|
||||
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
|
||||
PopupFactory popups, Consumer<Throwable> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
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."),
|
||||
"Use the equipped shard while aiming at another player within 12 blocks.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
|
||||
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
|
||||
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
|
||||
"death-age", BigDecimal.valueOf(86400)),
|
||||
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Player target = target(execution.input());
|
||||
if (target == null) {
|
||||
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
var captured = candidates(target);
|
||||
if (captured.isEmpty()) {
|
||||
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; }
|
||||
var eligible = new ArrayList<Choice>();
|
||||
Instant now = clock.instant();
|
||||
for (int index = 0; index < captured.size(); index++) {
|
||||
var found = loads.get(index).join();
|
||||
if (found.isEmpty()) continue;
|
||||
var record = found.orElseThrow();
|
||||
var candidate = captured.get(index);
|
||||
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.");
|
||||
execution.finish(); return;
|
||||
}
|
||||
open(execution, target, eligible, now);
|
||||
}));
|
||||
}
|
||||
|
||||
private Player target(ControlInput input) {
|
||||
Player caster = input.player();
|
||||
Entity supplied = input.target();
|
||||
Player selected = supplied instanceof Player player ? player : null;
|
||||
if (selected == null) {
|
||||
Location eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
|
||||
entity -> entity instanceof Player && entity != caster);
|
||||
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
|
||||
}
|
||||
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|
||||
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|
||||
|| !caster.hasLineOfSight(selected)) return null;
|
||||
return selected;
|
||||
}
|
||||
|
||||
private List<Candidate> candidates(Player target) {
|
||||
var result = new ArrayList<Candidate>();
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) continue;
|
||||
var identity = tails.identify(item);
|
||||
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
|
||||
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
|
||||
Duration age = Duration.between(record.diedAt(), now);
|
||||
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
|
||||
}
|
||||
|
||||
private boolean unchanged(Player target, Candidate candidate) {
|
||||
ItemStack current = target.getInventory().getItem(candidate.slot());
|
||||
return current != null && current.equals(candidate.captured());
|
||||
}
|
||||
|
||||
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
|
||||
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"));
|
||||
var choices = new LinkedHashMap<Integer, Choice>();
|
||||
for (int index = 0; index < eligible.size() && index < size; index++) {
|
||||
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
|
||||
var meta = display.getItemMeta();
|
||||
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
|
||||
meta.lore(List.of(Component.text("Level " + choice.record().level()),
|
||||
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));
|
||||
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
|
||||
target.openInventory(inventory);
|
||||
execution.later(VIEW_TIME, execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view == null) return;
|
||||
event.setCancelled(true);
|
||||
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
|
||||
Choice choice = view.choices.get(event.getRawSlot());
|
||||
if (choice == null || view.selecting) return;
|
||||
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
|
||||
view.inventory.clear(); view.target.closeInventory();
|
||||
confirm(view, choice);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
var view = inventories.get(event.getView().getTopInventory());
|
||||
if (view != null && !view.selecting) view.execution.finish();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
|
||||
for (var view : List.copyOf(inventories.values())) {
|
||||
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
|
||||
|
||||
private void confirm(View view, Choice choice) {
|
||||
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
|
||||
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 (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.execution.finish(); return;
|
||||
}
|
||||
reserve(view, choice.candidate(), found.orElseThrow());
|
||||
}));
|
||||
}
|
||||
|
||||
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
|
||||
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.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 (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.execution.finish(); return;
|
||||
}
|
||||
deliver(view, dead, reservation);
|
||||
}));
|
||||
}
|
||||
|
||||
private int emptyDirectSlot(Player target, int source) {
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40 || slot == source) continue;
|
||||
ItemStack item = target.getInventory().getItem(slot);
|
||||
if (item == null || item.isEmpty()) return slot;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
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.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
else {
|
||||
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
|
||||
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
|
||||
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
|
||||
}
|
||||
view.target.updateInventory();
|
||||
} catch (RuntimeException 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.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.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.");
|
||||
view.execution.finish();
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean installFinalSheet(Player target, CharacterRecord record) {
|
||||
int slot = uniqueSheetSlot(target, record.id());
|
||||
if (slot < 0) return false;
|
||||
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
|
||||
}
|
||||
|
||||
private int uniqueSheetSlot(Player target, UUID character) {
|
||||
int found = -1;
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
|
||||
if (found >= 0) return -2;
|
||||
found = slot;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public void reconcile(Player target) {
|
||||
if (closed || !target.isOnline()) return;
|
||||
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
|
||||
if (loadFailure != null) { report(loadFailure); return; }
|
||||
if (closed || !target.isOnline() || record.isEmpty()) return;
|
||||
reconcile(target, reservation, record.orElseThrow());
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
|
||||
int foundSheet = uniqueSheetSlot(target, reservation.character());
|
||||
boolean hasSheet = foundSheet >= 0;
|
||||
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
|
||||
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
|
||||
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
|
||||
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
|
||||
: 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;
|
||||
}
|
||||
if (record.dead() && unchangedTail && !hasSheet) {
|
||||
reservations.clear(reservation.character(), reservation.id()); return;
|
||||
}
|
||||
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
|
||||
if (unchangedTail && reservation.sourceAmount() > 1) {
|
||||
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
|
||||
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
(reservation.administrative()
|
||||
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit)
|
||||
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit))
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Runnable action) {
|
||||
try { nextTick.accept(action); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var view : List.copyOf(inventories.values())) view.execution.finish();
|
||||
inventories.clear(); targets.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Second Wind's validated persist-before-publication self-heal. */
|
||||
public final class SecondWind implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
|
||||
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Second Wind settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
|
||||
}
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private volatile Settings settings;
|
||||
|
||||
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
|
||||
List.of("Restore missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
|
||||
"Use while injured; full health refuses without spending cooldown.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing())),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
double health = input.player().getHealth();
|
||||
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
return attribute != null && Double.isFinite(health) && health >= 0
|
||||
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
|
||||
}
|
||||
|
||||
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
|
||||
static BigInteger healing(BigInteger base, BigInteger wisdom) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
|
||||
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
|
||||
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var definition = execution.definition();
|
||||
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; }
|
||||
double current = player.getHealth(); double maximum = attribute.getValue();
|
||||
double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { 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; }
|
||||
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; }
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
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)) {
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
|
||||
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
|
||||
double horizontalKnockback, double minimumVerticalKnockback) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64
|
||||
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|
||||
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|
||||
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final Consumer<PlayerVelocityEvent> velocityEvents;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
|
||||
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
|
||||
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
|
||||
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
|
||||
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
|
||||
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
|
||||
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
|
||||
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
|
||||
static BigInteger damage(BigInteger base, BigInteger strength) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(strength);
|
||||
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
|
||||
var delta = strength.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 LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { 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; }
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { target.damage(amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
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;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
}
|
||||
|
||||
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 static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
try {
|
||||
Location source = player.getLocation(); Location destination = target.getLocation();
|
||||
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
|
||||
&& player.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean finite(Vector value) {
|
||||
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
|
||||
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
|
||||
}
|
||||
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
|
||||
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
|
||||
public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid Stone Skin settings");
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
|
||||
State(AbilityExecution execution, Player player, AutoCloseable grant) {
|
||||
this.execution = execution; this.player = player; this.grant = grant;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
|
||||
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CombatMitigation mitigation;
|
||||
private final Map<UUID, State> protections = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public StoneSkin(Settings settings, CombatMitigation mitigation) {
|
||||
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
|
||||
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
|
||||
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
|
||||
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
|
||||
}
|
||||
|
||||
@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 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);
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(),
|
||||
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
definition.parameters().get("damage-reduction-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
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));}}
|
||||
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;
|
||||
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();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
|
||||
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
|
||||
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,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;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,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
|
||||
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)&¤t>=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();}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/** Plain native book pages, conservatively bounded for the vanilla font (no styled/bold text). */
|
||||
public final class BookPagination {
|
||||
private BookPagination() { }
|
||||
|
||||
public static List<Component> pages(String text) {
|
||||
var lines = new ArrayList<String>();
|
||||
var line = new StringBuilder();
|
||||
int width = 0;
|
||||
for (int offset = 0; offset < text.length();) {
|
||||
int codePoint = text.codePointAt(offset);
|
||||
offset += Character.charCount(codePoint);
|
||||
if (codePoint == '\n') {
|
||||
lines.add(line.toString()); line.setLength(0); width = 0;
|
||||
continue;
|
||||
}
|
||||
// ASCII is at most six pixels; vanilla's Unicode fallback is at most nine.
|
||||
// Leave six pixels of horizontal slack and one line of vertical slack.
|
||||
int advance = codePoint < 128 ? 6 : 9;
|
||||
if (width + advance > 108) {
|
||||
lines.add(line.toString()); line.setLength(0); width = 0;
|
||||
}
|
||||
line.appendCodePoint(codePoint); width += advance;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.NativeHealthLimit;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
|
||||
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules rules;
|
||||
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
|
||||
private final PartyBookControls partyControls;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this(characters, rules, sessions, nextTick, failures, parties, server, names,
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
this.abilityEngine = abilityEngine;
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
partyControls.forget(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
if (closed || pending.get(owner) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !pending.remove(owner, ticket) || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Character book could not be loaded; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
input.player().sendMessage("This character is no longer available.");
|
||||
return;
|
||||
}
|
||||
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
|
||||
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(owner, ticket);
|
||||
report(input, rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var all = BookPagination.pages(first + "\n" + second);
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
|
||||
statViews.put(input.session().player(), statView);
|
||||
Component purchases = Component.text("Spend 1 point\n\n");
|
||||
for (var stat : Stat.values()) {
|
||||
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
var definitions = abilities.definitions();
|
||||
if (!definitions.isEmpty() && end == all.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
abilityViews.put(input.session().player(), abilityView);
|
||||
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
|
||||
}
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
|
||||
pages.add(navigation);
|
||||
}
|
||||
pages.addAll(partyControls.pages(input));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static java.util.List<Component> abilityPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
|
||||
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
boolean learned = record.learned().contains(definition.id());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
|
||||
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
|
||||
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
String text = definition.name() + "\nIcon: " + definition.icon().name()
|
||||
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
|
||||
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
|
||||
+ "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state;
|
||||
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
|
||||
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
|
||||
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
|
||||
} else if (learned) {
|
||||
Component action = Component.text("Assign ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
}
|
||||
action = action.append(Component.text("\n\nRefund: "))
|
||||
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
|
||||
pages.add(action);
|
||||
}
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static String durationSeconds(java.time.Duration duration) {
|
||||
return java.math.BigDecimal.valueOf(duration.getSeconds())
|
||||
.add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
|
||||
String ability, Integer slot) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
}
|
||||
|
||||
private static Component pageButton(String label, View view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = views.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
// Reread authoritative data, rather than retaining a stale giant character snapshot.
|
||||
load(view.input(), offset);
|
||||
}
|
||||
|
||||
private void report(ControlInput input, Throwable failure) {
|
||||
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !STATS.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = statViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player || event.getTag() == null) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 80) return;
|
||||
var match = STAT_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))
|
||||
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
var stat = Stat.valueOf(match.group(2));
|
||||
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
|
||||
var nativeLimit = NativeHealthLimit.read();
|
||||
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
|
||||
.whenComplete((purchase, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
|
||||
} catch (RuntimeException rejected) { report(view.input(), rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
|
||||
var input = view.input();
|
||||
if (closed || !input.stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(input, failure);
|
||||
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
|
||||
return;
|
||||
}
|
||||
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
|
||||
var saved = purchase.record().orElseThrow();
|
||||
try {
|
||||
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
|
||||
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException nativeFailure) {
|
||||
report(input, nativeFailure);
|
||||
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
|
||||
return;
|
||||
}
|
||||
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
|
||||
load(input, 0);
|
||||
return;
|
||||
}
|
||||
String message = switch (purchase.status()) {
|
||||
case STALE -> "That character page is stale; reopen it before spending points.";
|
||||
case NO_POINTS -> "This character has no unspent points.";
|
||||
case UNAVAILABLE -> "This character is no longer available.";
|
||||
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
|
||||
};
|
||||
input.player().sendMessage(message);
|
||||
load(input, 0);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = abilityViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| assign != (match.group(3) != null)
|
||||
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String ability = match.group(2);
|
||||
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
|
||||
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
|
||||
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case STALE -> "That character page is stale; reopen it before changing abilities.";
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case UNAVAILABLE -> "That character or ability is unavailable.";
|
||||
};
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
|
||||
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package games.dmg.fantasystick.book;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import io.papermc.paper.connection.PlayerGameConnection;
|
||||
import io.papermc.paper.event.player.PlayerCustomClickEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.nbt.api.BinaryTagHolder;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
|
||||
/** One single-use view per player; client payloads select server-owned actions, never arbitrary UUIDs/commands. */
|
||||
public final class PartyBookControls implements AutoCloseable {
|
||||
private static final Key ACTION = Key.key("fantasystick", "party_book");
|
||||
private static final Pattern PAYLOAD = Pattern.compile("\"([a-f0-9-]{36}):([0-9]{1,3})\"");
|
||||
private static final int PAGE_SIZE = 8;
|
||||
private enum Menu { ROOT, PLAYERS, INVITATIONS }
|
||||
private record View(String nonce, ControlInput input, List<Runnable> actions) {
|
||||
private View { actions = List.copyOf(actions); }
|
||||
}
|
||||
private final Map<UUID, View> views = new HashMap<>();
|
||||
private final PartyRoster parties;
|
||||
private final Server server;
|
||||
private final Function<UUID, Component> names;
|
||||
private final Consumer<ControlInput> refresh;
|
||||
private final BiConsumer<ControlInput, Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PartyBookControls(PartyRoster parties, Server server, Function<UUID, Component> names,
|
||||
Consumer<ControlInput> refresh, BiConsumer<ControlInput, Throwable> failures) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
this.refresh = Objects.requireNonNull(refresh);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
var owner = input.session().player();
|
||||
var party = parties.partyOf(owner);
|
||||
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
|
||||
if (menu == Menu.ROOT) {
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
|
||||
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
|
||||
build.pages.add(summary);
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
boolean leader = group.leader().equals(owner);
|
||||
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
|
||||
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
|
||||
if (leader) {
|
||||
summary = summary.append(Component.newline())
|
||||
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
|
||||
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
|
||||
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
|
||||
}
|
||||
build.pages.add(summary);
|
||||
for (var member : group.members()) {
|
||||
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
|
||||
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
|
||||
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
|
||||
build.pages.add(row);
|
||||
}
|
||||
}
|
||||
} else if (menu == Menu.PLAYERS) {
|
||||
var group = party.orElseThrow();
|
||||
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
|
||||
.map(player -> player.getUniqueId()).filter(id -> parties.partyOf(id).isEmpty())
|
||||
.sorted(Comparator.comparing(UUID::toString)).toList();
|
||||
int start = start(offset, candidates.size());
|
||||
build.navigation("Invite players", Menu.PLAYERS, start, candidates.size());
|
||||
for (int i = start; i < Math.min(candidates.size(), start + PAGE_SIZE); i++) {
|
||||
UUID target = candidates.get(i);
|
||||
build.pages.add(Component.text("Invite\n\n").append(names.apply(target)).append(Component.text("\n\n"))
|
||||
.append(build.button("Invite", () -> mutate(input, () -> {
|
||||
boolean invited = parties.invite(owner, group.id(), target).isPresent();
|
||||
if (invited) {
|
||||
var recipient = server.getPlayer(target);
|
||||
if (recipient != null && recipient.isOnline()) recipient.sendMessage(
|
||||
"Party invitation received. Open your character book to accept within 60 seconds.");
|
||||
}
|
||||
return invited;
|
||||
}))));
|
||||
}
|
||||
} else {
|
||||
var invitations = parties.invitationsFor(owner);
|
||||
int start = start(offset, invitations.size());
|
||||
build.navigation("Invitations", Menu.INVITATIONS, start, invitations.size());
|
||||
for (int i = start; i < Math.min(invitations.size(), start + PAGE_SIZE); i++) {
|
||||
var invitation = invitations.get(i);
|
||||
build.pages.add(Component.text("Invitation from\n\n").append(names.apply(invitation.leader()))
|
||||
.append(Component.text("\n\n")).append(build.button("Accept",
|
||||
() -> mutate(input, () -> parties.accept(owner, invitation.id())))));
|
||||
}
|
||||
}
|
||||
views.put(owner, new View(build.nonce, input, build.actions));
|
||||
return List.copyOf(build.pages);
|
||||
}
|
||||
|
||||
private static int start(int requested, int size) {
|
||||
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
|
||||
}
|
||||
|
||||
private final class Pages {
|
||||
final ControlInput input;
|
||||
final String nonce = UUID.randomUUID().toString();
|
||||
final List<Component> pages = new ArrayList<>();
|
||||
final List<Runnable> actions = new ArrayList<>();
|
||||
Pages(ControlInput input) { this.input = input; }
|
||||
Component button(String label, Runnable action) {
|
||||
int index = actions.size();
|
||||
actions.add(action);
|
||||
return Component.text("[" + label + "]", NamedTextColor.DARK_BLUE).clickEvent(ClickEvent.custom(ACTION,
|
||||
BinaryTagHolder.binaryTagHolder("\"" + nonce + ":" + index + "\"")));
|
||||
}
|
||||
void navigation(String title, Menu menu, int start, int count) {
|
||||
Component page = Component.text(title + "\n\n" + (count == 0 ? "None available\n\n" : ""))
|
||||
.append(button("Character book", () -> refresh.accept(input)));
|
||||
if (start > 0) page = page.append(Component.newline()).append(button("Previous", () -> show(input, menu, start - PAGE_SIZE)));
|
||||
if (start + PAGE_SIZE < count) page = page.append(Component.newline()).append(button("Next", () -> show(input, menu, start + PAGE_SIZE)));
|
||||
pages.add(page);
|
||||
}
|
||||
}
|
||||
|
||||
private void show(ControlInput input, Menu menu, int offset) {
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Party"); meta.setAuthor("Fantasy Stick");
|
||||
meta.pages(render(input, menu, offset));
|
||||
item.setItemMeta(meta);
|
||||
input.player().openBook(item);
|
||||
}
|
||||
|
||||
public void click(PlayerCustomClickEvent event) {
|
||||
if (closed || !ACTION.equals(event.getIdentifier()) || !(event.getCommonConnection() instanceof PlayerGameConnection connection)) return;
|
||||
var player = connection.getPlayer();
|
||||
var view = views.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
var tag = event.getTag();
|
||||
if (tag == null) return;
|
||||
String raw = tag.string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int index = Integer.parseInt(match.group(2));
|
||||
if (index >= view.actions().size()) return;
|
||||
views.remove(player.getUniqueId());
|
||||
if (!view.input().stillCurrent()) return;
|
||||
try { view.actions().get(index).run(); }
|
||||
catch (RuntimeException failure) { forget(player.getUniqueId()); failures.accept(view.input(), failure); }
|
||||
}
|
||||
|
||||
private void mutate(ControlInput input, BooleanSupplier operation) {
|
||||
if (!operation.getAsBoolean()) input.player().sendMessage("Party action unavailable: membership, authority or invitation changed.");
|
||||
refresh.accept(input);
|
||||
}
|
||||
public void forget(UUID player) { views.remove(player); }
|
||||
@Override public void close() { closed = true; views.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
|
||||
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
|
||||
private static final class Pending {
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|
||||
|| input.gesture() != ControlInput.Gesture.ATTACK
|
||||
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
|
||||
var record = sessions.currentRecord(input.session()).orElse(null);
|
||||
if (record == null) return;
|
||||
Instant now = clock.instant();
|
||||
Instant ready = readyAt.get(input.session().character());
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
readyAt.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent;
|
||||
|
||||
/** Observes the native accepted-take boundary; does not replace vanilla movement, XP cost or anvil damage. */
|
||||
public final class CharacterAnvilNames implements Listener {
|
||||
public record Failure(UUID player, UUID character, Throwable cause) { }
|
||||
private final CharacterStorage storage;
|
||||
private final Consumer<Failure> failures;
|
||||
private final org.bukkit.inventory.ItemFactory items;
|
||||
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
|
||||
|
||||
/** Diagnostics may run on the persistence worker and must not access native player state. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
|
||||
this(storage, failures, items, ignored -> { });
|
||||
}
|
||||
|
||||
/** Acknowledgments run after file commit, possibly on the worker; never access Bukkit in that callback. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items,
|
||||
Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged) {
|
||||
this.items = java.util.Objects.requireNonNull(items);
|
||||
this.storage = java.util.Objects.requireNonNull(storage);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
this.acknowledged = java.util.Objects.requireNonNull(acknowledged);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onTaken(AnvilTakeResultEvent event) {
|
||||
var sheets = new CharacterSheets();
|
||||
var result = event.getResult();
|
||||
var id = sheets.identify(result);
|
||||
if (id.isEmpty()) return;
|
||||
var input = event.getInventory().getItem(0);
|
||||
if (!id.equals(sheets.identify(input))) return;
|
||||
String name = name(result);
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = id.orElseThrow();
|
||||
storage.renameCharacter(character, name).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
|
||||
try { acknowledged.accept(renamed.orElseThrow()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
});
|
||||
}
|
||||
|
||||
private String name(ItemStack sheet) {
|
||||
var custom = sheet.getItemMeta().customName();
|
||||
return custom == null ? items.getI18NDisplayName(sheet)
|
||||
: net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText().serialize(custom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.CharacterProgress;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
/** Allocation only; invoke for an accepted craft, never for recipe previews or activation. */
|
||||
public final class CharacterFactory {
|
||||
private static final String[] GIVEN_NAMES = {"Ari", "Bryn", "Elin", "Finn", "Iris", "Kael", "Mira", "Neris",
|
||||
"Orin", "Rhea", "Soren", "Tala", "Vera", "Wren", "Yara", "Zev"};
|
||||
private static final String[] FAMILY_NAMES = {"Ash", "Birch", "Brook", "Dawn", "Ember", "Fern", "Flint", "Glen",
|
||||
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
|
||||
private final Supplier<UUID> identities;
|
||||
private final RandomGenerator names;
|
||||
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
}
|
||||
|
||||
public synchronized CharacterRecord create(ProgressionRules rules) {
|
||||
Objects.requireNonNull(rules);
|
||||
var initial = new CharacterProgress(identities.get(), rules).snapshot();
|
||||
String name = GIVEN_NAMES[names.nextInt(GIVEN_NAMES.length)] + " "
|
||||
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
|
||||
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
|
||||
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
|
||||
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
|
||||
/**
|
||||
* Fixed-destination adapter for the pinned Purpur runtime, not a listener or a portable transaction API.
|
||||
* Call on the server thread after event containment/lifecycle authorization. Supply the player's actual
|
||||
* current open view, never a retained event's view. Close/reopen generations remain the caller's duty.
|
||||
* World drops are deliberately unsupported here. The caller synchronizes the client after completion.
|
||||
*/
|
||||
public final class ManualCraftDelivery {
|
||||
public enum Result { DELIVERED, STALE, UNAVAILABLE, UNSUPPORTED_ROUTE }
|
||||
|
||||
private ManualCraftDelivery() { }
|
||||
|
||||
public static Result commit(ManualCraftPlan plan, CharacterStorage.CraftBatch batch, InventoryView current) {
|
||||
if (plan.route() == ManualCraftPlan.Route.DROP) return Result.UNSUPPORTED_ROUTE;
|
||||
if (!plan.matches(current)) return Result.STALE;
|
||||
var records = batch.claimForDelivery(plan.quantity());
|
||||
if (records.isEmpty()) return Result.UNAVAILABLE;
|
||||
var sheets = new CharacterSheets();
|
||||
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
|
||||
var grid = (CraftingInventory) current.getTopInventory();
|
||||
var inputs = grid.getMatrix();
|
||||
// Purpur 2618: these are native mirrors; count changes do not call slotsChanged.
|
||||
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - plan.quantity());
|
||||
if (plan.route() == ManualCraftPlan.Route.CURSOR) current.setCursor(outputs.getFirst());
|
||||
else for (int index = 0; index < outputs.size(); index++) {
|
||||
current.getBottomInventory().setItem(plan.inventorySlots().get(index), outputs.get(index));
|
||||
}
|
||||
// One adapter-triggered recomputation after ALL debits and placements, including exhausted cells.
|
||||
// Do not make further planned inventory writes after this synchronous callback boundary.
|
||||
grid.setItem(1, inputs[0]);
|
||||
return Result.DELIVERED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
|
||||
/** Server-thread planning only: does not save, mint, cancel, consume or deliver anything. */
|
||||
public final class ManualCraftPlan {
|
||||
public enum Route { CURSOR, INVENTORY, DROP }
|
||||
private final ItemStack[] matrix;
|
||||
private final InventoryView view;
|
||||
private final CraftingInventory grid;
|
||||
private final PlayerInventory inventory;
|
||||
private final ItemStack result;
|
||||
private final ItemStack cursor;
|
||||
private final int quantity;
|
||||
private final Route route;
|
||||
private final List<Integer> inventorySlots;
|
||||
|
||||
private ManualCraftPlan(CraftItemEvent event, ItemStack[] matrix, int quantity, Route route, List<Integer> inventorySlots) {
|
||||
this.view = event.getView();
|
||||
this.grid = event.getInventory();
|
||||
this.inventory = (PlayerInventory) view.getBottomInventory();
|
||||
this.result = copy(grid.getResult());
|
||||
this.cursor = copy(view.getCursor());
|
||||
this.matrix = copy(matrix);
|
||||
this.quantity = quantity;
|
||||
this.route = route;
|
||||
this.inventorySlots = List.copyOf(inventorySlots);
|
||||
}
|
||||
|
||||
public static Optional<ManualCraftPlan> capture(CraftItemEvent event) {
|
||||
if (event.isCancelled() || event.getRawSlot() != 0 || event.getSlotType() != InventoryType.SlotType.RESULT
|
||||
|| !isSheetRecipe(event.getRecipe())
|
||||
|| !(event.getView().getTopInventory() instanceof CraftingInventory)
|
||||
|| !isSheetRecipe(event.getInventory().getRecipe())
|
||||
|| !(event.getView().getBottomInventory() instanceof PlayerInventory)
|
||||
|| empty(event.getCurrentItem()) || !event.getRecipe().getResult().equals(event.getCurrentItem())
|
||||
|| !new CharacterSheets().recipe().getResult().equals(event.getCurrentItem())
|
||||
|| !event.getInventory().getRecipe().getResult().equals(event.getCurrentItem())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var matrix = event.getInventory().getMatrix();
|
||||
if (matrix.length != 4 && matrix.length != 9) return Optional.empty();
|
||||
var required = EnumSet.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
|
||||
int limit = Integer.MAX_VALUE;
|
||||
for (var item : matrix) {
|
||||
if (empty(item)) continue;
|
||||
if (!required.remove(item.getType()) || item.getAmount() > item.getMaxStackSize()) return Optional.empty();
|
||||
limit = Math.min(limit, item.getAmount());
|
||||
}
|
||||
if (!required.isEmpty()) return Optional.empty();
|
||||
if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY && event.isShiftClick()) {
|
||||
var slots = new ArrayList<Integer>();
|
||||
// Vanilla reverse result transfer: hotbar 8..0, then main storage 35..9.
|
||||
for (int slot = 8; slot >= 0 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
for (int slot = 35; slot >= 9 && slots.size() < limit; slot--) {
|
||||
if (empty(event.getView().getBottomInventory().getItem(slot))) slots.add(slot);
|
||||
}
|
||||
if (slots.isEmpty()) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, slots.size(), Route.INVENTORY, slots));
|
||||
}
|
||||
if (event.getAction() == InventoryAction.HOTBAR_SWAP) {
|
||||
int slot;
|
||||
if (event.getClick() == ClickType.SWAP_OFFHAND) slot = 40;
|
||||
else if (event.getClick() == ClickType.NUMBER_KEY && event.getHotbarButton() >= 0
|
||||
&& event.getHotbarButton() <= 8) slot = event.getHotbarButton();
|
||||
else return Optional.empty();
|
||||
if (!empty(event.getView().getBottomInventory().getItem(slot))) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.INVENTORY, List.of(slot)));
|
||||
}
|
||||
boolean singleDrop = event.getClick() == ClickType.DROP && event.getAction() == InventoryAction.DROP_ONE_SLOT;
|
||||
boolean bulkDrop = event.getClick() == ClickType.CONTROL_DROP && event.getAction() == InventoryAction.DROP_ALL_SLOT;
|
||||
if ((singleDrop || bulkDrop) && empty(event.getCursor())) {
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, bulkDrop ? limit : 1, Route.DROP, List.of()));
|
||||
}
|
||||
boolean cursorClick = event.getClick() == ClickType.LEFT && event.getAction() == InventoryAction.PICKUP_ALL
|
||||
|| event.getClick() == ClickType.RIGHT && event.getAction() == InventoryAction.PICKUP_HALF;
|
||||
if (!cursorClick || !empty(event.getCursor())) return Optional.empty();
|
||||
return Optional.of(new ManualCraftPlan(event, matrix, 1, Route.CURSOR, List.of()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Content check only, not a commit or session authorization. The caller must also invalidate
|
||||
* tokens on close/reopen, disconnect and disable, including reuse of the same native menu.
|
||||
* Never call this from a worker or use an old view to write a new menu's cursor.
|
||||
*/
|
||||
public boolean matches(InventoryView current) {
|
||||
return matches(current, matrix);
|
||||
}
|
||||
|
||||
private boolean matches(InventoryView current, ItemStack[] expected) {
|
||||
if (current != view || current.getTopInventory() != grid || current.getBottomInventory() != inventory) return false;
|
||||
var recipe = grid.getRecipe();
|
||||
return isSheetRecipe(recipe) && Objects.equals(result, recipe.getResult())
|
||||
&& Arrays.equals(expected, copy(grid.getMatrix())) && Objects.equals(result, copy(grid.getResult()))
|
||||
&& Objects.equals(cursor, copy(current.getCursor()))
|
||||
&& inventorySlots.stream().allMatch(slot -> empty(inventory.getItem(slot)));
|
||||
}
|
||||
|
||||
/** Revalidate the original drop intent after exactly this many completed units, not a new snapshot. */
|
||||
public boolean matchesAfterDrops(InventoryView current, int completed) {
|
||||
return route == Route.DROP && completed >= 0 && completed < quantity
|
||||
&& matches(current, remainingMatrix(completed));
|
||||
}
|
||||
|
||||
public int quantity() { return quantity; }
|
||||
public Route route() { return route; }
|
||||
public List<Integer> inventorySlots() { return inventorySlots; }
|
||||
|
||||
public ItemStack[] remainingMatrix() {
|
||||
return remainingMatrix(quantity);
|
||||
}
|
||||
|
||||
private ItemStack[] remainingMatrix(int consumed) {
|
||||
var remaining = copy(matrix);
|
||||
for (int slot = 0; slot < remaining.length; slot++) {
|
||||
var item = remaining[slot];
|
||||
if (item != null) {
|
||||
if (item.getAmount() == consumed) remaining[slot] = null;
|
||||
else item.setAmount(item.getAmount() - consumed);
|
||||
}
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private static boolean isSheetRecipe(Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed && CharacterSheets.RECIPE.equals(keyed.getKey());
|
||||
}
|
||||
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
private static ItemStack copy(ItemStack item) { return empty(item) ? null : item.clone(); }
|
||||
|
||||
private static ItemStack[] copy(ItemStack[] items) {
|
||||
var result = new ItemStack[items.length];
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
result[slot] = copy(items[slot]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import org.bukkit.Keyed;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
|
||||
/**
|
||||
* Owns accepted manual craft lifetimes. Native entrypoints/close run on the server thread.
|
||||
* nextTick must always enqueue onto that thread (never run inline), or throw on rejection.
|
||||
* Diagnostics must be thread-safe, nonthrowing and independent of native player access.
|
||||
* A cancellation observed before this HIGHEST handler is preserved; the event's single boolean cannot
|
||||
* distinguish our containment cancellation from an independent later plugin veto.
|
||||
*/
|
||||
public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
public record Failure(UUID player, List<UUID> characters, String operation, Throwable cause) {
|
||||
public Failure { characters = List.copyOf(characters); }
|
||||
}
|
||||
|
||||
private final CharacterStorage storage;
|
||||
private final Supplier<CharacterRecord> allocate;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<Player, ManualDropDelivery.DropTarget> drops;
|
||||
private final Consumer<Failure> failures;
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
|
||||
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
|
||||
|
||||
public ManualCraftingController(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Function<Player, ManualDropDelivery.DropTarget> drops, Consumer<Failure> failures) {
|
||||
this.storage = java.util.Objects.requireNonNull(storage);
|
||||
this.allocate = java.util.Objects.requireNonNull(allocate);
|
||||
this.nextTick = java.util.Objects.requireNonNull(nextTick);
|
||||
this.drops = java.util.Objects.requireNonNull(drops);
|
||||
this.failures = java.util.Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|
||||
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
java.util.Optional<ManualCraftPlan> plan;
|
||||
try {
|
||||
plan = ManualCraftPlan.capture(event);
|
||||
} catch (RuntimeException failure) {
|
||||
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
|
||||
return;
|
||||
} finally {
|
||||
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
|
||||
event.setCancelled(true);
|
||||
}
|
||||
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|
||||
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
|
||||
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
|
||||
try {
|
||||
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
|
||||
attempt.characters = records.stream().map(CharacterRecord::id).toList();
|
||||
attempt.preparation = storage.prepareCraft(records);
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nextTick.accept(() -> complete(attempt, batch, failure));
|
||||
} catch (RuntimeException rejected) {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
// This branch may run on the persistence worker: diagnostics use captured IDs, never native player access.
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "schedule", rejected));
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
return;
|
||||
}
|
||||
if (!delivering.add(attempt.id)) return;
|
||||
try {
|
||||
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
else if (attempt.player.isOnline()) {
|
||||
attempt.handoffStarted = true;
|
||||
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
|
||||
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
|
||||
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
|
||||
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
|
||||
}
|
||||
} else {
|
||||
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
|
||||
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
|
||||
} finally {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
try {
|
||||
if (attempt.player.isOnline()) attempt.player.updateInventory();
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "sync", unexpected));
|
||||
} finally {
|
||||
delivering.remove(attempt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final UUID id;
|
||||
final Player player;
|
||||
final ManualCraftPlan plan;
|
||||
List<UUID> characters = List.of();
|
||||
CharacterStorage.CraftPreparation preparation;
|
||||
volatile boolean handoffStarted;
|
||||
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
Attempt(UUID id, Player player, ManualCraftPlan plan) {
|
||||
this.id = id;
|
||||
this.player = player;
|
||||
this.plan = plan;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onClose(org.bukkit.event.inventory.InventoryCloseEvent event) { invalidate(event.getPlayer().getUniqueId()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onOpen(org.bukkit.event.inventory.InventoryOpenEvent event) {
|
||||
if (!event.isCancelled()) invalidate(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) { invalidate(event.getPlayer().getUniqueId()); }
|
||||
|
||||
private void invalidate(UUID player) {
|
||||
var attempt = pending.remove(player);
|
||||
if (attempt != null) abandon(attempt);
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.preparation == null || !attempt.cleanupObserved.compareAndSet(false, true)) return;
|
||||
attempt.preparation.abandon().thenAccept(cleanup -> {
|
||||
if (!attempt.handoffStarted && !cleanup.retained().isEmpty()) {
|
||||
failures.accept(new Failure(attempt.id, List.copyOf(cleanup.retained()), "cleanup-retained", null));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var id : pending.keySet()) invalidate(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.CrafterCraftEvent;
|
||||
|
||||
/** Register alongside the completed manual-crafting adapter, before exposing the recipe. */
|
||||
public final class ManualCraftingGuard implements Listener {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onAutomatedCraft(CrafterCraftEvent event) {
|
||||
if (CharacterSheets.RECIPE.equals(event.getRecipe().getKey())) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* Server-thread drop executor. The caller owns vanilla containment and a one-use lifecycle/attempt guard,
|
||||
* including refusing NEW attempts during callbacks against these same still-undebited inputs.
|
||||
* UNCONFIRMED means an output may have escaped without a safely authorized debit; stop/report, never replay.
|
||||
* Public callbacks consume the batch's cleanup proof, including any unattempted suffix. This is not atomic
|
||||
* with arbitrary plugin mutations, native insertion failures or Minecraft/file saves.
|
||||
*/
|
||||
public final class ManualDropDelivery {
|
||||
public enum Status { COMPLETE, CANCELLED, STALE, UNCONFIRMED, UNAVAILABLE, UNSUPPORTED_ROUTE }
|
||||
/** completed counts units with both confirmed insertion and ingredient debit, not every possibly issued sheet. */
|
||||
public record Result(Status status, int completed) { }
|
||||
|
||||
@FunctionalInterface
|
||||
public interface DropTarget {
|
||||
/** COMPLETE means one matching sheet was inserted; callbacks must recheck context before insertion. */
|
||||
Status drop(ItemStack sheet, BooleanSupplier current);
|
||||
}
|
||||
|
||||
private ManualDropDelivery() { }
|
||||
|
||||
public static Result commit(ManualCraftPlan plan, CharacterStorage.CraftBatch batch,
|
||||
Supplier<InventoryView> currentView, BooleanSupplier authorized, DropTarget target) {
|
||||
if (plan.route() != ManualCraftPlan.Route.DROP) return new Result(Status.UNSUPPORTED_ROUTE, 0);
|
||||
BooleanSupplier current = () -> authorized.getAsBoolean() && plan.matchesAfterDrops(currentView.get(), 0);
|
||||
if (!current.getAsBoolean()) return new Result(Status.STALE, 0);
|
||||
var records = batch.claimForDelivery(plan.quantity());
|
||||
if (records.isEmpty()) return new Result(Status.UNAVAILABLE, 0);
|
||||
var sheets = new CharacterSheets();
|
||||
var outputs = records.orElseThrow().stream().map(sheets::create).toList();
|
||||
for (int completed = 0; completed < outputs.size(); completed++) {
|
||||
int expectedCompleted = completed;
|
||||
BooleanSupplier next = () -> authorized.getAsBoolean()
|
||||
&& plan.matchesAfterDrops(currentView.get(), expectedCompleted);
|
||||
if (!next.getAsBoolean()) return new Result(Status.STALE, completed);
|
||||
var status = java.util.Objects.requireNonNull(target.drop(outputs.get(completed), next));
|
||||
if (status != Status.COMPLETE) return new Result(status, completed);
|
||||
// A spawn callback may close/replace the menu after an item has escaped. Never charge a stale snapshot.
|
||||
if (!next.getAsBoolean()) return new Result(Status.UNCONFIRMED, completed);
|
||||
var grid = (CraftingInventory) currentView.get().getTopInventory();
|
||||
var inputs = grid.getMatrix();
|
||||
for (var item : inputs) if (item != null && !item.isEmpty()) item.setAmount(item.getAmount() - 1);
|
||||
grid.setItem(1, inputs[0]);
|
||||
// Revalidate against the original plan's expected remaining matrix after this callback boundary.
|
||||
}
|
||||
return new Result(Status.COMPLETE, outputs.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Item;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* World boundary for saved sheets; no inventory-source drop or cancellation return-to-inventory fallback.
|
||||
* Invoke on the server thread with an existing reference, or after the craft batch claim,
|
||||
* since listeners receive a playable identity.
|
||||
* Native spawnAt supplies ItemSpawnEvent; do not dispatch that event separately or infer success from a wrapper.
|
||||
*/
|
||||
public final class PlayerSheetDrops implements ManualDropDelivery.DropTarget {
|
||||
private final Player player;
|
||||
private final Consumer<PlayerDropItemEvent> events;
|
||||
|
||||
/** events must dispatch synchronously through the server's PluginManager.callEvent in the registered path. */
|
||||
public PlayerSheetDrops(Player player, Consumer<PlayerDropItemEvent> events) {
|
||||
this.player = java.util.Objects.requireNonNull(player);
|
||||
this.events = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ManualDropDelivery.Status drop(ItemStack sheet, BooleanSupplier current) {
|
||||
if (sheet == null || sheet.getAmount() != 1 || new CharacterSheets().identify(sheet).isEmpty()) {
|
||||
throw new IllegalArgumentException("Exactly one identified character sheet is required");
|
||||
}
|
||||
return dropReferences(sheet, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an existing stack of references to the same character, not a batch of newly crafted characters.
|
||||
* COMPLETE confirms this quantity in one entity; the caller must recheck context before source debit.
|
||||
* No character allocation, inventory-source fallback or retry of uncertain exposure is performed.
|
||||
*/
|
||||
public ManualDropDelivery.Status dropReferences(ItemStack sheet, BooleanSupplier current) {
|
||||
if (sheet == null || sheet.getAmount() <= 0 || sheet.getAmount() > sheet.getMaxStackSize()
|
||||
|| new CharacterSheets().identify(sheet).isEmpty()) {
|
||||
throw new IllegalArgumentException("A positive, native-sized stack of identified character references is required");
|
||||
}
|
||||
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
|
||||
var location = player.getEyeLocation().subtract(0, .3, 0);
|
||||
var item = location.getWorld().createEntity(location, Item.class);
|
||||
item.setItemStack(sheet.clone());
|
||||
item.setPickupDelay(40);
|
||||
item.setThrower(player.getUniqueId());
|
||||
var random = ThreadLocalRandom.current();
|
||||
double angle = random.nextDouble() * Math.PI * 2;
|
||||
double spread = random.nextDouble() * .02;
|
||||
var velocity = location.getDirection().multiply(.3);
|
||||
velocity.add(new org.bukkit.util.Vector(Math.cos(angle) * spread,
|
||||
.1 + (random.nextDouble() - random.nextDouble()) * .1, Math.sin(angle) * spread));
|
||||
item.setVelocity(velocity);
|
||||
var event = new PlayerDropItemEvent(player, item);
|
||||
events.accept(event);
|
||||
if (item.isInWorld() || item.isValid() || item.isDead() || !sheet.equals(item.getItemStack())) {
|
||||
return ManualDropDelivery.Status.UNCONFIRMED;
|
||||
}
|
||||
if (event.isCancelled()) return ManualDropDelivery.Status.CANCELLED;
|
||||
if (!current.getAsBoolean()) return ManualDropDelivery.Status.STALE;
|
||||
boolean inserted = item.spawnAt(item.getLocation(), SpawnReason.CUSTOM);
|
||||
return inserted && item.isValid() && item.isInWorld() && sheet.equals(item.getItemStack())
|
||||
? ManualDropDelivery.Status.COMPLETE : ManualDropDelivery.Status.UNCONFIRMED;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
|
||||
public final class CharacterSheets {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
|
||||
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 String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
|
||||
|
||||
public ItemStack create(CharacterRecord character) {
|
||||
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
|
||||
@@ -24,13 +27,14 @@ public final class CharacterSheets {
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
|
||||
public ShapelessRecipe recipe() {
|
||||
return new ShapelessRecipe(new NamespacedKey("fantasystick", "character_sheet"), paper("Character sheet"))
|
||||
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
}
|
||||
@@ -39,11 +43,21 @@ public final class CharacterSheets {
|
||||
var item = new ItemStack(Material.PAPER);
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(name));
|
||||
meta.lore(List.of(Component.text(INSTRUCTION)));
|
||||
meta.lore(INSTRUCTION);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Adds only the cosmetic model association to an already authentic sheet. */
|
||||
public boolean applyModel(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return false;
|
||||
var meta = item.getItemMeta();
|
||||
if (MODEL.equals(meta.getItemModel())) return true;
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -22,21 +22,39 @@ public final class EquipmentStorage {
|
||||
private static final NamespacedKey SESSION = key("storage_session_id");
|
||||
private static final NamespacedKey SLOTS = key("storage_slots");
|
||||
|
||||
private record PackPlan(PackResult result, ItemStack box, int destination) { }
|
||||
|
||||
/** Full read-only preflight; no contents are removed and no box is installed. */
|
||||
public java.util.Optional<PackResult> refusal(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
var plan = inspect(inventory, owner, session);
|
||||
return plan.result() == PackResult.PACKED ? java.util.Optional.empty() : java.util.Optional.of(plan.result());
|
||||
}
|
||||
|
||||
public PackResult pack(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
var plan = inspect(inventory, owner, session);
|
||||
if (plan.result() != PackResult.PACKED) return plan.result();
|
||||
// Install the genuine, fully constructed recovery copy before removing any original.
|
||||
// Setter exceptions may retain duplicates; the caller must report uncertainty, not roll back.
|
||||
inventory.setItem(plan.destination(), plan.box());
|
||||
for (int slot : EQUIPMENT_SLOTS) inventory.setItem(slot, null);
|
||||
return PackResult.PACKED;
|
||||
}
|
||||
|
||||
private PackPlan inspect(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) return PackResult.ALREADY_PACKED;
|
||||
if (matches(inventory.getItem(slot), owner, session)) return new PackPlan(PackResult.ALREADY_PACKED, null, -1);
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
var item = inventory.getItem(slot);
|
||||
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
if (!empty(item) && item.getType().name().endsWith("SHULKER_BOX")) return new PackPlan(PackResult.INELIGIBLE_EQUIPMENT, null, -1);
|
||||
}
|
||||
int available = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (empty(inventory.getItem(slot))) { available = slot; break; }
|
||||
}
|
||||
if (available == -1) return PackResult.NO_SPACE;
|
||||
if (available == -1) return new PackPlan(PackResult.NO_SPACE, null, -1);
|
||||
var item = new ItemStack(Material.SHULKER_BOX);
|
||||
var meta = (BlockStateMeta) item.getItemMeta();
|
||||
var box = (ShulkerBox) meta.getBlockState();
|
||||
@@ -44,7 +62,7 @@ public final class EquipmentStorage {
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) {
|
||||
var equipment = inventory.getItem(EQUIPMENT_SLOTS[i]);
|
||||
if (!empty(equipment) && equipment.getAmount() > Math.min(equipment.getMaxStackSize(), contents.getMaxStackSize())) {
|
||||
return PackResult.INELIGIBLE_EQUIPMENT;
|
||||
return new PackPlan(PackResult.INELIGIBLE_EQUIPMENT, null, -1);
|
||||
}
|
||||
contents.setItem(i, equipment);
|
||||
}
|
||||
@@ -56,26 +74,48 @@ public final class EquipmentStorage {
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOTS, PersistentDataType.INTEGER_ARRAY, EQUIPMENT_SLOTS.clone());
|
||||
item.setItemMeta(meta);
|
||||
for (int slot : EQUIPMENT_SLOTS) inventory.setItem(slot, null);
|
||||
inventory.setItem(available, item);
|
||||
return PackResult.PACKED;
|
||||
return new PackPlan(PackResult.PACKED, item, available);
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session) {
|
||||
return restore(inventory, owner, session, item -> false);
|
||||
}
|
||||
|
||||
/** Validates the entire restore before replacing only the current session's owned hotbar controls. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
|
||||
java.util.function.Predicate<ItemStack> ownedControl) {
|
||||
return restore(inventory, owner, session, ownedControl, null, ignored -> { });
|
||||
}
|
||||
|
||||
/** Cursor-aware restore; the setter is invoked only when the matching box is on the cursor. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
|
||||
ItemStack cursor, java.util.function.Consumer<ItemStack> setCursor) {
|
||||
return restore(inventory, owner, session, item -> false, cursor, setCursor);
|
||||
}
|
||||
|
||||
/** All candidates and destinations are validated before any write; setter failures are uncertain. */
|
||||
public RestoreResult restore(PlayerInventory inventory, UUID owner, UUID session,
|
||||
java.util.function.Predicate<ItemStack> ownedControl,
|
||||
ItemStack cursor, java.util.function.Consumer<ItemStack> setCursor) {
|
||||
Objects.requireNonNull(inventory);
|
||||
Objects.requireNonNull(ownedControl);
|
||||
Objects.requireNonNull(setCursor);
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
int found = -1;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
if (matches(inventory.getItem(slot), owner, session)) {
|
||||
// 9..35 main, 40 offhand, 41 cursor (not an inventory index).
|
||||
for (int slot = 9; slot <= 41; slot++) {
|
||||
if (slot >= 36 && slot < 40) continue;
|
||||
if (matches(slot == 41 ? cursor : inventory.getItem(slot), owner, session)) {
|
||||
if (found != -1) return RestoreResult.AMBIGUOUS;
|
||||
found = slot;
|
||||
}
|
||||
}
|
||||
if (found == -1) return RestoreResult.NOT_FOUND;
|
||||
var item = inventory.getItem(found);
|
||||
var item = found == 41 ? cursor : inventory.getItem(found);
|
||||
if (item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return RestoreResult.INVALID_BOX;
|
||||
var data = meta.getPersistentDataContainer();
|
||||
@@ -89,12 +129,15 @@ public final class EquipmentStorage {
|
||||
if (!empty(contents.getItem(slot))) return RestoreResult.INVALID_BOX;
|
||||
}
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
if (slot != 39 && !empty(inventory.getItem(slot))) return RestoreResult.OCCUPIED_SLOT;
|
||||
if (slot == 39) continue;
|
||||
var current = inventory.getItem(slot);
|
||||
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) return RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
var currentHead = inventory.getItem(39);
|
||||
var displacedHead = empty(currentHead) ? null : currentHead.clone();
|
||||
for (int i = 0; i < EQUIPMENT_SLOTS.length; i++) inventory.setItem(EQUIPMENT_SLOTS[i], contents.getItem(i));
|
||||
inventory.setItem(found, displacedHead);
|
||||
if (found == 41) setCursor.accept(displacedHead);
|
||||
else inventory.setItem(found, displacedHead);
|
||||
return RestoreResult.RESTORED;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/**
|
||||
* Detached, server-thread control presentation/identity, never session or casting authorization.
|
||||
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
|
||||
*/
|
||||
public final class SessionControls {
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
private static final NamespacedKey OWNER = key("control_owner_id");
|
||||
private static final NamespacedKey SESSION = key("control_session_id");
|
||||
private static final NamespacedKey SLOT = key("control_slot");
|
||||
private static final NamespacedKey KIND = key("control_kind");
|
||||
private static final NamespacedKey ABILITY = key("control_ability_id");
|
||||
|
||||
/** Presentation catalogue only: no unlock requirements, effects or casting authorization. */
|
||||
private record Icon(Material material, String name) { }
|
||||
private static final Map<String, Icon> ABILITIES = Map.ofEntries(
|
||||
Map.entry("fireball", new Icon(Material.SNOWBALL, "Fireball")),
|
||||
Map.entry("great_sword", new Icon(Material.WOODEN_SWORD, "Great Sword")),
|
||||
Map.entry("frost_shard", new Icon(Material.PRISMARINE_SHARD, "Frost Shard")),
|
||||
Map.entry("longbow", new Icon(Material.BOW, "Longbow")),
|
||||
Map.entry("quickstep", new Icon(Material.RABBIT_FOOT, "Quickstep")),
|
||||
Map.entry("second_wind", new Icon(Material.GHAST_TEAR, "Second Wind")),
|
||||
Map.entry("stone_skin", new Icon(Material.BRICK, "Stone Skin")),
|
||||
Map.entry("shield_bash", new Icon(Material.SHIELD, "Shield Bash")),
|
||||
Map.entry("hex", new Icon(Material.SPIDER_EYE, "Hex")),
|
||||
Map.entry("inspire", new Icon(Material.AMETHYST_SHARD, "Inspire")),
|
||||
Map.entry("mending_light", new Icon(Material.GHAST_TEAR, "Mending Light")),
|
||||
Map.entry("purify", new Icon(Material.HONEY_BOTTLE, "Purify")),
|
||||
Map.entry("guardian_bond", new Icon(Material.IRON_CHESTPLATE, "Guardian Bond")),
|
||||
Map.entry("rallying_cry", new Icon(Material.GOAT_HORN, "Rallying Cry")),
|
||||
Map.entry("frost_nova", new Icon(Material.SNOWBALL, "Frost Nova")),
|
||||
Map.entry("chain_lightning", new Icon(Material.LIGHTNING_ROD, "Chain Lightning")),
|
||||
Map.entry("sanctuary", new Icon(Material.SEA_LANTERN, "Sanctuary")),
|
||||
Map.entry("war_banner", new Icon(Material.RED_BANNER, "War Banner")),
|
||||
Map.entry("meteor", new Icon(Material.FIRE_CHARGE, "Meteor")),
|
||||
Map.entry("revive", new Icon(Material.ECHO_SHARD, "Revive"))
|
||||
);
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
var definition = registry.find(ability).orElse(null);
|
||||
return definition == null ? ABILITIES.get(ability) : new Icon(definition.icon(), definition.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the entire detached hotbar before a caller may start packing equipment.
|
||||
* Slot zero is a plain book control, not the pending character-book UI.
|
||||
*/
|
||||
public ItemStack[] create(CharacterRecord character, UUID owner, UUID session) {
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
var items = new ItemStack[9];
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
|
||||
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
|
||||
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOT, PersistentDataType.INTEGER, slot);
|
||||
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
|
||||
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Structural identity only, not proof of an active session or permission to cast. */
|
||||
public Optional<Identity> identify(ItemStack item) {
|
||||
if (item == null || item.getAmount() != 1 || !item.hasItemMeta()) return Optional.empty();
|
||||
var meta = item.getItemMeta();
|
||||
// The native codec can omit redundant overrides on naturally unstackable icons.
|
||||
if (item.getMaxStackSize() != 1) return Optional.empty();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER)
|
||||
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|
||||
|| !data.has(OWNER, PersistentDataType.STRING)
|
||||
|| !data.has(SESSION, PersistentDataType.STRING)
|
||||
|| !data.has(SLOT, PersistentDataType.INTEGER)
|
||||
|| !data.has(KIND, PersistentDataType.STRING)) return Optional.empty();
|
||||
int slot = data.get(SLOT, PersistentDataType.INTEGER);
|
||||
if (slot < 0 || slot > 8 || !validKind(item.getType(), slot, data)) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(new Identity(canonicalUuid(data.get(OWNER, PersistentDataType.STRING)),
|
||||
canonicalUuid(data.get(SESSION, PersistentDataType.STRING)), slot));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** A structurally recognized selectable ability; still not session/cast authorization. */
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
|
||||
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
|
||||
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
|
||||
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
|
||||
String ability = data.get(ABILITY, PersistentDataType.STRING);
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
private static UUID canonicalUuid(String value) {
|
||||
UUID parsed = UUID.fromString(value);
|
||||
if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static NamespacedKey key(String name) { return new NamespacedKey("fantasystick", name); }
|
||||
|
||||
public boolean owned(ItemStack item, UUID owner, UUID session) {
|
||||
return identify(item).filter(identity -> identity.owner().equals(owner)
|
||||
&& identity.session().equals(session)).isPresent();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.book.BookPagination;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.util.ArrayList;
|
||||
@@ -35,14 +36,26 @@ public final class TailBooks {
|
||||
meta.setGeneration(BookMeta.Generation.ORIGINAL);
|
||||
meta.displayName(Component.text(fullTitle));
|
||||
List<Component> pages = new ArrayList<>();
|
||||
pages.add(Component.text(fullTitle));
|
||||
pages.add(Component.text("Level: " + character.level()));
|
||||
pages.add(Component.text("XP: " + character.experience()));
|
||||
pages.add(Component.text("Unspent points: " + character.unspentPoints()));
|
||||
for (Stat stat : Stat.values()) pages.add(Component.text(stat + ": " + character.stats().get(stat)));
|
||||
pages.add(Component.text("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
|
||||
character.learned().stream().sorted().forEach(ability -> pages.add(Component.text(ability)));
|
||||
pages.add(Component.text("Fell: " + character.diedAt()));
|
||||
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
|
||||
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
|
||||
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
|
||||
pages.addAll(BookPagination.pages(summary.toString()));
|
||||
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
|
||||
character.learned().stream().sorted().forEach(ability -> {
|
||||
var slots = new ArrayList<String>();
|
||||
for (int slot = 0; slot < character.loadout().size(); slot++) {
|
||||
if (character.loadout().get(slot).equals(ability)) slots.add(Integer.toString(slot + 1));
|
||||
}
|
||||
var section = new StringBuilder("Ability: ").append(ability)
|
||||
.append("\nSlots: ").append(slots.isEmpty() ? "none" : String.join(", ", slots));
|
||||
var deadline = character.cooldowns().get(ability);
|
||||
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
|
||||
pages.addAll(BookPagination.pages(section.toString()));
|
||||
});
|
||||
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
|
||||
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
|
||||
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
|
||||
pages.addAll(BookPagination.pages("Fell:\n" + character.diedAt()));
|
||||
meta.pages(pages);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.CombatContributions;
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import games.dmg.fantasystick.session.SessionVisuals;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Accepted native contribution capture and durable character-bound XP delivery. */
|
||||
public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level) { }
|
||||
private static final Set<EntityType> PASSIVE = Set.of(
|
||||
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
|
||||
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
|
||||
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
|
||||
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
|
||||
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
|
||||
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
|
||||
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
|
||||
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Function<UUID, Player> players;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final CombatContributions contributions;
|
||||
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final UUID character;
|
||||
final BigInteger reward;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
|
||||
this.character = character; this.reward = reward; this.restricted = restricted;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
|
||||
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
}
|
||||
|
||||
/** Records target-specific, effective support; only a direct contributor can activate it at defeat. */
|
||||
public void support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
|
||||
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| event.getEntity().getUniqueId().equals(origin.player())) return;
|
||||
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
|
||||
}
|
||||
|
||||
private CombatSources.Origin source(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) defeats.put(event, new Defeat(active, record.level()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
var defeat = defeats.remove(event);
|
||||
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
|
||||
contributions.invalidate(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
|
||||
Instant now = clock.instant();
|
||||
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
|
||||
if (contributors.isEmpty()) return;
|
||||
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
for (UUID character : contributors) {
|
||||
var operation = new PendingAward(character, share, restricted);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static CombatRewardRules.KillPosition position(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
|
||||
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
|
||||
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
|
||||
}
|
||||
|
||||
private void attempt(PendingAward operation) {
|
||||
synchronized (operation) {
|
||||
if (closed || operation.running || !pending.containsKey(operation.id)) return;
|
||||
operation.running = true;
|
||||
}
|
||||
if (operation.expectedRevision == null) {
|
||||
characters.find(operation.character).whenComplete((record, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
|
||||
operation.expectedRevision = record.orElseThrow().revision();
|
||||
attempt(operation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
|
||||
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
|
||||
}
|
||||
pending.remove(operation.id);
|
||||
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
|
||||
try { nextTick.accept(() -> acknowledge(operation, award)); }
|
||||
catch (RuntimeException rejected) { report(operation.character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void fail(PendingAward operation, Throwable failure) {
|
||||
report(operation.character, failure);
|
||||
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
|
||||
}
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
var operation = pending.get(id);
|
||||
if (operation != null) attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
|
||||
if (closed) return;
|
||||
CharacterRecord saved = award.record().orElseThrow();
|
||||
var active = sessions.activeCharacter(operation.character).orElse(null);
|
||||
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
|
||||
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
|
||||
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
|
||||
return;
|
||||
}
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
|
||||
for (int gained = 1; gained <= award.levels(); gained++) {
|
||||
int level = saved.level() - award.levels() + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
SessionVisuals.burst(player, Color.BLUE, 12);
|
||||
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
|
||||
}
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
|
||||
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
|
||||
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
}
|
||||
|
||||
static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (retryTask != null) retryTask.cancel();
|
||||
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
|
||||
final class CombatSources {
|
||||
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
|
||||
record Origin(UUID player, UUID session, UUID character) {
|
||||
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
|
||||
static Origin decode(String value) {
|
||||
if (value == null || value.length() > 110) return null;
|
||||
var parts = value.split("/", -1);
|
||||
if (parts.length != 3) return null;
|
||||
try {
|
||||
var origin = new Origin(UUID.fromString(parts[0]), parts[1].equals("-") ? null : UUID.fromString(parts[1]),
|
||||
parts[2].equals("-") ? null : UUID.fromString(parts[2]));
|
||||
return (origin.session == null) == (origin.character == null) && value.equals(origin.encode()) ? origin : null;
|
||||
} catch (IllegalArgumentException malformed) { return null; }
|
||||
}
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
Origin player(UUID player) {
|
||||
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
|
||||
.orElseGet(() -> new Origin(player, null, null));
|
||||
}
|
||||
boolean current(Origin origin) {
|
||||
return origin.equals(player(origin.player())) && (origin.session() == null
|
||||
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
|
||||
}
|
||||
Origin read(Entity entity) { return read(entity, 0); }
|
||||
private Origin read(Entity entity, int depth) {
|
||||
if (entity == null || depth >= 8) return null;
|
||||
if (entity instanceof Player player) return player(player.getUniqueId());
|
||||
var saved = Origin.decode(entity.getPersistentDataContainer().get(ORIGIN, PersistentDataType.STRING));
|
||||
if (saved != null) return saved;
|
||||
if (entity instanceof org.bukkit.entity.Tameable pet && pet.getOwnerUniqueId() != null) {
|
||||
return player(pet.getOwnerUniqueId()); // A pet's new attack, not its old taming session.
|
||||
}
|
||||
var owner = owner(entity, depth + 1);
|
||||
// No launch proof (e.g. predating plugin startup): never assign a current fantasy session.
|
||||
return owner == null ? null : new Origin(owner.player(), null, null);
|
||||
}
|
||||
private Origin owner(Entity entity, int depth) {
|
||||
if (entity instanceof Projectile projectile && projectile.getShooter() instanceof Entity shooter) return read(shooter, depth);
|
||||
if (entity instanceof org.bukkit.entity.Firework firework && firework.getSpawningEntity() != null) return player(firework.getSpawningEntity());
|
||||
if (entity instanceof org.bukkit.entity.TNTPrimed tnt) return read(tnt.getSource(), depth);
|
||||
if (entity instanceof org.bukkit.entity.EvokerFangs fangs) return read(fangs.getOwner(), depth);
|
||||
if (entity instanceof org.bukkit.entity.AreaEffectCloud cloud && cloud.getSource() instanceof Entity source) return read(source, depth);
|
||||
return null;
|
||||
}
|
||||
void launch(Entity entity) {
|
||||
if (entity.getPersistentDataContainer().has(ORIGIN)) return;
|
||||
store(entity, owner(entity, 0));
|
||||
}
|
||||
void store(Entity entity, Origin origin) {
|
||||
if (origin != null) entity.getPersistentDataContainer().set(ORIGIN, PersistentDataType.STRING, origin.encode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded provenance for native damage layers, not copied effects or character/XP state.
|
||||
* Native effects remain authoritative. Durations reconcile in native ticks (also across offline saves),
|
||||
* so a hidden effect regaining the foreground keeps its own source, not a covering effect's source.
|
||||
*/
|
||||
final class OngoingDamage {
|
||||
record Application(CombatSources.Origin source, CombatSources.Origin target) { }
|
||||
private record Layer(int amplifier, int duration, Application application) { }
|
||||
private static final List<DamageCause> CAUSES = List.of(DamageCause.POISON, DamageCause.WITHER, DamageCause.FIRE_TICK);
|
||||
private static NamespacedKey key(DamageCause cause) {
|
||||
return new NamespacedKey("fantasystick", "combat_dot_" + cause.name().toLowerCase(java.util.Locale.ROOT) + "_v1");
|
||||
}
|
||||
static DamageCause cause(PotionEffectType type) {
|
||||
if (type.equals(PotionEffectType.POISON)) return DamageCause.POISON;
|
||||
if (type.equals(PotionEffectType.WITHER)) return DamageCause.WITHER;
|
||||
return null;
|
||||
}
|
||||
static void clear(Player player, DamageCause cause) { player.getPersistentDataContainer().remove(key(cause)); }
|
||||
static void potion(Player player, PotionEffect old, PotionEffect incoming, boolean override, Application application) {
|
||||
var cause = cause(incoming.getType());
|
||||
var layers = old == null ? new ArrayList<Layer>() : reconcile(load(player, cause), old.getAmplifier(), old.getDuration());
|
||||
var next = new Layer(incoming.getAmplifier(), incoming.getDuration(), application);
|
||||
// Purpur also stores a longer weaker effect behind the active one when override=false.
|
||||
if (layers.isEmpty() || override || next.amplifier() < layers.getFirst().amplifier()
|
||||
&& longer(next.duration(), layers.getFirst().duration())) merge(layers, 0, next);
|
||||
save(player, cause, layers);
|
||||
}
|
||||
private static boolean longer(int a, int b) { return a == -1 ? b != -1 : b != -1 && a > b; }
|
||||
private static void merge(List<Layer> layers, int index, Layer next) {
|
||||
if (index == layers.size()) { layers.add(next); return; }
|
||||
var old = layers.get(index);
|
||||
if (next.amplifier() > old.amplifier()) {
|
||||
if (longer(old.duration(), next.duration())) layers.add(index, next);
|
||||
else layers.set(index, next);
|
||||
} else if (next.amplifier() == old.amplifier()) {
|
||||
if (longer(next.duration(), old.duration())) layers.set(index, next);
|
||||
} else if (longer(next.duration(), old.duration())) merge(layers, index + 1, next);
|
||||
}
|
||||
static void fire(Player player, int duration, Application application) {
|
||||
save(player, DamageCause.FIRE_TICK, List.of(new Layer(0, duration, application)));
|
||||
}
|
||||
static Application read(Player player, DamageCause cause) {
|
||||
if (!CAUSES.contains(cause) || !player.getPersistentDataContainer().has(key(cause))) return null;
|
||||
List<Layer> layers = load(player, cause);
|
||||
if (cause == DamageCause.FIRE_TICK) {
|
||||
int fire = player.getFireTicks();
|
||||
layers = fire > 0 && !layers.isEmpty() && fire <= layers.getFirst().duration()
|
||||
? List.of(new Layer(0, fire, layers.getFirst().application())) : List.of();
|
||||
} else {
|
||||
var effect = player.getPotionEffect(cause == DamageCause.POISON ? PotionEffectType.POISON : PotionEffectType.WITHER);
|
||||
layers = effect == null ? List.of() : reconcile(layers, effect.getAmplifier(), effect.getDuration());
|
||||
}
|
||||
save(player, cause, layers);
|
||||
return layers.isEmpty() ? null : layers.getFirst().application();
|
||||
}
|
||||
static void sweep(Player player) { for (var cause : CAUSES) read(player, cause); }
|
||||
|
||||
private static ArrayList<Layer> reconcile(List<Layer> saved, int amplifier, int duration) {
|
||||
var result = new ArrayList<Layer>();
|
||||
for (int i = 0; i < saved.size(); i++) {
|
||||
var layer = saved.get(i);
|
||||
if (layer.amplifier() != amplifier || longer(duration, layer.duration())) continue;
|
||||
int elapsed = layer.duration() == -1 ? 0 : layer.duration() - duration;
|
||||
for (int j = i; j < saved.size(); j++) {
|
||||
var remaining = saved.get(j);
|
||||
int ticks = remaining.duration() == -1 ? -1 : Math.max(0, remaining.duration() - elapsed);
|
||||
if (ticks != 0) result.add(new Layer(remaining.amplifier(), ticks, remaining.application()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Unknown/out-of-band replacement never borrows old attribution or gains party immunity.
|
||||
if (duration != 0) result.add(new Layer(amplifier, duration, null));
|
||||
return result;
|
||||
}
|
||||
private static List<Layer> load(Player player, DamageCause cause) {
|
||||
String value = player.getPersistentDataContainer().get(key(cause), PersistentDataType.STRING);
|
||||
if (value == null || value.length() > 65536) return List.of();
|
||||
var result = new ArrayList<Layer>();
|
||||
try {
|
||||
for (var row : value.split("\n", -1)) {
|
||||
var cells = row.split(",", -1);
|
||||
if (cells.length != 4 || result.size() >= 256) return List.of();
|
||||
int amplifier = Integer.parseInt(cells[0]), duration = Integer.parseInt(cells[1]);
|
||||
if (amplifier < 0 || amplifier > 255 || duration < -1 || duration == 0
|
||||
|| !result.isEmpty() && amplifier >= result.getLast().amplifier()) return List.of();
|
||||
Application application = null;
|
||||
if (!cells[2].equals("-") || !cells[3].equals("-")) {
|
||||
var source = CombatSources.Origin.decode(cells[2]);
|
||||
var target = CombatSources.Origin.decode(cells[3]);
|
||||
if (source == null || target == null || !target.player().equals(player.getUniqueId())) return List.of();
|
||||
application = new Application(source, target);
|
||||
}
|
||||
result.add(new Layer(amplifier, duration, application));
|
||||
}
|
||||
} catch (IllegalArgumentException malformed) { return List.of(); }
|
||||
return result;
|
||||
}
|
||||
private static void save(Player player, DamageCause cause, List<Layer> layers) {
|
||||
if (layers.stream().noneMatch(layer -> layer.application() != null)) { clear(player, cause); return; }
|
||||
var value = new StringBuilder();
|
||||
for (var layer : layers) {
|
||||
if (!value.isEmpty()) value.append('\n');
|
||||
value.append(layer.amplifier()).append(',').append(layer.duration()).append(',');
|
||||
var application = layer.application();
|
||||
value.append(application == null ? "-,-" : application.source().encode() + "," + application.target().encode());
|
||||
}
|
||||
player.getPersistentDataContainer().set(key(cause), PersistentDataType.STRING, value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntitySpawnEvent;
|
||||
import org.bukkit.event.entity.LingeringPotionSplashEvent;
|
||||
|
||||
/** Server-thread native damage protection; never uncancels another protection's decision. */
|
||||
public final class PartyCombat implements Listener, AutoCloseable {
|
||||
private org.bukkit.scheduler.BukkitTask cleanup;
|
||||
private boolean closed;
|
||||
private final PartyRoster parties;
|
||||
private final CombatSources sources;
|
||||
|
||||
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
|
||||
}, 1, 1);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (cleanup != null) cleanup.cancel();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void launch(EntitySpawnEvent event) { sources.launch(event.getEntity()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void splash(org.bukkit.event.entity.PotionSplashEvent event) {
|
||||
// Native splash passes only its owner to addEffect, losing the projectile's session.
|
||||
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
|
||||
// actual harmful applications and instantaneous damage have their own guards below.
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void staleLingering(LingeringPotionSplashEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void lingering(LingeringPotionSplashEvent event) {
|
||||
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
if (origin != null && (!sources.current(origin)
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void potion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|
||||
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
|
||||
var origin = sources.read(event.getSource());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var cause = OngoingDamage.cause(event.getModifiedType());
|
||||
if (cause == null) return;
|
||||
if (event.getNewEffect() == null) OngoingDamage.clear(target, cause);
|
||||
else {
|
||||
var origin = sources.read(event.getSource());
|
||||
OngoingDamage.potion(target, event.getOldEffect(), event.getNewEffect(), event.isOverride(), origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = sources.read(event.getCombuster());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedCombust(org.bukkit.event.entity.EntityCombustEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = event instanceof org.bukkit.event.entity.EntityCombustByEntityEvent entity
|
||||
? sources.read(entity.getCombuster()) : null;
|
||||
int duration = Math.max(target.getFireTicks(), (int) Math.ceil(event.getDuration() * 20));
|
||||
OngoingDamage.fire(target, duration, origin == null ? null
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
boolean fantasy = origin.session() != null;
|
||||
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
|
||||
&& !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
|
||||
/** Quit is authoritative even before Bukkit's online flag changes. */
|
||||
public final class PartyEvents implements Listener {
|
||||
private final PartyRoster parties;
|
||||
public PartyEvents(PartyRoster parties) { this.parties = Objects.requireNonNull(parties); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) { parties.arrive(event.getPlayer().getUniqueId()); }
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(PlayerQuitEvent event) { parties.disconnect(event.getPlayer().getUniqueId()); }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
|
||||
public final class PartyRoster implements AutoCloseable {
|
||||
public record Party(UUID id, UUID leader, List<UUID> members) {
|
||||
public Party { members = List.copyOf(members); }
|
||||
}
|
||||
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
|
||||
private final Map<UUID, Party> membership = new HashMap<>();
|
||||
private final Map<UUID, Invitation> invitations = new HashMap<>();
|
||||
private final Set<UUID> departed = new HashSet<>();
|
||||
private boolean closed;
|
||||
private final Clock clock;
|
||||
private final Predicate<UUID> online;
|
||||
private final java.util.function.IntSupplier maximumSize;
|
||||
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.online = Objects.requireNonNull(online);
|
||||
this.maximumSize = Objects.requireNonNull(maximumSize);
|
||||
maximumSize();
|
||||
}
|
||||
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 || !connected(player) || membership.containsKey(player)) return Optional.empty();
|
||||
var party = new Party(UUID.randomUUID(), player, List.of(player));
|
||||
membership.put(player, party);
|
||||
return Optional.of(party);
|
||||
}
|
||||
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
|
||||
public List<Invitation> invitationsFor(UUID player) {
|
||||
expire();
|
||||
return invitations.values().stream().filter(i -> i.invitee().equals(player))
|
||||
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
|
||||
}
|
||||
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
|
||||
expire();
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || group.members().size() >= 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);
|
||||
}
|
||||
public boolean accept(UUID player, UUID invitation) {
|
||||
expire();
|
||||
var offer = invitations.get(invitation);
|
||||
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
|
||||
var group = membership.get(offer.leader());
|
||||
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
|
||||
var members = new ArrayList<>(group.members());
|
||||
members.add(player);
|
||||
var updated = new Party(group.id(), group.leader(), members);
|
||||
for (var member : members) membership.put(member, updated);
|
||||
invitations.values().removeIf(i -> i.invitee().equals(player));
|
||||
return true;
|
||||
}
|
||||
public boolean kick(UUID leader, UUID party, UUID member) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
|
||||
removeMember(group, member);
|
||||
return true;
|
||||
}
|
||||
public boolean disband(UUID leader, UUID party) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null) return false;
|
||||
remove(group);
|
||||
return true;
|
||||
}
|
||||
private Party managedBy(UUID leader, UUID party) {
|
||||
var group = membership.get(leader);
|
||||
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
|
||||
}
|
||||
public boolean leave(UUID player, UUID party) {
|
||||
var group = membership.get(player);
|
||||
if (group == null || !group.id().equals(party) || !connected(player)) return false;
|
||||
if (group.leader().equals(player)) remove(group);
|
||||
else removeMember(group, player);
|
||||
return true;
|
||||
}
|
||||
private void removeMember(Party group, UUID player) {
|
||||
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
|
||||
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
|
||||
membership.remove(player);
|
||||
departed.remove(player);
|
||||
var updated = new Party(group.id(), group.leader(), remaining);
|
||||
for (var member : remaining) membership.put(member, updated);
|
||||
}
|
||||
public void arrive(UUID player) { departed.remove(player); }
|
||||
public void disconnect(UUID player) {
|
||||
var group = membership.get(player);
|
||||
if (group == null) return;
|
||||
departed.add(player);
|
||||
if (group.members().stream().noneMatch(this::connected)) remove(group);
|
||||
}
|
||||
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
|
||||
private void expire() {
|
||||
var now = clock.instant();
|
||||
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
|
||||
}
|
||||
private void remove(Party group) {
|
||||
for (var member : group.members()) membership.remove(member);
|
||||
departed.removeAll(group.members());
|
||||
invitations.values().removeIf(i -> i.party().equals(group.id()));
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
membership.clear();
|
||||
invitations.clear();
|
||||
departed.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for later administrator revival selections. */
|
||||
public final class AdminReviveSettings implements AutoCloseable {
|
||||
public static final String KEY="admin-revive-selection-timeout-seconds";public static final String DEFAULT="60";
|
||||
private static final int MAX_BYTES=1_048_576;private static final long MAX_SECONDS=365L*24*60*60;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,Duration timeout,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-admin-revive-configuration"));
|
||||
private volatile Duration effective;private Duration persisted;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdminReviveSettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
AdminReviveSettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);persisted=effective;}
|
||||
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(60);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be positive whole seconds",invalid);}}
|
||||
private static Duration validate(Duration value){long seconds;try{seconds=value.toSeconds();}catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid admin revival timeout",invalid);}if(value.isZero()||value.isNegative()||value.getNano()!=0||seconds>MAX_SECONDS)throw new IllegalArgumentException(KEY+" must be positive whole seconds through 365 days");return value;}
|
||||
public Duration timeout(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
|
||||
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);persisted=accepted;return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Admin revival configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.timeout());publishedSequence=update.sequence();}
|
||||
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Worker-owned config writes; publish a new-session policy only after durable file acknowledgment. */
|
||||
public final class AdventureSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public AdventureSettings(Path directory, boolean initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
AdventureSettings(Path directory, boolean initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
enabled = initial;
|
||||
}
|
||||
|
||||
public static boolean validate(Object configured) {
|
||||
if (configured == null) return true;
|
||||
if (configured instanceof Boolean value) return value;
|
||||
throw new IllegalArgumentException("adventure-mode must be true or false");
|
||||
}
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
values.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put("adventure-mode", value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
/** Drain accepted writes without waiting on the server thread. */
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
worker.shutdown();
|
||||
if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.CombatRewardRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
@@ -16,7 +17,16 @@ public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
cooldowns, Map.of(), dead, deathId, diedAt);
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
@@ -28,6 +38,7 @@ public record CharacterRecord(
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
@@ -35,6 +46,8 @@ public record CharacterRecord(
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -33,42 +39,494 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare already allocated fresh characters off-thread. The caller validates configured
|
||||
* starting points and retains the returned handle through success, cancellation and disable.
|
||||
* No inventory access occurs here; abandoning an observer is not abandoning the attempt.
|
||||
*/
|
||||
public CraftPreparation prepareCraft(List<CharacterRecord> records) {
|
||||
var snapshot = List.copyOf(records);
|
||||
var ids = new HashSet<UUID>();
|
||||
if (snapshot.isEmpty()) throw new IllegalArgumentException("Craft batch must not be empty");
|
||||
for (var record : snapshot) {
|
||||
if (!ids.add(record.id()) || record.revision().signum() != 0 || record.name().isBlank()
|
||||
|| record.dead() || record.deathId() != null || record.level() != 1
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
}
|
||||
return new CraftPreparation(snapshot);
|
||||
}
|
||||
|
||||
public final class CraftPreparation {
|
||||
private final List<CharacterRecord> requested;
|
||||
private final CompletableFuture<CraftBatch> completion;
|
||||
private volatile boolean abandoned;
|
||||
private CompletableFuture<CraftCleanup> cleanup;
|
||||
|
||||
private CraftPreparation(List<CharacterRecord> records) {
|
||||
requested = records;
|
||||
completion = writeCraftBatch(records, this);
|
||||
}
|
||||
|
||||
/** Observation only; cancelling/timing out this copy does not cancel the owned operation. */
|
||||
public CompletableFuture<CraftBatch> ready() { return completion.copy(); }
|
||||
|
||||
/** Retain this handle through completion; invalidate the attempt, not an observation future. */
|
||||
public synchronized CompletableFuture<CraftCleanup> abandon() {
|
||||
abandoned = true;
|
||||
if (cleanup == null) {
|
||||
cleanup = completion.handle((batch, failure) -> {
|
||||
if (batch != null) return batch.abandon();
|
||||
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
|
||||
var outcome = cause instanceof CraftPreparationException preparation ? preparation.cleanup()
|
||||
: retainAll(requested);
|
||||
return CompletableFuture.completedFuture(outcome);
|
||||
}).thenCompose(result -> result);
|
||||
}
|
||||
return cleanup.copy();
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<CraftBatch> writeCraftBatch(List<CharacterRecord> snapshot, CraftPreparation owner) {
|
||||
return submit(() -> {
|
||||
var created = new ArrayList<YamlCharacterRepository.Creation>();
|
||||
UUID attempted = null;
|
||||
try {
|
||||
for (var record : snapshot) {
|
||||
if (owner.abandoned) break;
|
||||
attempted = record.id();
|
||||
created.add(repository.createTracked(record));
|
||||
attempted = null;
|
||||
}
|
||||
return new CraftBatch(created, owner);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
var cleanup = discardUnissued(created);
|
||||
var retained = new HashSet<>(cleanup.retained());
|
||||
if (attempted != null) retained.add(attempted);
|
||||
throw new CraftPreparationException(failure, new CraftCleanup(cleanup.removed(), retained));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static final class CraftPreparationException extends IOException {
|
||||
@java.io.Serial private static final long serialVersionUID = 1L;
|
||||
private final transient CraftCleanup cleanup;
|
||||
|
||||
private CraftPreparationException(Throwable cause, CraftCleanup cleanup) {
|
||||
super("Character craft preparation failed; no batch was issued", cause);
|
||||
this.cleanup = cleanup;
|
||||
}
|
||||
|
||||
public CraftCleanup cleanup() { return cleanup; }
|
||||
}
|
||||
|
||||
/** Removed includes already-absent files; retained includes failed writes whose outcome is unknown. */
|
||||
public record CraftCleanup(Set<UUID> removed, Set<UUID> retained) {
|
||||
public CraftCleanup {
|
||||
removed = Set.copyOf(removed);
|
||||
retained = Set.copyOf(retained);
|
||||
}
|
||||
}
|
||||
|
||||
public final class CraftBatch {
|
||||
private final List<CharacterRecord> records;
|
||||
private final List<YamlCharacterRepository.Creation> created;
|
||||
private final CraftPreparation owner;
|
||||
private boolean resolved;
|
||||
private CompletableFuture<CraftCleanup> cleanup;
|
||||
|
||||
private CraftBatch(List<YamlCharacterRepository.Creation> created, CraftPreparation owner) {
|
||||
this.owner = owner;
|
||||
this.created = List.copyOf(created);
|
||||
this.records = created.stream().map(creation -> creation.record).toList();
|
||||
}
|
||||
|
||||
/** Reject a wrong-sized batch before giving up its never-issued cleanup proof. */
|
||||
public synchronized Optional<List<CharacterRecord>> claimForDelivery(int expectedQuantity) {
|
||||
if (expectedQuantity < 1 || records.size() != expectedQuantity) return Optional.empty();
|
||||
return claimForDelivery();
|
||||
}
|
||||
|
||||
/** One-use handoff immediately before native delivery; an exception afterward cannot authorize deletion. */
|
||||
public synchronized Optional<List<CharacterRecord>> claimForDelivery() {
|
||||
if (resolved || owner.abandoned) return Optional.empty();
|
||||
resolved = true;
|
||||
if (worker.isShutdown()) return Optional.empty();
|
||||
return Optional.of(records);
|
||||
}
|
||||
|
||||
public synchronized CompletableFuture<CraftCleanup> abandon() {
|
||||
if (cleanup == null) {
|
||||
if (resolved) {
|
||||
cleanup = CompletableFuture.completedFuture(retainAll(records));
|
||||
} else {
|
||||
resolved = true;
|
||||
cleanup = submit(() -> discardUnissued(created)).exceptionally(failure -> retainAll(records));
|
||||
}
|
||||
}
|
||||
return cleanup.copy();
|
||||
}
|
||||
}
|
||||
|
||||
private static CraftCleanup retainAll(List<CharacterRecord> records) {
|
||||
return new CraftCleanup(Set.of(), records.stream().map(CharacterRecord::id)
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
private CraftCleanup discardUnissued(List<YamlCharacterRepository.Creation> created) {
|
||||
var removed = new HashSet<UUID>();
|
||||
var retained = new HashSet<UUID>();
|
||||
for (var creation : created) {
|
||||
try {
|
||||
if (repository.discardUnissued(creation)) removed.add(creation.record.id());
|
||||
else retained.add(creation.record.id());
|
||||
} catch (IOException exception) {
|
||||
retained.add(creation.record.id());
|
||||
}
|
||||
}
|
||||
return new CraftCleanup(removed, retained);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> repository.load(id));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(UUID characterId, String name) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(name);
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
}
|
||||
|
||||
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist configured transferable XP/level/point progression as one expected revision. */
|
||||
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules rules) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward);
|
||||
Objects.requireNonNull(rules);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
|
||||
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
|
||||
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
|
||||
}
|
||||
var experience = old.experience().add(reward).min(cap);
|
||||
if (experience.equals(old.experience())) {
|
||||
var status = old.level() >= rules.maximumLevel()
|
||||
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
|
||||
return new ExperienceAward(status, Optional.of(old), 0);
|
||||
}
|
||||
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist one combat share and its restricted-kill reference in the same character revision. */
|
||||
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|
||||
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
|
||||
var history = new java.util.HashMap<>(old.restrictedKills());
|
||||
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
|
||||
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
|
||||
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
|
||||
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
|
||||
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
|
||||
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
|
||||
return new CombatAward(status, Optional.of(old), 0);
|
||||
}
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Unlock one validated registered ID and spend its points in the same expected revision. */
|
||||
public CompletableFuture<AbilityMutation> unlockAbility(UUID characterId, BigInteger expectedRevision, String ability,
|
||||
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
|
||||
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
|
||||
throw new IllegalArgumentException("Invalid ability unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.ALREADY_LEARNED, Optional.of(old));
|
||||
if (requirements.entrySet().stream().anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
|
||||
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
|
||||
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
|
||||
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.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
|
||||
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, int slot) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
if (ability.isBlank() || slot < 0 || slot >= 7) throw new IllegalArgumentException("Invalid ability assignment");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
var loadout = new java.util.ArrayList<>(old.loadout());
|
||||
for (int index = 0; index < loadout.size(); index++) if (loadout.get(index).equals(ability)) loadout.set(index, "");
|
||||
loadout.set(slot, ability);
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Commit a real-time character/ability deadline before exposing the cast's native effect. */
|
||||
public CompletableFuture<AbilityMutation> commitAbilityCooldown(UUID characterId, BigInteger expectedRevision,
|
||||
String ability, Instant readyAt) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(readyAt);
|
||||
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability cooldown");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
Instant previous = old.cooldowns().get(ability);
|
||||
if (previous != null && previous.isAfter(clock.instant())) return new AbilityMutation(AbilityMutationStatus.COOLDOWN, Optional.of(old));
|
||||
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
|
||||
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Persist one active-session stat purchase and its current native health as one revision. */
|
||||
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
|
||||
java.math.BigDecimal currentHealth,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(stat);
|
||||
Objects.requireNonNull(currentHealth);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
|
||||
throw new IllegalArgumentException("Invalid stat-purchase health bound");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().signum() == 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
|
||||
}
|
||||
var oldMaximum = old.maximumHealth().min(nativeLimit);
|
||||
if (currentHealth.compareTo(oldMaximum) > 0) {
|
||||
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
|
||||
}
|
||||
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
|
||||
stats.putAll(old.stats());
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
return saveHealth(characterId, null, health, nativeLimit);
|
||||
}
|
||||
|
||||
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
|
||||
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One accepted defeat against the latest live record on this worker, including queued names.
|
||||
* The repository's locked revision compare-and-replace prevents overwriting a concurrent
|
||||
* external writer; a conflict fails rather than retrying an uncertain death or reviving it.
|
||||
* Callers must retain their one-use session authority until the result resolves.
|
||||
*/
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId) {
|
||||
return death(characterId, null);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
return death(characterId, Objects.requireNonNull(expectedRevision));
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Instant diedAt = clock.instant();
|
||||
UUID deathId = UUID.randomUUID();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
if (old.dead() || !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), true, UUID.randomUUID(), diedAt);
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
return restoreDeathRecord(characterId, deathId, null);
|
||||
}
|
||||
|
||||
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
return restore(characterId, deathId, nativeLimit, true);
|
||||
}
|
||||
|
||||
/** Administrator revival retains death identity/consume-once checks but deliberately ignores death age. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(
|
||||
UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) {
|
||||
return restore(characterId, deathId, nativeLimit, false);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> restore(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit, boolean ageLimited) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(deathId);
|
||||
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
Duration age = Duration.between(old.diedAt(), selectedAt);
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative()
|
||||
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
|
||||
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(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,13 @@ final class CharacterYaml {
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
root.put("cooldowns", cooldowns);
|
||||
List<Map<String, String>> restrictedKills = new ArrayList<>();
|
||||
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
|
||||
var position = entry.getValue();
|
||||
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
|
||||
"y", position.y().toString(), "z", position.z().toString()));
|
||||
});
|
||||
root.put("restricted-kills", restrictedKills);
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -74,11 +81,19 @@ final class CharacterYaml {
|
||||
throw new IllegalArgumentException("Duplicate cooldown");
|
||||
}
|
||||
}
|
||||
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
|
||||
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
|
||||
Map<?, ?> row = object(value);
|
||||
UUID world = UUID.fromString(text(row.get("world")));
|
||||
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
|
||||
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
|
||||
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
|
||||
}
|
||||
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/**
|
||||
* Player/session receipts, never an external copy of equipment or proof of restoration.
|
||||
* All disk work is deferred. Coordination assumes cooperating writers, not adversarial
|
||||
* replacement of directories. Forced atomic file replacement is not inventory/save atomicity
|
||||
* or a guarantee of directory durability after power loss. Ambiguous receipts are retained.
|
||||
*/
|
||||
public final class EquipmentSessionStore implements AutoCloseable {
|
||||
private static final int MAX_FILE_BYTES = 16_384;
|
||||
private final Path directory;
|
||||
private final Clock clock;
|
||||
private final Replacement replacement;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Replacement { void move(Path source, Path target) throws IOException; }
|
||||
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-equipment-session-persistence"));
|
||||
|
||||
public record Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode, OrdinaryVitals ordinaryVitals) {
|
||||
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt) { this(sessionId, characterId, preparedAt, null, null); }
|
||||
public Receipt(UUID sessionId, UUID characterId, Instant preparedAt, String previousGameMode) {
|
||||
this(sessionId, characterId, preparedAt, previousGameMode, null);
|
||||
}
|
||||
public Receipt {
|
||||
Objects.requireNonNull(sessionId);
|
||||
Objects.requireNonNull(characterId);
|
||||
timestamp(preparedAt);
|
||||
if (previousGameMode != null && !Set.of("SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR").contains(previousGameMode)) {
|
||||
throw new IllegalArgumentException("Invalid previous game mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Exit(UUID sessionId, UUID characterId, Instant exitedAt, Instant deadline) {
|
||||
public Exit {
|
||||
Objects.requireNonNull(sessionId);
|
||||
Objects.requireNonNull(characterId);
|
||||
timestamp(exitedAt);
|
||||
timestamp(deadline);
|
||||
if (deadline.isBefore(exitedAt)) throw new IllegalArgumentException("Deadline precedes exit");
|
||||
}
|
||||
}
|
||||
|
||||
public record State(UUID playerId, BigInteger revision, Receipt pending, Exit lastExit) {
|
||||
public State {
|
||||
Objects.requireNonNull(playerId);
|
||||
Objects.requireNonNull(revision);
|
||||
if (revision.signum() < 0) throw new IllegalArgumentException("Negative session revision");
|
||||
if (pending != null && lastExit != null && pending.sessionId().equals(lastExit.sessionId())
|
||||
&& !pending.characterId().equals(lastExit.characterId())) {
|
||||
throw new IllegalArgumentException("Inconsistent session character");
|
||||
}
|
||||
}
|
||||
|
||||
/** Only a delay check; this does not authorize a sheet, equipment, or native state changes. */
|
||||
public boolean canEnter(UUID character, Duration configuredDelay, Instant now) {
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(configuredDelay);
|
||||
Objects.requireNonNull(now);
|
||||
if (configuredDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
return configuredDelay.isZero() || lastExit == null || character.equals(lastExit.characterId())
|
||||
|| !now.isBefore(lastExit.deadline());
|
||||
}
|
||||
}
|
||||
|
||||
/** No filesystem access, directory creation or recovery occurs in the constructor. */
|
||||
public EquipmentSessionStore(Path pluginDataDirectory, Clock clock) {
|
||||
this(pluginDataDirectory, clock, (source, target) -> Files.move(source, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
// Narrow disk-boundary seam for deterministic acknowledgment/failure tests.
|
||||
EquipmentSessionStore(Path pluginDataDirectory, Clock clock, Replacement replacement) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("sessions").toAbsolutePath();
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
}
|
||||
|
||||
public CompletionStage<Optional<State>> find(UUID player) {
|
||||
Objects.requireNonNull(player);
|
||||
return submit(() -> withLock(() -> load(player)));
|
||||
}
|
||||
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character) {
|
||||
return prepare(player, session, character, null);
|
||||
}
|
||||
|
||||
/** A receipt acknowledgment precedes mode/gear mutation; it is not proof either happened. */
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode) {
|
||||
return prepare(player, session, character, previousGameMode, null);
|
||||
}
|
||||
|
||||
public CompletionStage<State> prepare(UUID player, UUID session, UUID character, String previousGameMode, OrdinaryVitals ordinaryVitals) {
|
||||
Objects.requireNonNull(player);
|
||||
var receipt = new Receipt(session, character, clock.instant(), previousGameMode, ordinaryVitals);
|
||||
return submit(() -> withLock(() -> {
|
||||
var found = load(player);
|
||||
if (found.isPresent()) {
|
||||
var old = found.orElseThrow();
|
||||
if (old.pending() != null) {
|
||||
if (session.equals(old.pending().sessionId()) && character.equals(old.pending().characterId())
|
||||
&& Objects.equals(previousGameMode, old.pending().previousGameMode())
|
||||
&& Objects.equals(ordinaryVitals, old.pending().ordinaryVitals())) {
|
||||
return old;
|
||||
}
|
||||
throw new IOException("Conflicting pending equipment session");
|
||||
}
|
||||
if (old.lastExit() != null && session.equals(old.lastExit().sessionId())) {
|
||||
throw new IOException("Exited session cannot be prepared again");
|
||||
}
|
||||
return write(new State(player, old.revision().add(BigInteger.ONE), receipt, old.lastExit()));
|
||||
}
|
||||
return write(new State(player, BigInteger.ZERO, receipt, null));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Capture the actual exit request time, never queue completion time. The receipt remains pending. */
|
||||
public CompletionStage<State> recordExit(UUID player, UUID session, Duration delay) {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(delay);
|
||||
Instant exitedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
if (delay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
Instant deadline = exitedAt.plus(delay);
|
||||
timestamp(exitedAt);
|
||||
timestamp(deadline);
|
||||
return withLock(() -> {
|
||||
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
|
||||
if (old.lastExit() != null && session.equals(old.lastExit().sessionId())) return old;
|
||||
if (old.pending() == null || !session.equals(old.pending().sessionId())) {
|
||||
throw new IOException("Exit does not identify the prepared session");
|
||||
}
|
||||
var exit = new Exit(session, old.pending().characterId(), exitedAt, deadline);
|
||||
return write(new State(player, old.revision().add(BigInteger.ONE), old.pending(), exit));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Atomically record a proven native restoration and clear only its matching receipt. */
|
||||
public CompletionStage<State> completeExit(UUID player, UUID session, Duration delay) {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(delay);
|
||||
Instant exitedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
if (delay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
Instant deadline = exitedAt.plus(delay);
|
||||
timestamp(exitedAt);
|
||||
timestamp(deadline);
|
||||
return withLock(() -> {
|
||||
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
|
||||
boolean recorded = old.lastExit() != null && session.equals(old.lastExit().sessionId());
|
||||
boolean matching = old.pending() != null && session.equals(old.pending().sessionId());
|
||||
if (recorded && !matching) return old; // Never clear a newer pending receipt.
|
||||
if (!matching) throw new IOException("Exit does not identify the prepared session");
|
||||
var exit = recorded ? old.lastExit()
|
||||
: new Exit(session, old.pending().characterId(), exitedAt, deadline);
|
||||
return write(new State(player, old.revision().add(BigInteger.ONE), null, exit));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Caller must prove never-packed or actually restored; absent items are not such proof. */
|
||||
public CompletionStage<State> clearReceipt(UUID player, UUID session) {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
return submit(() -> withLock(() -> {
|
||||
var old = load(player).orElseThrow(() -> new IOException("Equipment session not found"));
|
||||
if (old.pending() == null) return old;
|
||||
if (!session.equals(old.pending().sessionId())) throw new IOException("Receipt session mismatch");
|
||||
return write(new State(player, old.revision().add(BigInteger.ONE), null, old.lastExit()));
|
||||
}));
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DiskOperation<T> { T run() throws IOException; }
|
||||
|
||||
private <T> CompletionStage<T> submit(DiskOperation<T> operation) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return operation.run();
|
||||
} catch (IOException exception) {
|
||||
throw new CompletionException(exception);
|
||||
}
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException exception) {
|
||||
return CompletableFuture.failedStage(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withLock(DiskOperation<T> operation) throws IOException {
|
||||
try {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe session directory");
|
||||
}
|
||||
Path lockPath = directory.resolve(".sessions.lock");
|
||||
if (Files.exists(lockPath, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe session coordination file");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Equipment session busy; retry the operation");
|
||||
return operation.run();
|
||||
}
|
||||
} catch (OverlappingFileLockException exception) {
|
||||
throw new IOException("Equipment session busy; retry the operation");
|
||||
} catch (IOException exception) {
|
||||
// Filesystem paths and parser data must not escape through diagnostics/cause chains.
|
||||
throw new IOException("Equipment session IO refused or failed; original state must be inspected");
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<State> load(UUID player) throws IOException {
|
||||
Path path = file(player);
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe session record");
|
||||
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] bytes = input.readNBytes(MAX_FILE_BYTES + 1);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Session record exceeds size limit");
|
||||
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
|
||||
State state = decode(text);
|
||||
if (!state.playerId().equals(player)) throw new IllegalArgumentException("Session player mismatch");
|
||||
return Optional.of(state);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("Invalid equipment session YAML");
|
||||
}
|
||||
}
|
||||
|
||||
private State write(State state) throws IOException {
|
||||
byte[] bytes = encode(state).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Session record exceeds size limit");
|
||||
Path temporary = Files.createTempFile(directory, ".session-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
// Do not fall back to truncating or non-atomic replacement of an existing record.
|
||||
replacement.move(temporary, file(state.playerId()));
|
||||
return state;
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private Path file(UUID player) { return directory.resolve(player + ".yml"); }
|
||||
|
||||
private static Yaml yaml() {
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(0);
|
||||
options.setNestingDepthLimit(8);
|
||||
options.setCodePointLimit(MAX_FILE_BYTES);
|
||||
return new Yaml(new SafeConstructor(options));
|
||||
}
|
||||
|
||||
private static String encode(State state) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("schema-version", 1);
|
||||
root.put("player-id", state.playerId().toString());
|
||||
root.put("revision", state.revision().toString());
|
||||
if (state.pending() != null) {
|
||||
var receipt = state.pending();
|
||||
var pending = new LinkedHashMap<String, Object>();
|
||||
pending.put("session-id", receipt.sessionId().toString());
|
||||
pending.put("character-id", receipt.characterId().toString());
|
||||
pending.put("prepared-at", receipt.preparedAt().toString());
|
||||
if (receipt.previousGameMode() != null) pending.put("previous-game-mode", receipt.previousGameMode());
|
||||
if (receipt.ordinaryVitals() != null) pending.put("ordinary-vitals", receipt.ordinaryVitals().encode());
|
||||
root.put("pending", pending);
|
||||
}
|
||||
if (state.lastExit() != null) {
|
||||
var exit = state.lastExit();
|
||||
root.put("last-exit", Map.of("session-id", exit.sessionId().toString(),
|
||||
"character-id", exit.characterId().toString(), "exited-at", exit.exitedAt().toString(),
|
||||
"deadline", exit.deadline().toString()));
|
||||
}
|
||||
return yaml().dump(root);
|
||||
}
|
||||
|
||||
private static State decode(String input) {
|
||||
Map<?, ?> root = mapping(yaml().load(input), Set.of("schema-version", "player-id", "revision"),
|
||||
Set.of("schema-version", "player-id", "revision", "pending", "last-exit"));
|
||||
if (!Integer.valueOf(1).equals(root.get("schema-version"))) {
|
||||
throw new IllegalArgumentException("Unsupported session schema");
|
||||
}
|
||||
Receipt pending = null;
|
||||
Exit exit = null;
|
||||
if (root.containsKey("pending")) {
|
||||
var keys = Set.of("session-id", "character-id", "prepared-at");
|
||||
var row = mapping(root.get("pending"), keys, Set.of("session-id", "character-id", "prepared-at", "previous-game-mode", "ordinary-vitals"));
|
||||
String previousMode = null;
|
||||
if (row.containsKey("previous-game-mode")) {
|
||||
if (!(row.get("previous-game-mode") instanceof String value)) throw new IllegalArgumentException("Invalid previous mode");
|
||||
previousMode = value;
|
||||
}
|
||||
pending = new Receipt(id(row.get("session-id")), id(row.get("character-id")), instant(row.get("prepared-at")), previousMode,
|
||||
row.containsKey("ordinary-vitals") ? OrdinaryVitals.decode(text(row.get("ordinary-vitals"))) : null);
|
||||
}
|
||||
if (root.containsKey("last-exit")) {
|
||||
var keys = Set.of("session-id", "character-id", "exited-at", "deadline");
|
||||
var row = mapping(root.get("last-exit"), keys, keys);
|
||||
exit = new Exit(id(row.get("session-id")), id(row.get("character-id")),
|
||||
instant(row.get("exited-at")), instant(row.get("deadline")));
|
||||
}
|
||||
String revision = text(root.get("revision"));
|
||||
if (!revision.matches("0|[1-9][0-9]*")) throw new IllegalArgumentException("Invalid revision");
|
||||
return new State(id(root.get("player-id")), new BigInteger(revision), pending, exit);
|
||||
}
|
||||
|
||||
private static Map<?, ?> mapping(Object value, Set<String> required, Set<String> allowed) {
|
||||
if (value instanceof Map<?, ?> map && map.keySet().containsAll(required) && allowed.containsAll(map.keySet())) {
|
||||
return map;
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid session mapping");
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof String string) return string;
|
||||
throw new IllegalArgumentException("Expected session string");
|
||||
}
|
||||
|
||||
private static UUID id(Object value) {
|
||||
String string = text(value);
|
||||
UUID id = UUID.fromString(string);
|
||||
if (!id.toString().equals(string)) throw new IllegalArgumentException("Noncanonical session UUID");
|
||||
return id;
|
||||
}
|
||||
|
||||
private static Instant instant(Object value) {
|
||||
String string = text(value);
|
||||
if (!string.matches("[0-9]{4}-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{1,9})?Z")) {
|
||||
throw new IllegalArgumentException("Expected UTC session timestamp");
|
||||
}
|
||||
return Instant.parse(string);
|
||||
}
|
||||
|
||||
private static void timestamp(Instant value) {
|
||||
instant(Objects.requireNonNull(value).toString());
|
||||
}
|
||||
|
||||
/** Nonblocking shutdown: accepted work drains; no recovery sweep, deletion or replay. */
|
||||
@Override public void close() { worker.shutdown(); }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.FrostShard;
|
||||
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 durable Frost Shard settings; publication remains a server-thread caller decision. */
|
||||
public final class FrostShardSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "frost-shard-base-damage";
|
||||
public static final String COOLDOWN = "frost-shard-cooldown-seconds";
|
||||
public static final String RANGE = "frost-shard-range-blocks";
|
||||
public static final String LIFETIME = "frost-shard-lifetime-seconds";
|
||||
public static final String SPEED = "frost-shard-projectile-speed-blocks-per-tick";
|
||||
public static final String AMPLIFIER = "frost-shard-slowness-amplifier";
|
||||
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
|
||||
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, FrostShard.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-frost-shard-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 FrostShardSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FrostShardSettings(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 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) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Frost Shard 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) || key.equals(AMPLIFIER)) 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 FrostShard.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
double range = finite(values.get(RANGE));
|
||||
var lifetime = seconds(values.get(LIFETIME));
|
||||
double speed = finite(values.get(SPEED));
|
||||
int amplifier = new java.math.BigInteger(values.get(AMPLIFIER)).intValueExact();
|
||||
var slowness = seconds(values.get(SLOWNESS));
|
||||
return new FrostShard.Settings(base, cooldown, range, lifetime, speed, amplifier, slowness);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Frost Shard settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Hex;
|
||||
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 durable Hex settings; publication remains a server-thread caller decision. */
|
||||
public final class HexSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "hex-cooldown-seconds";
|
||||
public static final String RANGE = "hex-range-blocks";
|
||||
public static final String DURATION = "hex-duration-seconds";
|
||||
public static final String REDUCTION = "hex-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, RANGE, DURATION, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "10", RANGE, "12", DURATION, "4", REDUCTION, "20");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Hex.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-hex-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 HexSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
HexSettings(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 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) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Hex 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Hex.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var range = finite(values.get(RANGE));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
|
||||
return new Hex.Settings(cooldown, range, duration, reduction);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Hex settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Inspire;
|
||||
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 durable Inspire settings; publication remains a server-thread caller decision. */
|
||||
public final class InspireSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "inspire-base-temporary-health";
|
||||
public static final String COOLDOWN = "inspire-cooldown-seconds";
|
||||
public static final String RANGE = "inspire-range-blocks";
|
||||
public static final String DURATION = "inspire-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RANGE, DURATION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE, "4", COOLDOWN, "15", RANGE, "12", DURATION, "6");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Inspire.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-inspire-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 InspireSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
InspireSettings(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 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) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Inspire 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)) 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 Inspire.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var range = finite(values.get(RANGE));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
return new Inspire.Settings(base, cooldown, range, duration);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Inspire settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Longbow;
|
||||
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 durable Longbow settings; publication remains a server-thread caller decision. */
|
||||
public final class LongbowSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "longbow-base-damage";
|
||||
public static final String RECOVERY = "longbow-recovery-seconds";
|
||||
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
|
||||
public static final String FULL_DRAW = "longbow-full-draw-ticks";
|
||||
public static final String RANGE = "longbow-range-blocks";
|
||||
public static final String LIFETIME = "longbow-lifetime-seconds";
|
||||
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
|
||||
LIFETIME, FULL_SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
|
||||
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Longbow.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-longbow-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 LongbowSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
LongbowSettings(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 Longbow.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Longbow 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) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
|
||||
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 Longbow.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var recovery = seconds(values.get(RECOVERY));
|
||||
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
|
||||
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
|
||||
double range = finite(values.get(RANGE));
|
||||
var lifetime = seconds(values.get(LIFETIME));
|
||||
double speed = finite(values.get(FULL_SPEED));
|
||||
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Longbow settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
/** Holder-owned ordinary state, never transferable character progress or an attribute snapshot. */
|
||||
public record OrdinaryVitals(double health, int food, float saturation, float exhaustion,
|
||||
boolean scaled, double scale) {
|
||||
public OrdinaryVitals {
|
||||
if (!Double.isFinite(health) || health < 0 || food < 0 || food > 20
|
||||
|| !Float.isFinite(saturation) || saturation < 0 || !Float.isFinite(exhaustion) || exhaustion < 0
|
||||
|| !Double.isFinite(scale) || scale <= 0) throw new IllegalArgumentException("Invalid ordinary vitals");
|
||||
}
|
||||
|
||||
public String encode() {
|
||||
return health + ";" + food + ";" + saturation + ";" + exhaustion + ";" + scaled + ";" + scale;
|
||||
}
|
||||
|
||||
public static OrdinaryVitals decode(String encoded) {
|
||||
if (encoded.length() > 256) throw new IllegalArgumentException("Oversized ordinary vitals");
|
||||
String[] fields = encoded.split(";", -1);
|
||||
if (fields.length != 6 || !fields[4].equals("true") && !fields[4].equals("false")) {
|
||||
throw new IllegalArgumentException("Invalid ordinary vitals fields");
|
||||
}
|
||||
var value = new OrdinaryVitals(Double.parseDouble(fields[0]), Integer.parseInt(fields[1]),
|
||||
Float.parseFloat(fields[2]), Float.parseFloat(fields[3]), Boolean.parseBoolean(fields[4]), Double.parseDouble(fields[5]));
|
||||
if (!encoded.equals(value.encode())) throw new IllegalArgumentException("Noncanonical ordinary vitals");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for party capacity checks and book presentation. */
|
||||
public final class PartySettings implements AutoCloseable {
|
||||
public static final String KEY="party-max-size";public static final String DEFAULT="6";
|
||||
private static final int MAX_BYTES=1_048_576;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,int maximum,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
|
||||
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
|
||||
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
|
||||
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
|
||||
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
|
||||
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Quickstep;
|
||||
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 durable Quickstep settings; publication remains a server-thread caller decision. */
|
||||
public final class QuickstepSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "quickstep-cooldown-seconds";
|
||||
public static final String DISTANCE = "quickstep-distance-blocks";
|
||||
public static final String SPEED = "quickstep-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Quickstep.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-quickstep-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 QuickstepSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
QuickstepSettings(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 Quickstep.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Quickstep 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static Quickstep.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
double distance = finite(values.get(DISTANCE));
|
||||
double speed = finite(values.get(SPEED));
|
||||
return new Quickstep.Settings(cooldown, distance, speed);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
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.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
|
||||
public final class RevivalStore implements AutoCloseable {
|
||||
private static final int MAX_FILE_BYTES = 32_768;
|
||||
private static final int MAX_FILES = 4096;
|
||||
private static final Set<String> KEYS_V1 = Set.of("schema-version", "reservation-id", "caster-character-id",
|
||||
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
|
||||
private static final Set<String> KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative"))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
|
||||
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
|
||||
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false);
|
||||
}
|
||||
public Reservation {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
|
||||
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|
||||
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|
||||
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
|
||||
throw new IllegalArgumentException("Invalid revival reservation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-revival-persistence"));
|
||||
|
||||
public RevivalStore(Path pluginDataDirectory) {
|
||||
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
|
||||
Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
Path file = file(reservation.character());
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
safeDirectory();
|
||||
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return Optional.of(reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> find(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return submit(() -> load(file(character)));
|
||||
}
|
||||
|
||||
public CompletableFuture<List<Reservation>> target(UUID player) {
|
||||
Objects.requireNonNull(player);
|
||||
return submit(() -> {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe revival directory");
|
||||
}
|
||||
var result = new ArrayList<Reservation>();
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.limit(MAX_FILES + 1).toList()) {
|
||||
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
|
||||
if (!path.getFileName().toString().endsWith(".yml")) continue;
|
||||
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
|
||||
if (reservation.targetPlayer().equals(player)) result.add(reservation);
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
var found = load(file(character));
|
||||
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
|
||||
return Files.deleteIfExists(file(character));
|
||||
});
|
||||
}
|
||||
|
||||
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("Unsafe revival directory");
|
||||
}
|
||||
} else Files.createDirectories(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
|
||||
|
||||
private Optional<Reservation> load(Path file) throws IOException {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
|
||||
Object decoded;
|
||||
try {
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
|
||||
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys");
|
||||
try {
|
||||
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))throw new IllegalArgumentException("schema");
|
||||
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
|
||||
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
|
||||
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
|
||||
integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative"));
|
||||
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
|
||||
return Optional.of(value);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
}
|
||||
|
||||
private static String encode(Reservation value) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
map.put("schema-version", "2"); map.put("reservation-id", value.id().toString());
|
||||
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
|
||||
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
|
||||
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
|
||||
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
|
||||
map.put("administrative", Boolean.toString(value.administrative()));
|
||||
return new Yaml().dump(map);
|
||||
}
|
||||
|
||||
private static String text(Map<?, ?> map, String key) {
|
||||
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
|
||||
}
|
||||
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
|
||||
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
|
||||
private static boolean bool(Map<?,?> map,String key){String value=text(map,key);if(!value.equals("true")&&!value.equals("false"))throw new IllegalArgumentException(key);return Boolean.parseBoolean(value);}
|
||||
|
||||
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
|
||||
private <T> CompletableFuture<T> submit(Work<T> work) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
|
||||
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
|
||||
}
|
||||
@Override public void close() { worker.shutdown(); }
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for roleplay availability and coordinated exits. */
|
||||
public final class RoleplaySettings implements AutoCloseable {
|
||||
public static final String KEY = "roleplay-disabled";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(boolean disabled, long sequence) { }
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-roleplay-configuration"));
|
||||
private volatile boolean disabled;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public RoleplaySettings(Path directory, boolean initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
RoleplaySettings(Path directory, boolean initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
disabled = initial;
|
||||
}
|
||||
|
||||
public static boolean parse(Object value) {
|
||||
if (value == null) return false;
|
||||
if (value instanceof Boolean parsed) return parsed;
|
||||
throw new IllegalArgumentException(KEY + " must be true or false");
|
||||
}
|
||||
|
||||
public boolean disabled() { return disabled; }
|
||||
public String value() { return Boolean.toString(disabled); }
|
||||
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
if (!"true".equals(raw) && !"false".equals(raw)) {
|
||||
throw new IllegalArgumentException(KEY + " must be true or false");
|
||||
}
|
||||
boolean accepted = Boolean.parseBoolean(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(accepted);
|
||||
return new Update(accepted, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Roleplay configuration save refused or failed; effective setting unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
disabled = update.disabled();
|
||||
publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
values.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put(KEY, value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
worker.shutdown();
|
||||
if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.SecondWind;
|
||||
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 durable SecondWind settings; publication remains a server-thread caller decision. */
|
||||
public final class SecondWindSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_HEALING = "second-wind-base-healing";
|
||||
public static final String COOLDOWN = "second-wind-cooldown-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, SecondWind.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-second-wind-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 SecondWindSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SecondWindSettings(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 SecondWind.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("SecondWind 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_HEALING)) 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 SecondWind.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_HEALING));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
return new SecondWind.Settings(base, cooldown);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
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;
|
||||
|
||||
/** Durable paired resource-pack overrides; each publication carries the latest complete saved snapshot. */
|
||||
public final class SheetResourcePackSettings implements AutoCloseable {
|
||||
public static final String URL_KEY = "sheet-resource-pack-url";
|
||||
public static final String SHA1_KEY = "sheet-resource-pack-sha1";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Defaults(String url, String sha1) {
|
||||
public Defaults { url = effectiveUrl(url, new DefaultsMarker()); sha1 = normalizeHash(sha1, false); }
|
||||
}
|
||||
private static final class DefaultsMarker { }
|
||||
public record Snapshot(String configuredUrl, String configuredSha1, String url, String sha1) { }
|
||||
public record Update(Snapshot snapshot, long sequence) { }
|
||||
private final Path directory;
|
||||
private final Defaults defaults;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker;
|
||||
private volatile Snapshot effective;
|
||||
private String persistedUrl;
|
||||
private String persistedSha1;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public SheetResourcePackSettings(Path directory, Object configuredUrl, Object configuredSha1, Defaults defaults) {
|
||||
this(directory, raw(configuredUrl, URL_KEY), raw(configuredSha1, SHA1_KEY), defaults,
|
||||
(from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SheetResourcePackSettings(Path directory, String configuredUrl, String configuredSha1, Defaults defaults, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.defaults = Objects.requireNonNull(defaults);
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = snapshot(configuredUrl, configuredSha1);
|
||||
worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "fantasy-sheet-resource-pack-configuration"));
|
||||
persistedUrl = configuredUrl;
|
||||
persistedSha1 = configuredSha1;
|
||||
}
|
||||
|
||||
public static Defaults packagedDefaults() {
|
||||
try (var input = SheetResourcePackSettings.class.getResourceAsStream("/sheet-resource-pack.properties")) {
|
||||
if (input == null) throw new IOException("Missing packaged sheet resource-pack metadata");
|
||||
var values = new Properties(); values.load(input);
|
||||
return new Defaults(values.getProperty("url"), values.getProperty("sha1"));
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
throw new IllegalStateException("Invalid packaged sheet resource-pack metadata", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String raw(Object value, String key) {
|
||||
if (value == null) return "";
|
||||
if (value instanceof String text) return text;
|
||||
throw new IllegalArgumentException(key + " must be a string");
|
||||
}
|
||||
|
||||
public static String effectiveUrl(String configured, Defaults defaults) {
|
||||
Objects.requireNonNull(defaults);
|
||||
return configured == null || configured.isEmpty() ? defaults.url() : validateUrl(configured);
|
||||
}
|
||||
|
||||
private static String effectiveUrl(String value, DefaultsMarker ignored) { return validateUrl(value); }
|
||||
|
||||
private static String validateUrl(String value) {
|
||||
if (value == null || value.length() > 2048 || value.chars().anyMatch(Character::isWhitespace)) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL");
|
||||
}
|
||||
try {
|
||||
var uri = URI.create(value);
|
||||
if (!"https".equals(uri.getScheme()) || uri.getHost() == null || uri.getHost().isBlank()
|
||||
|| uri.getRawUserInfo() != null || uri.getRawFragment() != null
|
||||
|| uri.getRawPath() == null || uri.getRawPath().isBlank()) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be an absolute HTTPS asset URL without credentials or fragment");
|
||||
}
|
||||
return uri.toASCIIString();
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
public static String effectiveSha1(String configured, Defaults defaults) {
|
||||
Objects.requireNonNull(defaults);
|
||||
return configured == null || configured.isEmpty() ? defaults.sha1() : normalizeHash(configured, false);
|
||||
}
|
||||
|
||||
private static String normalizeHash(String value, boolean allowEmpty) {
|
||||
if (allowEmpty && (value == null || value.isEmpty())) return "";
|
||||
if (value == null || !value.matches("[a-fA-F0-9]{40}")) {
|
||||
throw new IllegalArgumentException(SHA1_KEY + " must be exactly 40 hexadecimal characters");
|
||||
}
|
||||
return value.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private Snapshot snapshot(String configuredUrl, String configuredSha1) {
|
||||
String url = configuredUrl == null ? "" : configuredUrl;
|
||||
String sha1 = configuredSha1 == null ? "" : normalizeHash(configuredSha1, true);
|
||||
return new Snapshot(url, sha1, effectiveUrl(url, defaults), effectiveSha1(sha1, defaults));
|
||||
}
|
||||
|
||||
public String url() { return effective.url(); }
|
||||
public String sha1() { return effective.sha1(); }
|
||||
public String value(String key) {
|
||||
var state = effective;
|
||||
return switch (key) {
|
||||
case URL_KEY -> state.url();
|
||||
case SHA1_KEY -> state.sha1();
|
||||
default -> throw new IllegalArgumentException("Unknown sheet resource-pack key");
|
||||
};
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String raw) {
|
||||
if (!URL_KEY.equals(key) && !SHA1_KEY.equals(key)) throw new IllegalArgumentException("Unknown sheet resource-pack key");
|
||||
if (raw == null) throw new IllegalArgumentException(key + " requires a value");
|
||||
if (URL_KEY.equals(key) && !raw.isEmpty()) validateUrl(raw);
|
||||
if (SHA1_KEY.equals(key)) raw = normalizeHash(raw, true);
|
||||
final String accepted = raw;
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(key, accepted);
|
||||
if (URL_KEY.equals(key)) persistedUrl = accepted; else persistedSha1 = accepted;
|
||||
return new Update(snapshot(persistedUrl, persistedSha1), ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Sheet resource-pack 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"));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = update.snapshot();
|
||||
publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
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> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String name)) throw new IOException("Invalid configuration key");
|
||||
values.put(name, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put(key, value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.ShieldBash;
|
||||
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 durable Shield Bash settings; publication remains a server-thread caller decision. */
|
||||
public final class ShieldBashSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "shield-bash-base-damage";
|
||||
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
|
||||
public static final String RANGE = "shield-bash-range-blocks";
|
||||
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
|
||||
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
|
||||
HORIZONTAL, "0.8", VERTICAL, "0.35");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ShieldBash.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-shield-bash-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 ShieldBashSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
ShieldBashSettings(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 ShieldBash.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Shield Bash 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 ShieldBash.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
|
||||
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Shield Bash settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.StoneSkin;
|
||||
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 durable Stone Skin settings; publication remains a server-thread caller decision. */
|
||||
public final class StoneSkinSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
|
||||
public static final String DURATION = "stone-skin-duration-seconds";
|
||||
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, StoneSkin.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-stone-skin-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 StoneSkinSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
StoneSkinSettings(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 StoneSkin.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
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); // Fail malformed/out-of-range command input before queueing accepted persistence.
|
||||
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("Stone Skin 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 {
|
||||
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static StoneSkin.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
var cooldown = seconds(values.get(COOLDOWN));
|
||||
var duration = seconds(values.get(DURATION));
|
||||
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
|
||||
return new StoneSkin.Settings(cooldown, duration, reduction);
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for character-switch delay checks and later exits. */
|
||||
public final class SwitchDelaySettings implements AutoCloseable {
|
||||
public static final String KEY="character-swap-cooldown-seconds";public static final String DEFAULT="900";
|
||||
private static final int MAX_BYTES=1_048_576;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,Duration delay,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-switch-delay-configuration"));
|
||||
private volatile Duration effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public SwitchDelaySettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
SwitchDelaySettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
|
||||
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(900);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds",invalid);}}
|
||||
private static Duration validate(Duration value){if(value.isNegative()||value.getNano()!=0)throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds");try{var deadline=java.time.Instant.now().plus(value);if(deadline.atOffset(java.time.ZoneOffset.UTC).getYear()>9999)throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range");return value;}catch(java.time.DateTimeException|ArithmeticException invalid){throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range",invalid);}}
|
||||
public Duration delay(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
|
||||
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Character switch configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.delay());publishedSequence=update.sequence();}
|
||||
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -37,26 +39,70 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
|
||||
public void create(CharacterRecord character) throws IOException {
|
||||
createTracked(character);
|
||||
}
|
||||
|
||||
/** Process-local proof; never reconstructed from a revision-zero file after restart. */
|
||||
static final class Creation {
|
||||
final CharacterRecord record;
|
||||
private final BasicFileAttributes attributes;
|
||||
private final byte[] bytes;
|
||||
|
||||
private Creation(CharacterRecord record, BasicFileAttributes attributes, byte[] bytes) {
|
||||
this.record = record;
|
||||
this.attributes = attributes;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
|
||||
Creation createTracked(CharacterRecord character) throws IOException {
|
||||
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
|
||||
withLock(character.id(), () -> {
|
||||
Creation[] created = new Creation[1];
|
||||
withLock(() -> {
|
||||
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Character already exists: " + character.id());
|
||||
}
|
||||
write(character);
|
||||
created[0] = write(character);
|
||||
});
|
||||
return created[0];
|
||||
}
|
||||
|
||||
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(character.id(), () -> {
|
||||
withLock(() -> {
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
});
|
||||
}
|
||||
|
||||
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
|
||||
boolean discardUnissued(Creation created) throws IOException {
|
||||
boolean[] removed = {false};
|
||||
withLock(() -> {
|
||||
var path = file(created.record.id());
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
removed[0] = true;
|
||||
return;
|
||||
}
|
||||
var current = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
var expected = created.attributes;
|
||||
if (!current.isRegularFile() || expected.fileKey() == null
|
||||
|| !expected.fileKey().equals(current.fileKey())
|
||||
|| !expected.creationTime().equals(current.creationTime())
|
||||
|| !expected.lastModifiedTime().equals(current.lastModifiedTime())
|
||||
|| current.size() != created.bytes.length) return;
|
||||
try (var input = Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Arrays.equals(created.bytes, input.readNBytes(created.bytes.length + 1))) return;
|
||||
}
|
||||
Files.delete(path);
|
||||
removed[0] = true;
|
||||
});
|
||||
return removed[0];
|
||||
}
|
||||
|
||||
public Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
Path file = file(id);
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
@@ -73,7 +119,7 @@ public final class YamlCharacterRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private void write(CharacterRecord character) throws IOException {
|
||||
private Creation write(CharacterRecord character) throws IOException {
|
||||
byte[] bytes = CharacterYaml.encode(character).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
|
||||
Path temporary = Files.createTempFile(directory, ".character-", ".tmp");
|
||||
@@ -83,16 +129,19 @@ public final class YamlCharacterRepository {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
var attributes = Files.readAttributes(temporary, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
// Never fall back to an in-place, partially written character record.
|
||||
replacement.move(temporary, file(character.id()));
|
||||
return new Creation(character, attributes, bytes);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private void withLock(UUID id, Operation operation) throws IOException {
|
||||
/** Bounded coordination for all mutations; never mix with older per-ID writers in this directory. */
|
||||
private void withLock(Operation operation) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
Path lockPath = directory.resolve(id + ".lock");
|
||||
Path lockPath = directory.resolve(".records.lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
/** Substitutes only the renderer's sender-name argument; signed body and delivery stay native. */
|
||||
public final class CharacterChatNames implements Listener {
|
||||
private final Function<UUID, String> names;
|
||||
public CharacterChatNames(Function<UUID, String> names) { this.names = names; }
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onChat(AsyncChatEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
String name = names.apply(event.getPlayer().getUniqueId());
|
||||
if (name == null) return;
|
||||
var literal = net.kyori.adventure.text.Component.text(name);
|
||||
var ordinary = event.renderer();
|
||||
event.renderer((source, ignoredName, message, viewer) -> ordinary.render(source, literal, message, viewer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Immutable session decisions shared by chat/packet threads; native output stays on the server thread. */
|
||||
public final class CharacterPresentation implements SessionPresentation, AutoCloseable {
|
||||
public record View(UUID holder, UUID session, UUID character, BigInteger revision, String name) { }
|
||||
public interface Output extends AutoCloseable {
|
||||
void show(Player player, View view);
|
||||
void rename(View view);
|
||||
void clear(View view);
|
||||
@Override void close();
|
||||
}
|
||||
private final Output output;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final BooleanSupplier mainThread;
|
||||
private final Consumer<Throwable> failures;
|
||||
private volatile Map<UUID, View> views = Map.of();
|
||||
private boolean closed;
|
||||
private java.util.function.Function<UUID, java.util.concurrent.CompletionStage<java.util.Optional<CharacterRecord>>> currentRecord =
|
||||
ignored -> java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.empty());
|
||||
public CharacterPresentation(Output output, Consumer<Runnable> nextTick, BooleanSupplier mainThread, Consumer<Throwable> failures) {
|
||||
this.output = output; this.nextTick = nextTick; this.mainThread = mainThread; this.failures = failures;
|
||||
}
|
||||
public CharacterPresentation(Output output, Consumer<Runnable> nextTick, BooleanSupplier mainThread, Consumer<Throwable> failures,
|
||||
java.util.function.Function<UUID, java.util.concurrent.CompletionStage<java.util.Optional<CharacterRecord>>> currentRecord) {
|
||||
this(output, nextTick, mainThread, failures);
|
||||
this.currentRecord = java.util.Objects.requireNonNull(currentRecord);
|
||||
}
|
||||
public Map<UUID, View> snapshot() { return views; }
|
||||
public String name(UUID holder) { var view = views.get(holder); return view == null ? null : view.name(); }
|
||||
@Override public synchronized void activate(Player player, UUID session, CharacterRecord record) {
|
||||
if (closed) return;
|
||||
UUID holder = player.getUniqueId();
|
||||
var previous = views.get(holder);
|
||||
if (previous != null) withdraw(holder, previous.session());
|
||||
var view = new View(holder, session, record.id(), record.revision(), record.name());
|
||||
put(view);
|
||||
safely(() -> output.show(player, view));
|
||||
if (!closed && view.equals(views.get(holder))) safely(() -> currentRecord.apply(record.id()).whenComplete((saved, failure) -> {
|
||||
if (failure != null) { safely(() -> { throw new java.util.concurrent.CompletionException(failure); }); return; }
|
||||
saved.ifPresent(fresh -> acknowledge(view, fresh));
|
||||
}));
|
||||
}
|
||||
@Override public synchronized void withdraw(UUID player, UUID session) {
|
||||
var view = views.get(player);
|
||||
if (view == null || !view.session().equals(session)) return;
|
||||
var copy = new java.util.HashMap<>(views); copy.remove(player); views = Map.copyOf(copy);
|
||||
// Invalidate synchronously even on a worker failure; never access Bukkit on that worker.
|
||||
if (mainThread.getAsBoolean()) safely(() -> output.clear(view));
|
||||
else safely(() -> nextTick.accept(() -> safely(() -> output.clear(view))));
|
||||
}
|
||||
/** File acknowledgment, not the physical sheet name. Safe to call from the persistence worker. */
|
||||
public void acknowledged(CharacterRecord record) {
|
||||
// Capture lifetime tokens now. A late callback cannot name a replacement holder/session.
|
||||
for (var view : views.values()) acknowledge(view, record);
|
||||
}
|
||||
private void acknowledge(View target, CharacterRecord record) {
|
||||
if (!newer(target, record)) return;
|
||||
safely(() -> nextTick.accept(() -> {
|
||||
synchronized (CharacterPresentation.this) {
|
||||
if (closed || !newer(target, record)) return;
|
||||
var updated = new View(target.holder(), target.session(), target.character(), record.revision(), record.name());
|
||||
put(updated);
|
||||
safely(() -> output.rename(updated));
|
||||
}
|
||||
}));
|
||||
}
|
||||
private boolean newer(View target, CharacterRecord record) {
|
||||
var current = views.get(target.holder());
|
||||
return current != null && current.session().equals(target.session()) && record.id().equals(current.character())
|
||||
&& !record.dead() && record.revision().compareTo(current.revision()) > 0;
|
||||
}
|
||||
private void put(View view) {
|
||||
var copy = new java.util.HashMap<>(views); copy.put(view.holder(), view); views = Map.copyOf(copy);
|
||||
}
|
||||
private void safely(Runnable action) {
|
||||
try { action.run(); }
|
||||
catch (RuntimeException | LinkageError failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException | LinkageError ignored) { /* Cleanup must continue. */ }
|
||||
}
|
||||
}
|
||||
@Override public synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (var view : java.util.List.copyOf(views.values())) withdraw(view.holder(), view.session());
|
||||
safely(output::close);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Entry;
|
||||
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.AttributeSnapshot;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Display.BillboardConstraints;
|
||||
import net.minecraft.world.entity.Display.TextDisplay;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.entity.PositionMoveRotation;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.level.GameType;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Display-only codec pinned to Purpur 26.2 build 2618. No packet delivery, world insertion,
|
||||
* profile/team changes, or authoritative player mutations belong here.
|
||||
*
|
||||
* <p>Player reads and detached-label creation/updates belong on the server thread. Pure
|
||||
* packet projections accept already-captured session identity/text; the caller owns visibility,
|
||||
* per-viewer range, lifecycle checks and removing each viewer's client-only labels.
|
||||
*/
|
||||
public final class NativePresentationPackets {
|
||||
private NativePresentationPackets() { }
|
||||
|
||||
/**
|
||||
* Replace only the selected UUID's display component when its action is already present.
|
||||
* Preserve all other entry fields and the exact action set, including mixed ADD packets.
|
||||
* ADD without UPDATE_DISPLAY_NAME needs a separate targeted update after acceptance.
|
||||
*/
|
||||
public static ClientboundPlayerInfoUpdatePacket projectPlayerInfo(
|
||||
ClientboundPlayerInfoUpdatePacket packet, UUID activePlayer, String name) {
|
||||
var actions = packet.actions();
|
||||
if (!actions.contains(Action.UPDATE_DISPLAY_NAME)) {
|
||||
return packet;
|
||||
}
|
||||
var entries = new ArrayList<Entry>(packet.entries().size());
|
||||
boolean changed = false;
|
||||
for (var entry : packet.entries()) {
|
||||
if (entry.profileId().equals(activePlayer)) {
|
||||
entries.add(new Entry(entry.profileId(), entry.profile(), entry.listed(), entry.latency(),
|
||||
entry.gameMode(), literal(name), entry.showHat(), entry.listOrder(), entry.chatSession()));
|
||||
changed = true;
|
||||
} else {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
if (!changed) { return packet; }
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.copyOf(actions), entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an existing NAME_TAG_DISTANCE snapshot with base zero/no modifiers, only for
|
||||
* the selected entity. Do not inject absent attributes; use suppressNameTag on activation.
|
||||
*/
|
||||
public static ClientboundUpdateAttributesPacket projectAttributes(
|
||||
ClientboundUpdateAttributesPacket packet, int activeEntityId) {
|
||||
if (packet.getEntityId() != activeEntityId || packet.getValues().stream()
|
||||
.noneMatch(value -> value.attribute().equals(Attributes.NAME_TAG_DISTANCE))) {
|
||||
return packet;
|
||||
}
|
||||
var projected = new ClientboundUpdateAttributesPacket(activeEntityId, List.of());
|
||||
for (var value : packet.getValues()) {
|
||||
projected.getValues().add(value.attribute().equals(Attributes.NAME_TAG_DISTANCE)
|
||||
? new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE, 0, List.of()) : value);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
/** One client-only distance snapshot; never changes the server's attribute instance. */
|
||||
public static ClientboundUpdateAttributesPacket suppressNameTag(int entityId) {
|
||||
var packet = new ClientboundUpdateAttributesPacket(entityId, List.of());
|
||||
packet.getValues().add(new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE, 0, List.of()));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** Snapshot the current authoritative distance base and all modifiers, not an old default. */
|
||||
public static ClientboundUpdateAttributesPacket restoreNameTag(ServerPlayer player) {
|
||||
var distance = player.getAttribute(Attributes.NAME_TAG_DISTANCE);
|
||||
if (distance == null) {
|
||||
throw new IllegalStateException("Native player has no NAME_TAG_DISTANCE attribute");
|
||||
}
|
||||
var packet = new ClientboundUpdateAttributesPacket(player.getId(), List.of());
|
||||
packet.getValues().add(new AttributeSnapshot(Attributes.NAME_TAG_DISTANCE,
|
||||
distance.getBaseValue(), List.copyOf(distance.getModifiers())));
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** UPDATE_DISPLAY_NAME only, using the current ordinary native tab display (including null). */
|
||||
public static ClientboundPlayerInfoUpdatePacket refreshTab(ServerPlayer player) {
|
||||
// Only UUID and displayName are serialized by this action. Do not capture unrelated
|
||||
// connection/game-mode state or synthesize ADD_PLAYER/listed/profile updates.
|
||||
var ordinary = player.getTabListDisplayName();
|
||||
var entry = new Entry(player.getUUID(), null, false, 0,
|
||||
GameType.DEFAULT_MODE, ordinary == null ? null : ordinary.copy(),
|
||||
false, 0, null);
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.UPDATE_DISPLAY_NAME), List.of(entry));
|
||||
}
|
||||
|
||||
/** Pure targeted display update: no profile, listed, game-mode or reconnect actions. */
|
||||
public static ClientboundPlayerInfoUpdatePacket displayName(UUID player, String name) {
|
||||
var entry = new Entry(player, null, false, 0, GameType.DEFAULT_MODE, literal(name), false, 0, null);
|
||||
return new ClientboundPlayerInfoUpdatePacket(EnumSet.of(Action.UPDATE_DISPLAY_NAME), List.of(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a detached TextDisplay in the player's current native level at explicit coordinates.
|
||||
* Native construction allocates its ID through ServerLevel.getNextEntityId; never register it.
|
||||
* The caller chooses overhead position and recreates the label after world changes.
|
||||
*/
|
||||
public static Label createLabel(ServerPlayer player, String text, double x, double y, double z) {
|
||||
validatePosition(x, y, z);
|
||||
var component = literal(text);
|
||||
var level = player.level();
|
||||
if (level == null) { throw new IllegalStateException("Native player has no current server level"); }
|
||||
// A native constructor supplies the exact metadata schema and native ID allocation.
|
||||
// Never spawn, tick, wrap in a Bukkit entity, or expose this detached instance.
|
||||
var display = new TextDisplay(EntityTypes.TEXT_DISPLAY, level);
|
||||
display.persist = false;
|
||||
display.setNoGravity(true);
|
||||
display.setBillboardConstraints(BillboardConstraints.CENTER);
|
||||
display.getEntityData().set(TextDisplay.DATA_LINE_WIDTH_ID, Integer.MAX_VALUE);
|
||||
display.setFlags((byte) (TextDisplay.FLAG_SHADOW | TextDisplay.FLAG_USE_DEFAULT_BACKGROUND));
|
||||
display.setText(component);
|
||||
display.setPos(x, y, z);
|
||||
return new Label(display);
|
||||
}
|
||||
|
||||
private static Component literal(String text) {
|
||||
if (text == null) { throw new IllegalArgumentException("Display text must not be null"); }
|
||||
return Component.literal(text);
|
||||
}
|
||||
|
||||
private static void validatePosition(double x, double y, double z) {
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) {
|
||||
throw new IllegalArgumentException("Label coordinates must be finite");
|
||||
}
|
||||
}
|
||||
|
||||
/** A session-owned detached entity; no native entity is exposed for accidental world insertion. */
|
||||
public static final class Label {
|
||||
private final TextDisplay display;
|
||||
|
||||
private Label(TextDisplay display) { this.display = display; }
|
||||
|
||||
public int id() { return display.getId(); }
|
||||
public UUID uuid() { return display.getUUID(); }
|
||||
|
||||
/** Send spawn followed by metadata to each newly eligible viewer. */
|
||||
public ClientboundAddEntityPacket spawn() {
|
||||
return new ClientboundAddEntityPacket(id(), uuid(), display.getX(), display.getY(), display.getZ(),
|
||||
0, 0, display.getType(), 0, Vec3.ZERO, 0);
|
||||
}
|
||||
|
||||
/** Complete metadata, not consumable packDirty state: safe for multiple viewers. */
|
||||
public ClientboundSetEntityDataPacket metadata() {
|
||||
return new ClientboundSetEntityDataPacket(id(), display.getEntityData().packAll());
|
||||
}
|
||||
|
||||
/** Update only the detached label and return an absolute teleport packet. */
|
||||
public ClientboundTeleportEntityPacket move(double x, double y, double z) {
|
||||
validatePosition(x, y, z);
|
||||
display.setPos(x, y, z);
|
||||
return ClientboundTeleportEntityPacket.teleport(id(),
|
||||
PositionMoveRotation.of(display), Set.of(), false);
|
||||
}
|
||||
|
||||
/** Replace only the detached label's literal text and return complete metadata. */
|
||||
public ClientboundSetEntityDataPacket rename(String text) {
|
||||
display.setText(literal(text));
|
||||
return metadata();
|
||||
}
|
||||
|
||||
/** Client-only removal; never calls entity discard/remove on the server world. */
|
||||
public ClientboundRemoveEntitiesPacket remove() {
|
||||
return new ClientboundRemoveEntitiesPacket(id());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.ListenerPriority;
|
||||
import com.comphenix.protocol.events.PacketAdapter;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.events.PacketEvent;
|
||||
import com.comphenix.protocol.events.NetworkMarker;
|
||||
import com.comphenix.protocol.events.PacketPostListener;
|
||||
import games.dmg.fantasystick.presentation.CharacterPresentation.View;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundBundlePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundRespawnPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
|
||||
import net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/**
|
||||
* Pinned native output adapter. Public lifecycle methods and the scheduled reconciliation run
|
||||
* on the server thread. Packet callbacks use only immutable decisions, the parent's published
|
||||
* snapshot, packet values and recipient UUIDs. No authoritative presentation is ever changed.
|
||||
* The snapshot supplier is not read during construction: the owner may initialize its field
|
||||
* lazily, then publish a View before calling show/rename. Diagnostics may run on packet threads.
|
||||
*/
|
||||
public final class ProtocolPresentation implements CharacterPresentation.Output {
|
||||
private final Plugin plugin;
|
||||
private final ProtocolManager protocol;
|
||||
private final Supplier<Map<UUID, View>> snapshot;
|
||||
private final Consumer<Throwable> diagnostics;
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private final PacketAdapter listener;
|
||||
// Cancelled events never post. Weak keys release their immutable context with the marker.
|
||||
private final Map<NetworkMarker, Map<UUID, Decision>> deliveries = new WeakHashMap<>();
|
||||
private final PacketPostListener postListener = new PacketPostListener() {
|
||||
@Override public Plugin getPlugin() { return plugin; }
|
||||
@Override public void onPostEvent(PacketEvent event) { accepted(event); }
|
||||
};
|
||||
private record Decision(View view, int entity, Map<UUID, Integer> labels) { }
|
||||
private Map<UUID, Decision> decisions = Map.of();
|
||||
private record Route(UUID holder, UUID session, UUID viewer) { }
|
||||
private final Map<Route, Integer> blocked = new HashMap<>();
|
||||
private final Set<Route> retired = new HashSet<>();
|
||||
private final Set<Route> tracking = new HashSet<>();
|
||||
private static final int UNTRACKED = 1;
|
||||
private static final int INVISIBLE = 2;
|
||||
private static final int FAILED = 4;
|
||||
private static final int TAB_REMOVED = 8;
|
||||
private BukkitTask task;
|
||||
private boolean installed;
|
||||
private boolean closed;
|
||||
private int warnings;
|
||||
private boolean operating;
|
||||
private boolean draining;
|
||||
private final ArrayDeque<Runnable> pending = new ArrayDeque<>();
|
||||
|
||||
private static final class State {
|
||||
private final Player player;
|
||||
private int entity;
|
||||
private View view;
|
||||
private org.bukkit.World world;
|
||||
private NativePresentationPackets.Label label;
|
||||
private final Map<UUID, Player> viewers = new HashMap<>();
|
||||
private State(Player player, View view) {
|
||||
this.player = player; this.view = view; this.entity = player.getEntityId();
|
||||
this.world = player.getWorld();
|
||||
}
|
||||
}
|
||||
|
||||
public ProtocolPresentation(Plugin plugin, ProtocolManager protocol,
|
||||
Supplier<Map<UUID, View>> snapshot, Consumer<Throwable> diagnostics) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.protocol = Objects.requireNonNull(protocol);
|
||||
this.snapshot = Objects.requireNonNull(snapshot);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
listener = new PacketAdapter(plugin, ListenerPriority.HIGHEST,
|
||||
PacketType.Play.Server.PLAYER_INFO, PacketType.Play.Server.UPDATE_ATTRIBUTES,
|
||||
PacketType.Play.Server.ENTITY_DESTROY, PacketType.Play.Server.ENTITY_METADATA,
|
||||
PacketType.Play.Server.SPAWN_ENTITY, PacketType.Play.Server.PLAYER_INFO_REMOVE,
|
||||
PacketType.Play.Server.RESPAWN, PacketType.Play.Server.BUNDLE) {
|
||||
@Override public void onPacketSending(PacketEvent event) { project(event); }
|
||||
};
|
||||
}
|
||||
|
||||
/** Idempotent; the supplied manager is the sole ProtocolLib dependency. */
|
||||
public synchronized void install() {
|
||||
if (closed || installed) { return; }
|
||||
installed = true;
|
||||
try {
|
||||
protocol.addPacketListener(listener);
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 1L, 1L);
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
report(failure); close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void show(Player player, View view) {
|
||||
if (!begin(() -> show(player, view))) { return; }
|
||||
try {
|
||||
if (closed || !view.equals(snapshot.get().get(view.holder()))
|
||||
|| !player.getUniqueId().equals(view.holder())) { return; }
|
||||
var old = states.get(view.holder());
|
||||
if (old != null) {
|
||||
if (sameSession(old.view, view)) { rename(view); return; }
|
||||
states.remove(view.holder());
|
||||
removeLabels(old);
|
||||
forget(old);
|
||||
}
|
||||
if (closed || !view.equals(snapshot.get().get(view.holder())) || states.containsKey(view.holder())) { return; }
|
||||
var state = new State(player, view);
|
||||
states.put(view.holder(), state);
|
||||
// Existing trackers predate activation; subsequent viewers need an accepted pairing.
|
||||
for (var viewer : player.getTrackedBy()) { tracking.add(route(view, viewer.getUniqueId())); }
|
||||
publish();
|
||||
refreshTab(state, true);
|
||||
reconcile(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
@Override public synchronized void rename(View view) {
|
||||
if (!begin(() -> rename(view))) { return; }
|
||||
try {
|
||||
var state = states.get(view.holder());
|
||||
if (closed || state == null || !sameSession(state.view, view)
|
||||
|| !view.equals(snapshot.get().get(view.holder()))
|
||||
|| view.revision().compareTo(state.view.revision()) < 0) { return; }
|
||||
state.view = view;
|
||||
publish();
|
||||
refreshTab(state, true);
|
||||
reconcile(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
@Override public synchronized void clear(View view) {
|
||||
if (!begin(() -> clear(view))) { return; }
|
||||
try {
|
||||
var state = states.get(view.holder());
|
||||
if (state == null || !sameSession(state.view, view)) { return; }
|
||||
states.remove(view.holder());
|
||||
publish();
|
||||
removeLabels(state);
|
||||
forget(state);
|
||||
restore(state);
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
private synchronized void tick() {
|
||||
if (!begin(this::tick)) { return; }
|
||||
try {
|
||||
if (closed) { return; }
|
||||
for (var state : List.copyOf(states.values())) {
|
||||
try {
|
||||
if (!state.player.isOnline() || !sameSession(state.view, snapshot.get().get(state.view.holder()))) {
|
||||
clear(state.view);
|
||||
} else { reconcile(state); }
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
report(failure); removeLabels(state);
|
||||
}
|
||||
}
|
||||
} finally { finish(); }
|
||||
}
|
||||
|
||||
private boolean current(State state, View expected) {
|
||||
return !closed && states.get(expected.holder()) == state && expected.equals(state.view)
|
||||
&& expected.equals(snapshot.get().get(expected.holder()));
|
||||
}
|
||||
|
||||
private boolean deliver(State state, View expected, Player viewer, Object packet) {
|
||||
// A stale decision is skipped, not classified as an uncertain transport failure.
|
||||
return !current(state, expected) || send(viewer, packet);
|
||||
}
|
||||
|
||||
private void reconcile(State state) {
|
||||
var expected = state.view;
|
||||
if (!current(state, expected)) { return; }
|
||||
try {
|
||||
var player = state.player;
|
||||
for (var id : List.copyOf(state.viewers.keySet())) {
|
||||
if (retired.remove(route(state.view, id))) { state.viewers.remove(id); }
|
||||
}
|
||||
publish();
|
||||
var location = player.getLocation();
|
||||
if (state.world != player.getWorld() || state.entity != player.getEntityId()) {
|
||||
removeLabels(state);
|
||||
if (!current(state, expected)) { return; }
|
||||
state.label = null;
|
||||
state.world = player.getWorld();
|
||||
state.entity = player.getEntityId();
|
||||
// A world/entity change is not delivery evidence. Only an accepted native
|
||||
// spawn may clear UNTRACKED/INVISIBLE, never a tick with stale platform trackers.
|
||||
publish();
|
||||
}
|
||||
if (state.label == null) {
|
||||
state.label = NativePresentationPackets.createLabel(((CraftPlayer) player).getHandle(),
|
||||
state.view.name(), location.getX(), location.getY() + player.getHeight() + 0.3, location.getZ());
|
||||
}
|
||||
var move = state.label.move(location.getX(), location.getY() + player.getHeight() + 0.3, location.getZ());
|
||||
var metadata = state.label.rename(state.view.name());
|
||||
var tracked = player.getTrackedBy();
|
||||
for (var entry : List.copyOf(state.viewers.entrySet())) {
|
||||
if (!tracked.contains(entry.getValue()) || !eligible(player, entry.getValue())) {
|
||||
state.viewers.remove(entry.getKey());
|
||||
removeLabel(state, entry.getValue());
|
||||
}
|
||||
}
|
||||
for (var viewer : tracked) {
|
||||
if (!current(state, expected)) { return; }
|
||||
try {
|
||||
if (!eligible(player, viewer)) { continue; }
|
||||
var id = viewer.getUniqueId();
|
||||
var key = route(state.view, id);
|
||||
if (!tracking.contains(key) || blocked.containsKey(key)) { continue; }
|
||||
if (!state.viewers.containsKey(id)) {
|
||||
state.viewers.put(id, viewer);
|
||||
publish();
|
||||
if (!deliver(state, expected, viewer, NativePresentationPackets.suppressNameTag(state.entity))
|
||||
|| !deliver(state, expected, viewer, state.label.spawn()) || !deliver(state, expected, viewer, metadata)) {
|
||||
quarantine(state, viewer);
|
||||
}
|
||||
} else if (!deliver(state, expected, viewer, move) || !deliver(state, expected, viewer, metadata)) {
|
||||
quarantine(state, viewer);
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); removeLabels(state); }
|
||||
finally { publish(); }
|
||||
}
|
||||
|
||||
private void quarantine(State state, Player viewer) {
|
||||
if (states.get(state.view.holder()) != state) { return; }
|
||||
var id = viewer.getUniqueId();
|
||||
// A send can have escaped before throwing. Retire once; never retry its spawn sequence.
|
||||
blocked.merge(route(state.view, id), FAILED, (before, flag) -> before | flag);
|
||||
state.viewers.remove(id);
|
||||
publish();
|
||||
send(viewer, state.label.remove());
|
||||
}
|
||||
|
||||
private boolean eligible(Player target, Player viewer) {
|
||||
if (viewer.getUniqueId().equals(target.getUniqueId()) || !viewer.isOnline()
|
||||
|| target.getWorld() != viewer.getWorld() || !viewer.canSee(target)
|
||||
|| target.isInvisible() || target.getGameMode() == GameMode.SPECTATOR) { return false; }
|
||||
var nativeTarget = ((CraftPlayer) target).getHandle();
|
||||
var distance = nativeTarget.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.NAME_TAG_DISTANCE);
|
||||
if (distance == null) { return false; }
|
||||
double range = distance.getValue();
|
||||
if (target.isSneaking()) { range = Math.min(32, range); }
|
||||
if (!(range > 0) || !(viewer.getLocation().distanceSquared(target.getLocation()) < range * range)) { return false; }
|
||||
// The recipient's native scoreboard is the one its client sees (it may differ from
|
||||
// the main scoreboard). Read existing membership only; never create private teams.
|
||||
var board = ((CraftPlayer) viewer).getScoreboard().getHandle();
|
||||
var team = board.getPlayersTeam(target.getName());
|
||||
if (team == null) { return true; }
|
||||
var viewerTeam = board.getPlayersTeam(viewer.getName());
|
||||
return switch (team.getNameTagVisibility()) {
|
||||
case ALWAYS -> true;
|
||||
case NEVER -> false;
|
||||
case HIDE_FOR_OTHER_TEAMS -> viewerTeam == null || team.isAlliedTo(viewerTeam);
|
||||
case HIDE_FOR_OWN_TEAM -> viewerTeam == null || !team.isAlliedTo(viewerTeam);
|
||||
};
|
||||
}
|
||||
|
||||
private void refreshTab(State state, boolean fantasy) {
|
||||
var expected = state.view;
|
||||
try {
|
||||
var ordinary = NativePresentationPackets.refreshTab(((CraftPlayer) state.player).getHandle());
|
||||
var packet = fantasy ? NativePresentationPackets.projectPlayerInfo(ordinary, state.view.holder(), state.view.name()) : ordinary;
|
||||
for (var viewer : List.copyOf(plugin.getServer().getOnlinePlayers())) {
|
||||
if (fantasy ? !current(state, expected) : !restorable(state)) { return; }
|
||||
try { if (viewer.canSee(state.player)) { send(viewer, packet); } }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private boolean restorable(State state) {
|
||||
var replacement = snapshot.get().get(state.view.holder());
|
||||
return !states.containsKey(state.view.holder()) && (replacement == null || sameSession(state.view, replacement));
|
||||
}
|
||||
|
||||
private void restore(State state) {
|
||||
if (!restorable(state)) { return; }
|
||||
refreshTab(state, false);
|
||||
try {
|
||||
var packet = NativePresentationPackets.restoreNameTag(((CraftPlayer) state.player).getHandle());
|
||||
var recipients = new HashSet<>(state.player.getTrackedBy());
|
||||
recipients.add(state.player);
|
||||
for (var viewer : recipients) {
|
||||
if (!restorable(state)) { return; }
|
||||
try { if (viewer.canSee(state.player)) { send(viewer, packet); } }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void removeLabel(State state, Player viewer) {
|
||||
if (!send(viewer, state.label.remove()) && states.get(state.view.holder()) == state) {
|
||||
blocked.merge(route(state.view, viewer.getUniqueId()), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeLabels(State state) {
|
||||
var viewers = List.copyOf(state.viewers.values());
|
||||
var label = state.label;
|
||||
state.viewers.clear();
|
||||
publish();
|
||||
if (label != null) {
|
||||
for (var viewer : viewers) {
|
||||
if (!send(viewer, label.remove()) && states.get(state.view.holder()) == state) {
|
||||
blocked.merge(route(state.view, viewer.getUniqueId()), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
var next = new HashMap<UUID, Decision>();
|
||||
for (var state : states.values()) {
|
||||
var labels = new HashMap<UUID, Integer>();
|
||||
if (state.label != null) {
|
||||
for (var viewer : state.viewers.keySet()) { labels.put(viewer, state.label.id()); }
|
||||
}
|
||||
next.put(state.view.holder(), new Decision(state.view, state.entity, Map.copyOf(labels)));
|
||||
}
|
||||
decisions = Map.copyOf(next);
|
||||
}
|
||||
|
||||
private static Route route(View view, UUID viewer) { return new Route(view.holder(), view.session(), viewer); }
|
||||
|
||||
private void forget(State state) {
|
||||
blocked.keySet().removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
retired.removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
tracking.removeIf(key -> key.holder().equals(state.view.holder()) && key.session().equals(state.view.session()));
|
||||
}
|
||||
|
||||
private synchronized void project(PacketEvent event) {
|
||||
if (closed) { return; }
|
||||
try {
|
||||
var original = event.getPacket().getHandle();
|
||||
var viewer = event.getPlayer().getUniqueId();
|
||||
reserveSpawn(original, viewer);
|
||||
if (event.isCancelled()) { return; }
|
||||
var marker = event.getNetworkMarker();
|
||||
deliveries.putIfAbsent(marker, decisions);
|
||||
marker.addPostListener(postListener); // The parent and children share this deduplicating set.
|
||||
// ProtocolLib visits children AFTER the parent, including lower-priority vetoes.
|
||||
// Never expand a child into a bundle or mutate route state speculatively here.
|
||||
Object projected = original;
|
||||
for (var decision : decisions.values()) {
|
||||
var view = currentView(decision);
|
||||
if (view == null) { continue; }
|
||||
if (projected instanceof ClientboundPlayerInfoUpdatePacket info) {
|
||||
projected = NativePresentationPackets.projectPlayerInfo(info, view.holder(), view.name());
|
||||
} else if (projected instanceof ClientboundUpdateAttributesPacket attributes) {
|
||||
projected = NativePresentationPackets.projectAttributes(attributes, decision.entity());
|
||||
}
|
||||
}
|
||||
if (projected != original) { event.setPacket(wrap(projected)); }
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void reserveSpawn(Object packet, UUID viewer) {
|
||||
if (packet instanceof ClientboundBundlePacket bundle) {
|
||||
bundle.subPackets().forEach(child -> reserveSpawn(child, viewer));
|
||||
} else if (packet instanceof ClientboundAddEntityPacket spawn) {
|
||||
var decision = decisions.get(spawn.getUUID());
|
||||
if (decision != null && decision.entity() == spawn.getId() && !decision.labels().containsKey(viewer)) {
|
||||
// A tick between dispatch and post must not invent a successful new pairing.
|
||||
// Do not retire an existing label or clear any visibility flag on an attempt.
|
||||
blocked.merge(route(decision.view(), viewer), UNTRACKED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Native topology survives renames; any rendering payload uses the latest published view. */
|
||||
private View currentView(Decision expected) {
|
||||
var now = decisions.get(expected.view().holder());
|
||||
var latest = snapshot.get().get(expected.view().holder());
|
||||
return !closed && now != null && now.entity() == expected.entity()
|
||||
&& sameSession(expected.view(), now.view()) && sameSession(expected.view(), latest) ? latest : null;
|
||||
}
|
||||
|
||||
private synchronized void accepted(PacketEvent event) {
|
||||
if (!begin(() -> accepted(event))) { return; }
|
||||
try {
|
||||
var captured = deliveries.remove(NetworkMarker.getNetworkMarker(event));
|
||||
if (closed || event.isCancelled() || captured == null) { return; }
|
||||
var viewer = event.getPlayer();
|
||||
var id = viewer.getUniqueId();
|
||||
var removals = new HashMap<Decision, Integer>();
|
||||
var displays = new HashSet<Decision>();
|
||||
commitNative(event.getPacket().getHandle(), id, captured, removals, displays);
|
||||
for (var entry : removals.entrySet()) {
|
||||
var decision = entry.getKey();
|
||||
if (currentView(decision) != null && !send(viewer, new ClientboundRemoveEntitiesPacket(entry.getValue()))) {
|
||||
blocked.merge(route(decision.view(), id), FAILED, (before, flag) -> before | flag);
|
||||
}
|
||||
}
|
||||
for (var decision : displays) {
|
||||
var view = currentView(decision);
|
||||
if (view != null && (blocked.getOrDefault(route(view, id), 0) & TAB_REMOVED) == 0) {
|
||||
send(viewer, NativePresentationPackets.displayName(view.holder(), view.name()));
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
finally { finish(); }
|
||||
}
|
||||
|
||||
private void commitNative(Object packet, UUID viewer, Map<UUID, Decision> captured,
|
||||
Map<Decision, Integer> removals, Set<Decision> displays) {
|
||||
if (packet instanceof ClientboundBundlePacket bundle) {
|
||||
bundle.subPackets().forEach(child -> commitNative(child, viewer, captured, removals, displays));
|
||||
return;
|
||||
}
|
||||
for (var decision : captured.values()) {
|
||||
var view = currentView(decision);
|
||||
if (view == null) { continue; }
|
||||
var key = route(view, viewer);
|
||||
int mask = blocked.getOrDefault(key, 0);
|
||||
if (packet instanceof ClientboundPlayerInfoUpdatePacket info) {
|
||||
if (info.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER)
|
||||
&& info.entries().stream().anyMatch(entry -> entry.profileId().equals(view.holder()))) {
|
||||
mask &= ~TAB_REMOVED;
|
||||
if (!info.actions().contains(ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME)) {
|
||||
displays.add(decision);
|
||||
}
|
||||
}
|
||||
} else if (packet instanceof ClientboundRemoveEntitiesPacket removal
|
||||
&& removal.getEntityIds().contains(decision.entity())) { mask |= UNTRACKED; }
|
||||
else if (packet instanceof ClientboundPlayerInfoRemovePacket removal
|
||||
&& removal.profileIds().contains(view.holder())) { mask |= TAB_REMOVED; }
|
||||
else if (packet instanceof ClientboundRespawnPacket) { mask |= UNTRACKED; }
|
||||
else if (packet instanceof ClientboundAddEntityPacket spawn && spawn.getId() == decision.entity()
|
||||
&& spawn.getUUID().equals(view.holder())) {
|
||||
mask &= ~(UNTRACKED | INVISIBLE);
|
||||
tracking.add(key);
|
||||
}
|
||||
else if (packet instanceof ClientboundSetEntityDataPacket metadata && metadata.id() == decision.entity()) {
|
||||
for (var value : metadata.packedItems()) {
|
||||
if (value.id() == 0 && value.serializer() == net.minecraft.network.syncher.EntityDataSerializers.BYTE
|
||||
&& value.value() instanceof Byte flags) {
|
||||
mask = (flags & 0x20) != 0 ? mask | INVISIBLE : mask & ~INVISIBLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mask == 0) { blocked.remove(key); } else { blocked.put(key, mask); }
|
||||
var label = decisions.get(view.holder()).labels().get(viewer);
|
||||
if (mask != 0 && label != null && retired.add(key)) { removals.put(decision, label); }
|
||||
}
|
||||
}
|
||||
|
||||
private boolean send(Player viewer, Object raw) {
|
||||
try { protocol.sendServerPacket(viewer, wrap(raw), false); return true; }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); return false; }
|
||||
}
|
||||
|
||||
private boolean begin(Runnable retry) {
|
||||
if (operating) { pending.add(retry); return false; }
|
||||
operating = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void finish() {
|
||||
operating = false;
|
||||
if (draining) { return; }
|
||||
// Reentrant lifecycle operations wait until the transport attempt AND its failure/
|
||||
// resource bookkeeping finish. Cleanup is thus ordered after every possibly escaped
|
||||
// old packet, and a queued rename cannot continue an uncertain spawn. Never replay it.
|
||||
draining = true;
|
||||
try {
|
||||
while (!pending.isEmpty()) {
|
||||
try { pending.removeFirst().run(); }
|
||||
catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
} finally { draining = false; }
|
||||
}
|
||||
|
||||
private static PacketContainer wrap(Object raw) {
|
||||
PacketType type;
|
||||
if (raw instanceof ClientboundBundlePacket) { type = PacketType.Play.Server.BUNDLE; }
|
||||
else if (raw instanceof ClientboundPlayerInfoUpdatePacket) { type = PacketType.Play.Server.PLAYER_INFO; }
|
||||
else if (raw instanceof ClientboundUpdateAttributesPacket) { type = PacketType.Play.Server.UPDATE_ATTRIBUTES; }
|
||||
else if (raw instanceof ClientboundAddEntityPacket) { type = PacketType.Play.Server.SPAWN_ENTITY; }
|
||||
else if (raw instanceof ClientboundSetEntityDataPacket) { type = PacketType.Play.Server.ENTITY_METADATA; }
|
||||
else if (raw instanceof ClientboundTeleportEntityPacket) { type = PacketType.Play.Server.ENTITY_TELEPORT; }
|
||||
else if (raw instanceof ClientboundRemoveEntitiesPacket) { type = PacketType.Play.Server.ENTITY_DESTROY; }
|
||||
else { throw new IllegalArgumentException("Unsupported presentation packet"); }
|
||||
return new PacketContainer(type, raw);
|
||||
}
|
||||
|
||||
private static boolean sameSession(View first, View second) {
|
||||
return second != null && first.holder().equals(second.holder())
|
||||
&& first.session().equals(second.session()) && first.character().equals(second.character());
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
if (warnings < 8) {
|
||||
warnings++;
|
||||
try { diagnostics.accept(failure); } catch (RuntimeException | LinkageError ignored) { /* Cleanup must continue. */ }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
if (!begin(this::close)) { return; }
|
||||
try {
|
||||
if (closed) { return; }
|
||||
closed = true;
|
||||
deliveries.clear();
|
||||
if (task != null) {
|
||||
try { task.cancel(); } catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
if (installed) {
|
||||
try { protocol.removePacketListener(listener); } catch (RuntimeException | LinkageError failure) { report(failure); }
|
||||
}
|
||||
for (var state : List.copyOf(states.values())) { clear(state.view); }
|
||||
} finally { finish(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Session-owned output only. Withdrawal must invalidate decisions even when native cleanup fails. */
|
||||
public interface SessionPresentation {
|
||||
SessionPresentation NONE = new SessionPresentation() {
|
||||
@Override public void activate(Player player, UUID session, CharacterRecord record) { }
|
||||
@Override public void withdraw(UUID player, UUID session) { }
|
||||
};
|
||||
void activate(Player player, UUID session, CharacterRecord record);
|
||||
/** May be called by a failed worker completion: no worker Bukkit access is permitted. */
|
||||
void withdraw(UUID player, UUID session);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
|
||||
|
||||
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
|
||||
public final class SheetResourcePack implements Listener, AutoCloseable {
|
||||
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
|
||||
private final SheetResourcePackSettings settings;
|
||||
private final BiConsumer<UUID, Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void deliverAll(Collection<? extends Player> players) {
|
||||
if (closed) return;
|
||||
for (var player : players) if (player != null && player.isOnline()) deliver(player);
|
||||
}
|
||||
|
||||
public void deliver(Player player) {
|
||||
if (closed || !player.isOnline()) return;
|
||||
try {
|
||||
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
|
||||
"Optional Fantasy Stick first-person sheet presentation", false);
|
||||
} catch (RuntimeException failure) {
|
||||
report(player.getUniqueId(), failure);
|
||||
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
|
||||
|
||||
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
|
||||
if (closed || !ID.equals(event.getID())) return;
|
||||
switch (event.getStatus()) {
|
||||
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
|
||||
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
|
||||
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void report(UUID player, Throwable failure) {
|
||||
try { failures.accept(player, failure); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public final class CharacterProgress {
|
||||
|
||||
private final UUID characterId;
|
||||
private final ProgressionRules rules;
|
||||
private final BigDecimal nativeHealthLimit;
|
||||
private final EnumMap<Stat, BigInteger> stats = new EnumMap<>(Stat.class);
|
||||
private int level = 1;
|
||||
private BigInteger experience = BigInteger.ZERO;
|
||||
@@ -26,11 +27,19 @@ public final class CharacterProgress {
|
||||
private BigInteger revision = BigInteger.ZERO;
|
||||
private BigDecimal health = BigDecimal.TEN;
|
||||
|
||||
/** Uses the pinned runtime's default ceiling. Runtime adapters must supply their actual limit. */
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules) {
|
||||
this(characterId, rules, new BigDecimal("1024"));
|
||||
}
|
||||
|
||||
public CharacterProgress(UUID characterId, ProgressionRules rules, BigDecimal nativeHealthLimit) {
|
||||
this.characterId = Objects.requireNonNull(characterId);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.nativeHealthLimit = Objects.requireNonNull(nativeHealthLimit);
|
||||
if (nativeHealthLimit.signum() <= 0) throw new IllegalArgumentException("Native health limit must be positive");
|
||||
this.points = rules.startingPoints();
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
this.health = maximumHealth();
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
@@ -47,9 +56,10 @@ public final class CharacterProgress {
|
||||
if (!revision.equals(expectedRevision)) return Purchase.STALE_REVISION;
|
||||
if (health.signum() == 0) return Purchase.NOT_ALIVE;
|
||||
if (points.signum() == 0) return Purchase.NO_POINTS;
|
||||
BigDecimal previousMaximum = maximumHealth();
|
||||
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
|
||||
points = points.subtract(BigInteger.ONE);
|
||||
if (stat == Stat.CON) health = health.add(BigDecimal.ONE);
|
||||
if (stat == Stat.CON) health = health.add(maximumHealth().subtract(previousMaximum));
|
||||
revision = revision.add(BigInteger.ONE);
|
||||
return Purchase.APPLIED;
|
||||
}
|
||||
@@ -80,6 +90,6 @@ public final class CharacterProgress {
|
||||
}
|
||||
|
||||
private BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(nativeHealthLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Server-thread bounded contribution ledger keyed by target and originating character. */
|
||||
public final class CombatContributions implements AutoCloseable {
|
||||
private static final java.time.Duration SUPPORT_LIFETIME = java.time.Duration.ofSeconds(10);
|
||||
private final CombatRewardRules rules;
|
||||
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
|
||||
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
|
||||
|
||||
public void accept(UUID target, UUID character, Instant acceptedAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
|
||||
(previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public void support(UUID target, UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(aidedCharacter);
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
|
||||
if (closed) return;
|
||||
supports.computeIfAbsent(target, ignored -> new HashMap<>())
|
||||
.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
|
||||
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
|
||||
}
|
||||
|
||||
public Set<UUID> consume(UUID target, Instant deathAt) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
|
||||
if (closed) return Set.of();
|
||||
var contributions = targets.remove(target);
|
||||
var assistance = supports.remove(target);
|
||||
if (contributions == null) return Set.of();
|
||||
var eligible = new LinkedHashSet<UUID>();
|
||||
contributions.forEach((character, acceptedAt) -> {
|
||||
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
|
||||
});
|
||||
for (UUID aided : Set.copyOf(eligible)) {
|
||||
var supporters = assistance == null ? null : assistance.get(aided);
|
||||
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
|
||||
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
|
||||
});
|
||||
}
|
||||
return Set.copyOf(eligible);
|
||||
}
|
||||
|
||||
public void invalidate(UUID target) {
|
||||
if (!closed) { target = Objects.requireNonNull(target); targets.remove(target); supports.remove(target); }
|
||||
}
|
||||
|
||||
public void sweep(Instant now) {
|
||||
Objects.requireNonNull(now);
|
||||
if (closed) return;
|
||||
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
|
||||
targets.values().removeIf(Map::isEmpty);
|
||||
supports.values().forEach(byAlly -> {
|
||||
byAlly.values().forEach(bySupporter -> bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
|
||||
byAlly.values().removeIf(Map::isEmpty);
|
||||
});
|
||||
supports.values().removeIf(Map::isEmpty);
|
||||
}
|
||||
|
||||
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
|
||||
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.progression;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
|
||||
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
|
||||
BigDecimal restrictedSpacing) {
|
||||
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
|
||||
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
|
||||
|
||||
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
|
||||
public KillPosition {
|
||||
Objects.requireNonNull(world);
|
||||
x = coordinate(x); y = coordinate(y); z = coordinate(z);
|
||||
}
|
||||
private static BigDecimal coordinate(BigDecimal value) {
|
||||
Objects.requireNonNull(value);
|
||||
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatRewardRules {
|
||||
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
|
||||
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
|
||||
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|
||||
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
|
||||
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
|
||||
}
|
||||
}
|
||||
|
||||
public static CombatRewardRules defaults() {
|
||||
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
|
||||
}
|
||||
|
||||
public BigInteger pool(Target target, int defeatedLevel) {
|
||||
Objects.requireNonNull(target);
|
||||
if (target == Target.FANTASY_CHARACTER) {
|
||||
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
|
||||
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
|
||||
}
|
||||
return switch (target) {
|
||||
case PASSIVE -> passivePool;
|
||||
case ORDINARY -> ordinaryPool;
|
||||
case BOSS -> bossPool;
|
||||
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
|
||||
};
|
||||
}
|
||||
|
||||
public BigInteger share(BigInteger pool, int contributors) {
|
||||
Objects.requireNonNull(pool);
|
||||
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
|
||||
var count = BigInteger.valueOf(contributors);
|
||||
return pool.add(count).subtract(BigInteger.ONE).divide(count);
|
||||
}
|
||||
|
||||
public boolean contributes(Instant acceptedAt, Instant deathAt) {
|
||||
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
|
||||
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
|
||||
}
|
||||
|
||||
public boolean spaced(KillPosition previous, KillPosition current) {
|
||||
Objects.requireNonNull(previous); Objects.requireNonNull(current);
|
||||
if (!previous.world().equals(current.world())) return true;
|
||||
BigDecimal dx = current.x().subtract(previous.x());
|
||||
BigDecimal dy = current.y().subtract(previous.y());
|
||||
BigDecimal dz = current.z().subtract(previous.z());
|
||||
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
|
||||
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
|
||||
public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
public enum Outcome { DELIVERED, FULL, BUSY, STALE, SAVE_FAILED, RETAINED }
|
||||
public record Failure(UUID player, UUID character, String operation, Throwable cause) { }
|
||||
|
||||
private final CharacterStorage storage;
|
||||
private final Supplier<CharacterRecord> allocate;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
this.storage = Objects.requireNonNull(storage);
|
||||
this.allocate = Objects.requireNonNull(allocate);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
/** Must be entered on the server thread. Completion is delivered on that thread. */
|
||||
public void issue(Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
|
||||
int slot = player.getInventory().firstEmpty();
|
||||
if (slot < 0) { response.accept(Outcome.FULL); return; }
|
||||
var attempt = new Attempt(player, slot, response);
|
||||
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
|
||||
try {
|
||||
attempt.record = allocate.get();
|
||||
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.playerId, attempt);
|
||||
abandon(attempt);
|
||||
report(attempt, "prepare", failure);
|
||||
response.accept(Outcome.SAVE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.playerId) != attempt) { abandon(attempt); return; }
|
||||
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
|
||||
catch (RuntimeException rejected) {
|
||||
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
|
||||
if (failure != null) {
|
||||
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
|
||||
}
|
||||
var occupying = attempt.player.getInventory().getItem(attempt.slot);
|
||||
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|
||||
|| occupying != null && !occupying.getType().isAir()) {
|
||||
abandon(attempt); respond(attempt, Outcome.STALE); return;
|
||||
}
|
||||
var claimed = batch.claimForDelivery();
|
||||
if (claimed.isEmpty() || claimed.orElseThrow().size() != 1
|
||||
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
|
||||
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
try {
|
||||
var sheet = new CharacterSheets().create(attempt.record);
|
||||
attempt.player.getInventory().setItem(attempt.slot, sheet);
|
||||
attempt.delivered = true;
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(attempt, "delivery", uncertain);
|
||||
respond(attempt, Outcome.RETAINED);
|
||||
return;
|
||||
}
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
private void respond(Attempt attempt, Outcome outcome) {
|
||||
try { attempt.response.accept(outcome); }
|
||||
catch (RuntimeException failure) { report(attempt, "response", failure); }
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
|
||||
attempt.preparation.abandon().thenAccept(cleanup -> {
|
||||
if (!cleanup.retained().isEmpty()) report(attempt, "cleanup-retained", null);
|
||||
});
|
||||
}
|
||||
|
||||
private void report(Attempt attempt, String operation, Throwable cause) {
|
||||
try { failures.accept(new Failure(attempt.playerId, attempt.record == null ? null : attempt.record.id(), operation, cause)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var attempt : List.copyOf(pending.values())) {
|
||||
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Admin-owned, expiring Tail selection with authoritative consume-once restoration. */
|
||||
public final class AdminRevival implements Listener, AutoCloseable {
|
||||
public interface PopupFactory { Inventory create(int size, Component title); }
|
||||
public interface Scheduler { AutoCloseable later(Duration delay, Runnable action); }
|
||||
private record Candidate(int slot, ItemStack item, TailBooks.Identity identity) { }
|
||||
private record Choice(Candidate candidate, CharacterRecord record) { }
|
||||
private final CharacterStorage characters; private final RevivalStore reservations; private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
|
||||
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
|
||||
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
|
||||
boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);}
|
||||
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}}
|
||||
}
|
||||
|
||||
public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);}
|
||||
|
||||
public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout);}));}
|
||||
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())continue;var identity=tails.identify(item);if(identity.isPresent())result.add(new Candidate(slot,item.clone(),identity.orElseThrow()));}return List.copyOf(result);}
|
||||
private boolean eligible(CharacterRecord record,TailBooks.Identity id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());}
|
||||
private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);}
|
||||
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout));views.put(inventory,view);admin.openInventory(inventory);}
|
||||
|
||||
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&event.getRawSlots().stream().anyMatch(slot->slot<view.inventory.getSize()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void close(org.bukkit.event.inventory.InventoryCloseEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
|
||||
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){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.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Main-thread holder/session proof for optional Adventure mode; the receipt must already be saved. */
|
||||
public final class AdventureModeLease implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void changed(org.bukkit.event.player.PlayerGameModeChangeEvent event) {
|
||||
if (event.isCancelled()) return;
|
||||
var player = event.getPlayer();
|
||||
var mark = read(player);
|
||||
// Events precede the write. A currently non-Adventure mode proves a previous
|
||||
// external change already happened, even when this new request returns to Adventure.
|
||||
// A lone external change is also detected directly by restore(). Never infer success
|
||||
// from this event's requested value, which a later listener may still cancel.
|
||||
if (mark != null && mark.phase() == Phase.APPLIED && player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(mark.session(), mark.previous(), Phase.RELEASED));
|
||||
}
|
||||
}
|
||||
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "adventure_session");
|
||||
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
|
||||
private record Mark(UUID session, String previous, Phase phase) {
|
||||
boolean matches(UUID expected, String original) { return session.equals(expected) && previous.equals(original); }
|
||||
}
|
||||
|
||||
public boolean available(Player player) {
|
||||
var mark = read(player);
|
||||
return !player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED;
|
||||
}
|
||||
|
||||
public boolean enter(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var existing = read(player);
|
||||
if (player.getPersistentDataContainer().has(KEY) && (existing == null || existing.phase() != Phase.RELEASED)) {
|
||||
return existing != null && existing.matches(session, previous) && existing.phase() == Phase.APPLIED
|
||||
&& player.getGameMode() == GameMode.ADVENTURE;
|
||||
}
|
||||
if (player.getGameMode() != GameMode.valueOf(previous)) return false;
|
||||
write(player, new Mark(session, previous, Phase.APPLYING));
|
||||
player.setGameMode(GameMode.ADVENTURE);
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return false;
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean restore(Player player, UUID session, String previous) {
|
||||
if (previous == null) return true;
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.matches(session, previous)) return false;
|
||||
if (mark.phase() == Phase.RELEASED) return true;
|
||||
if (mark.phase() != Phase.APPLIED) return false; // Interrupted setter is not restoration proof.
|
||||
if (player.getGameMode() != GameMode.ADVENTURE) {
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true; // A later external/admin change is not ours to undo.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RESTORING));
|
||||
player.setGameMode(GameMode.valueOf(previous));
|
||||
if (player.getGameMode() == GameMode.ADVENTURE && !previous.equals("ADVENTURE")) {
|
||||
write(player, new Mark(session, previous, Phase.APPLIED));
|
||||
return false; // Native veto: unchanged and still owned, not successful restoration.
|
||||
}
|
||||
write(player, new Mark(session, previous, Phase.RELEASED));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Mark read(Player player) {
|
||||
var data = player.getPersistentDataContainer();
|
||||
if (!data.has(KEY, PersistentDataType.STRING)) return null;
|
||||
try {
|
||||
String[] fields = data.get(KEY, PersistentDataType.STRING).split("\\|", -1);
|
||||
if (fields.length != 4 || !fields[0].equals("1")) return null;
|
||||
var session = UUID.fromString(fields[1]);
|
||||
if (!session.toString().equals(fields[1])) return null;
|
||||
GameMode.valueOf(fields[2]);
|
||||
return new Mark(session, fields[2], Phase.valueOf(fields[3]));
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
|
||||
private static void write(Player player, Mark mark) {
|
||||
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
|
||||
"1|" + mark.session() + "|" + mark.previous() + "|" + mark.phase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/**
|
||||
* Internal server-thread input port for book/combat/ability adapters, not an add-on API.
|
||||
* Containment of vanilla item behavior is separate from executing an ability. Recipients
|
||||
* must revalidate after deferred work; this notification is never durable cast authority.
|
||||
*/
|
||||
public record ControlInput(Player player, EquipmentSessions.Active session, int slot, String ability,
|
||||
Kind kind, Gesture gesture, Entity target, int heldTicks, float attackStrength,
|
||||
BooleanSupplier validity) {
|
||||
public enum Kind { BOOK, BASIC_ATTACK, ABILITY }
|
||||
public enum Gesture { USE, ATTACK, DRAW_RELEASE }
|
||||
|
||||
public ControlInput {
|
||||
Objects.requireNonNull(player);
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(ability);
|
||||
Objects.requireNonNull(kind);
|
||||
Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(validity);
|
||||
if (slot < 0 || slot > 8 || heldTicks < 0 || !Float.isFinite(attackStrength)
|
||||
|| attackStrength < 0 || attackStrength > 1) throw new IllegalArgumentException("Invalid control input");
|
||||
}
|
||||
|
||||
public boolean stillCurrent() { return validity.getAsBoolean(); }
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
|
||||
/** Server-thread command entry; persistence callbacks never access senders or players. */
|
||||
public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
public static final String PERMISSION = "fantasystick.admin";
|
||||
public record Failure(UUID player, String operation, Throwable cause) { }
|
||||
private final EquipmentSessionStore receipts;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.persistence.AdventureSettings settings;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
|
||||
private games.dmg.fantasystick.ability.Quickstep quickstep;
|
||||
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
|
||||
private games.dmg.fantasystick.ability.SecondWind secondWind;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.ability.Hex hex;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics);
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings);
|
||||
this.frostSettings = Objects.requireNonNull(frostSettings);
|
||||
this.frostShard = Objects.requireNonNull(frostShard);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard, abilities);
|
||||
this.longbowSettings = Objects.requireNonNull(longbowSettings);
|
||||
this.longbow = Objects.requireNonNull(longbow);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, abilities);
|
||||
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
|
||||
this.quickstep = Objects.requireNonNull(quickstep);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
|
||||
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
|
||||
this.secondWind = Objects.requireNonNull(secondWind);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
|
||||
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
|
||||
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
|
||||
this.stoneSkin = Objects.requireNonNull(stoneSkin);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
|
||||
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
|
||||
games.dmg.fantasystick.ability.ShieldBash shieldBash,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, abilities);
|
||||
this.shieldBashSettings = Objects.requireNonNull(shieldBashSettings);
|
||||
this.shieldBash = Objects.requireNonNull(shieldBash);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
|
||||
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
|
||||
games.dmg.fantasystick.ability.ShieldBash shieldBash,
|
||||
games.dmg.fantasystick.persistence.HexSettings hexSettings,
|
||||
games.dmg.fantasystick.ability.Hex hex,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilities);
|
||||
this.hexSettings = Objects.requireNonNull(hexSettings); this.hex = Objects.requireNonNull(hex);
|
||||
}
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.persistence.AdventureSettings settings,
|
||||
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
|
||||
games.dmg.fantasystick.ability.FrostShard frostShard,
|
||||
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
|
||||
games.dmg.fantasystick.ability.Longbow longbow,
|
||||
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
|
||||
games.dmg.fantasystick.ability.Quickstep quickstep,
|
||||
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
|
||||
games.dmg.fantasystick.ability.SecondWind secondWind,
|
||||
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
|
||||
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
|
||||
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
|
||||
games.dmg.fantasystick.ability.ShieldBash shieldBash,
|
||||
games.dmg.fantasystick.persistence.HexSettings hexSettings,
|
||||
games.dmg.fantasystick.ability.Hex hex,
|
||||
games.dmg.fantasystick.persistence.InspireSettings inspireSettings,
|
||||
games.dmg.fantasystick.ability.Inspire inspire,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
|
||||
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
|
||||
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex, abilities);
|
||||
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
|
||||
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
|
||||
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
|
||||
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage("You need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("config")) {
|
||||
configuration(sender, args);
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("give")) {
|
||||
give(sender, args);
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("revive")) {
|
||||
adminRevive(sender,args);return true;
|
||||
}
|
||||
UUID owner;
|
||||
try {
|
||||
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
throw new IllegalArgumentException("Invalid command shape");
|
||||
}
|
||||
owner = UUID.fromString(args[1]);
|
||||
if (!owner.toString().equalsIgnoreCase(args[1])) throw new IllegalArgumentException("Noncanonical UUID shape");
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Usage: /fantasystick <inspect|recover> <player-uuid> (full UUID required)");
|
||||
return true;
|
||||
}
|
||||
if (args[0].equals("recover")) {
|
||||
var player = server.getPlayer(owner);
|
||||
if (player == null || !player.isOnline()) {
|
||||
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
|
||||
return true;
|
||||
}
|
||||
sessions.recover(player);
|
||||
sender.sendMessage("Validated equipment recovery requested for " + owner
|
||||
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
|
||||
return true;
|
||||
}
|
||||
receipts.find(owner).whenComplete((state, failure) -> {
|
||||
if (failure != null) report(owner, "inspect", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return;
|
||||
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) {
|
||||
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
|
||||
return;
|
||||
}
|
||||
sender.sendMessage("Equipment receipt player=" + owner + " (file and native observations are not atomic)");
|
||||
if (state.isEmpty()) sender.sendMessage("Saved receipt: none; pending=none; last exit=none.");
|
||||
else {
|
||||
var saved = state.orElseThrow();
|
||||
var pending = saved.pending();
|
||||
sender.sendMessage(pending == null ? "pending=none" : "pending session=" + pending.sessionId()
|
||||
+ " character=" + pending.characterId() + " prepared=" + pending.preparedAt());
|
||||
var exit = saved.lastExit();
|
||||
sender.sendMessage(exit == null ? "last exit=none; deadline=none" : "last exit session=" + exit.sessionId()
|
||||
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
|
||||
}
|
||||
var player = server.getPlayer(owner);
|
||||
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
|
||||
else {
|
||||
var marker = EquipmentSessionMarker.read(player);
|
||||
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
|
||||
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
|
||||
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
|
||||
sender.sendMessage("Online active=" + sessions.active(owner).map(active -> "session=" + active.session()
|
||||
+ " character=" + active.character()).orElse("none"));
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(owner, "inspect-response", rejected); }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
|
||||
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
|
||||
&& player.getName().equalsIgnoreCase(args[1])).toList();
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
characterIssuer.issue(target, outcome -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (outcome) {
|
||||
case DELIVERED -> "Issued one new character sheet to " + target.getName() + ".";
|
||||
case FULL -> "Character issue refused: " + target.getName() + " has no inventory space. No character was created.";
|
||||
case BUSY -> "Character issue refused: another delivery to " + target.getName() + " is pending.";
|
||||
case STALE -> "Character issue refused: target disconnected or the reserved inventory slot changed. No sheet was delivered.";
|
||||
case SAVE_FAILED -> "Character issue failed before delivery; no sheet was delivered. See server diagnostics.";
|
||||
case RETAINED -> "Character issue has ambiguous retained state; no retry was performed. See server diagnostics.";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
|
||||
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
quickstepConfiguration(sender, args); return;
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
secondWindConfiguration(sender, args); return;
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
stoneSkinConfiguration(sender, args); return;
|
||||
}
|
||||
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
|
||||
shieldBashConfiguration(sender, args); return;
|
||||
}
|
||||
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
|
||||
hexConfiguration(sender, args); return;
|
||||
}
|
||||
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
|
||||
inspireConfiguration(sender, args); return;
|
||||
}
|
||||
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
|
||||
try { operation = frostSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Frost Shard setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostShard.configure(saved.settings());
|
||||
abilities.replace(frostShard.definition());
|
||||
frostSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Frost Shard configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
try { operation = longbowSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Longbow setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
longbow.configure(saved.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
try { operation = quickstepSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Quickstep setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
|
||||
quickstepSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Quickstep configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
|
||||
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex setting is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;
|
||||
try{nextTick.accept(()->{if(closed)return;if(failure==null){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.publish(saved);}
|
||||
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
|
||||
sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Hex casts; active effects and deadlines are unchanged."
|
||||
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
}
|
||||
|
||||
private void shieldBashConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
|
||||
try { operation = shieldBashSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Shield Bash configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin setting is invalid; the effective value is unchanged."); return; }
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
try { operation = secondWindSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Second Wind setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.publish(saved);
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
|
||||
: "Adventure configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private static void configurationUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
|
||||
}
|
||||
|
||||
private void report(UUID owner, String operation, Throwable cause) {
|
||||
try { diagnostics.accept(new Failure(owner, operation, cause)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
|
||||
}
|
||||
|
||||
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
|
||||
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
|
||||
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
|
||||
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
|
||||
return matching(keys, args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
String current = longbowSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
|
||||
String current = quickstepSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
|
||||
String current = secondWindSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
|
||||
String current = stoneSkinSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
|
||||
String current = shieldBashSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
|
||||
String current = hexSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.HexSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.crafting.ManualDropDelivery;
|
||||
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.items.SessionControls;
|
||||
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCreativeEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Server-thread equipment bridge. The scheduler must defer, never execute inline. */
|
||||
public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EquipmentSessions.Failure> diagnostics;
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private static final class Intent { boolean started; }
|
||||
private final Map<UUID, Intent> swaps = new HashMap<>();
|
||||
private final Map<UUID, UUID> observed = new HashMap<>();
|
||||
private final Map<UUID, Object> queued = new HashMap<>();
|
||||
private final Set<UUID> writing = new HashSet<>();
|
||||
private final Map<UUID, BoxMove> moves = new HashMap<>();
|
||||
private static final class BoxMove {
|
||||
final Player player;
|
||||
final InventoryView view;
|
||||
final EquipmentSessions.Active active;
|
||||
final ItemStack box;
|
||||
final ItemStack control;
|
||||
boolean issued;
|
||||
boolean placing;
|
||||
BoxMove(Player player, InventoryView view, EquipmentSessions.Active active, ItemStack box) {
|
||||
this.player = player; this.view = view; this.active = active; this.box = box.clone();
|
||||
control = new ItemStack(org.bukkit.Material.GRAY_DYE);
|
||||
var meta = control.getItemMeta();
|
||||
meta.displayName(net.kyori.adventure.text.Component.text("Move Fantasy Storage Box"));
|
||||
meta.setMaxStackSize(1);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(new NamespacedKey("fantasystick", "control_move_version"), PersistentDataType.INTEGER, 1);
|
||||
data.set(new NamespacedKey("fantasystick", "control_move_owner"), PersistentDataType.STRING, active.player().toString());
|
||||
data.set(new NamespacedKey("fantasystick", "control_move_session"), PersistentDataType.STRING, active.session().toString());
|
||||
data.set(new NamespacedKey("fantasystick", "control_move_token"), PersistentDataType.STRING, UUID.randomUUID().toString());
|
||||
control.setItemMeta(meta);
|
||||
}
|
||||
}
|
||||
private boolean closed;
|
||||
private Consumer<ControlInput> inputs = ignored -> { };
|
||||
private record Draw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) { }
|
||||
private final Map<UUID, Draw> draws = new HashMap<>();
|
||||
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick) {
|
||||
this(sessions, nextTick, failure -> System.getLogger(EquipmentSessionEvents.class.getName())
|
||||
.log(System.Logger.Level.WARNING, "Equipment event failure for " + failure.player(), failure.cause()));
|
||||
}
|
||||
|
||||
/** Diagnostics report contained scheduler failures and possibly exposed drop outcomes; never throw. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
sessions.attachCursorCustody(this::cancelCustody);
|
||||
}
|
||||
|
||||
/** Install before registration so extensible controls keep the same structural containment catalogue. */
|
||||
public void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (closed || !draws.isEmpty()) throw new IllegalStateException("Input handling already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
}
|
||||
|
||||
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
|
||||
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
|
||||
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
|
||||
this(sessions, nextTick, diagnostics);
|
||||
this.inputs = Objects.requireNonNull(inputs);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void death(org.bukkit.event.entity.PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
UUID owner = event.getEntity().getUniqueId();
|
||||
queued.remove(owner);
|
||||
swaps.remove(owner);
|
||||
sessions.cancelEntry(owner);
|
||||
boolean reserved = writing.add(owner);
|
||||
try { sessions.defeat(event); }
|
||||
finally { if (reserved) writing.remove(owner); }
|
||||
}
|
||||
|
||||
// Only actual ordinary death invalidates context; preserve a prior cancellation entirely.
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void ordinaryDeath(org.bukkit.event.entity.PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
invalidate(event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void respawn(org.bukkit.event.player.PlayerRespawnEvent event) {
|
||||
if (!closed) invalidate(event.getPlayer());
|
||||
}
|
||||
|
||||
private void invalidate(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
queued.remove(owner);
|
||||
swaps.remove(owner);
|
||||
observed.put(owner, sheets.identify(player.getInventory().getItemInOffHand()).orElse(null));
|
||||
sessions.invalidateContext(owner);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void changed(PlayerInventorySlotChangeEvent event) { reconcile(event.getPlayer()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(org.bukkit.event.player.PlayerJoinEvent event) {
|
||||
if (closed) return;
|
||||
var player = event.getPlayer();
|
||||
UUID owner = player.getUniqueId();
|
||||
queued.remove(owner);
|
||||
observed.put(owner, sheets.identify(player.getInventory().getItemInOffHand()).orElse(null));
|
||||
sessions.arrive(player);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var player = event.getPlayer();
|
||||
UUID owner = player.getUniqueId();
|
||||
queued.remove(owner);
|
||||
swaps.remove(owner);
|
||||
boolean reserved = writing.add(owner);
|
||||
try { sessions.invalidateContext(owner); sessions.exit(player); }
|
||||
finally { if (reserved) writing.remove(owner); observed.remove(owner); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void swap(org.bukkit.event.player.PlayerSwapHandItemsEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
if (reservedExchange(player, slot)) {
|
||||
event.setCancelled(true);
|
||||
// Purpur 2618 snapshots the proposed hand stacks BEFORE dispatch. Never let its
|
||||
// continuation write the old fantasy control over the restored ordinary stack.
|
||||
if (Objects.equals(event.getMainHandItem(), player.getInventory().getItemInOffHand())
|
||||
&& Objects.equals(event.getOffHandItem(), player.getInventory().getItem(slot))) {
|
||||
exchange(player, slot, null);
|
||||
}
|
||||
} else if (special(event.getMainHandItem()) || special(event.getOffHandItem())
|
||||
|| special(player.getInventory().getItemInMainHand()) || special(player.getInventory().getItemInOffHand())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void click(InventoryClickEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||
if (ownInventory(player, event.getClickedInventory()) && event.getSlot() == 40
|
||||
&& (event.getClick() == ClickType.DROP || event.getClick() == ClickType.CONTROL_DROP)
|
||||
&& (event.getAction() == InventoryAction.DROP_ONE_SLOT || event.getAction() == InventoryAction.DROP_ALL_SLOT)
|
||||
&& sheets.identify(event.getCurrentItem()).isPresent()) {
|
||||
// LivingEntity.drop's cancelled-source fallback adds to inventory, not the offhand;
|
||||
// at full capacity it discards leftovers. Keep custody until detached insertion succeeds.
|
||||
event.setCancelled(true);
|
||||
dropSheet(player, event.getView(), event.getClick() == ClickType.CONTROL_DROP);
|
||||
return;
|
||||
}
|
||||
int slot = -1;
|
||||
if (event.getClickedInventory() instanceof PlayerInventory) {
|
||||
if (event.getSlot() == 40 && event.getClick() == ClickType.NUMBER_KEY) slot = event.getHotbarButton();
|
||||
else if (event.getClick() == ClickType.SWAP_OFFHAND) slot = event.getSlot();
|
||||
}
|
||||
if (event.getAction() == InventoryAction.HOTBAR_SWAP && reservedExchange(player, slot)) {
|
||||
event.setCancelled(true);
|
||||
exchange(player, slot, event.getView());
|
||||
return;
|
||||
}
|
||||
if (pickupClaim(event.getCursor(), "control_move_") && !cursorIndependent(event)) {
|
||||
event.setCancelled(true);
|
||||
if (!(event instanceof InventoryCreativeEvent)
|
||||
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) {
|
||||
if (event.getRawSlot() == -999) cancelMove(player.getUniqueId());
|
||||
else if (ownInventory(player, event.getClickedInventory()) && event.getSlot() >= 9 && event.getSlot() < 36
|
||||
&& (event.getAction() == InventoryAction.PLACE_ALL || event.getAction() == InventoryAction.PLACE_ONE
|
||||
|| event.getAction() == InventoryAction.SWAP_WITH_CURSOR)) {
|
||||
placeMove(player, event.getView(), event.getSlot(), event.getCursor());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (box(event.getCurrentItem()) != null && empty(event.getCursor())
|
||||
&& ownInventory(player, event.getClickedInventory()) && event.getSlot() >= 9 && event.getSlot() < 36
|
||||
&& !(event instanceof InventoryCreativeEvent)
|
||||
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)
|
||||
&& (event.getAction() == InventoryAction.PICKUP_ALL || event.getAction() == InventoryAction.PICKUP_HALF)) {
|
||||
event.setCancelled(true);
|
||||
beginMove(player, event.getView(), event.getCurrentItem());
|
||||
return;
|
||||
}
|
||||
if (blockedClick(player, event)) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void recipeBook(com.destroystokyo.paper.event.player.PlayerRecipeBookClickEvent event) {
|
||||
var player = event.getPlayer();
|
||||
// Native recipe-book placement can pull directly from the hotbar without a click
|
||||
// event. Exact recipe ingredients also bypass vanilla's custom-name exclusion.
|
||||
if (Arrays.stream(player.getOpenInventory().getTopInventory().getContents()).anyMatch(this::special)) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
var recipe = player.getServer().getRecipe(event.getRecipe());
|
||||
java.util.Collection<org.bukkit.inventory.RecipeChoice> choices;
|
||||
if (recipe instanceof org.bukkit.inventory.ShapedRecipe shaped) choices = shaped.getChoiceMap().values();
|
||||
else if (recipe instanceof org.bukkit.inventory.ShapelessRecipe shapeless) choices = shapeless.getChoiceList();
|
||||
else if (recipe instanceof org.bukkit.inventory.CookingRecipe<?> cooking) choices = java.util.List.of(cooking.getInputChoice());
|
||||
else return; // Other recipe types do not use these native auto-fill routes.
|
||||
for (var item : player.getInventory().getStorageContents()) {
|
||||
if (!special(item)) continue;
|
||||
boolean ordinaryEligible = net.minecraft.world.entity.player.Inventory.isUsableForCrafting(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(item));
|
||||
if (choices.stream().filter(Objects::nonNull).anyMatch(choice -> choice.test(item)
|
||||
&& (ordinaryEligible || choice instanceof org.bukkit.inventory.RecipeChoice.ExactChoice))) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean cursorIndependent(InventoryClickEvent event) {
|
||||
return switch (event.getAction()) {
|
||||
case DROP_ONE_SLOT, DROP_ALL_SLOT, MOVE_TO_OTHER_INVENTORY, HOTBAR_SWAP -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean blockedClick(Player player, InventoryClickEvent event) {
|
||||
var current = event.getCurrentItem();
|
||||
var cursor = pickupClaim(event.getCursor(), "control_move_") && cursorIndependent(event) ? null : event.getCursor();
|
||||
var inventory = event.getClickedInventory();
|
||||
int slot = event.getSlot();
|
||||
if (event.getSlotType() == InventoryType.SlotType.RESULT
|
||||
&& Arrays.stream(event.getView().getTopInventory().getContents()).anyMatch(this::special)) return true;
|
||||
if (control(current) || control(cursor)) return true;
|
||||
if (event instanceof InventoryCreativeEvent && (special(current) || special(cursor))) return true;
|
||||
// Native pickup swaps the active sheet onto the cursor and its own box into offhand.
|
||||
// Reconciliation then restores into that freed offhand, without needing main capacity.
|
||||
if (ownInventory(player, inventory) && slot == 40
|
||||
&& event.getAction() == InventoryAction.SWAP_WITH_CURSOR
|
||||
&& (event.getClick() == ClickType.LEFT || event.getClick() == ClickType.RIGHT)) {
|
||||
var active = sessions.active(player.getUniqueId()).orElse(null);
|
||||
var heldBox = box(cursor);
|
||||
if (active != null && heldBox != null && heldBox.owner().equals(active.player())
|
||||
&& heldBox.session().equals(active.session())
|
||||
&& sheets.identify(current).filter(active.character()::equals).isPresent()) return false;
|
||||
}
|
||||
if (event.getClick() == ClickType.NUMBER_KEY || event.getClick() == ClickType.SWAP_OFFHAND) {
|
||||
int source = event.getClick() == ClickType.SWAP_OFFHAND ? 40 : event.getHotbarButton();
|
||||
if (source < 0) return false;
|
||||
var hidden = player.getInventory().getItem(source);
|
||||
return special(current) || special(hidden)
|
||||
|| destinationBlocked(player, inventory, slot, hidden)
|
||||
|| destinationBlocked(player, player.getInventory(), source, current);
|
||||
}
|
||||
// Normal pickup is exclusively the bound move-control path above. No unsupported
|
||||
// action may fall back to vanilla taking the genuine box onto the cursor.
|
||||
if (box(current) != null) return true;
|
||||
if (box(cursor) != null && destinationBlocked(player, inventory, slot, cursor)) return true;
|
||||
if (event.getAction() == InventoryAction.COLLECT_TO_CURSOR && !empty(cursor)) {
|
||||
for (var item : event.getView().getTopInventory().getContents()) if (special(item) && item.isSimilar(cursor)) return true;
|
||||
for (var item : player.getInventory().getContents()) if (special(item) && item.isSimilar(cursor)) return true;
|
||||
}
|
||||
if (!active(player)) return false;
|
||||
if (ownInventory(player, inventory) && slot >= 0 && slot < 9) return true;
|
||||
if (event.isShiftClick() && !empty(current)) {
|
||||
if (ownInventory(player, inventory) && event.getView().getType() == InventoryType.CRAFTING && slot >= 9 && slot < 36) {
|
||||
// Native InventoryMenu tries armor first, otherwise routes main -> hotbar.
|
||||
return !(cosmetic(current) && !forbiddenEquip(current) && empty(player.getInventory().getHelmet()));
|
||||
}
|
||||
if (!ownInventory(player, inventory) && mainCapacity(player, current) < current.getAmount()) return true;
|
||||
}
|
||||
return destinationBlocked(player, inventory, slot, cursor);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void drag(InventoryDragEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||
if (control(event.getOldCursor())) {
|
||||
event.setCancelled(true);
|
||||
if (pickupClaim(event.getOldCursor(), "control_move_") && event.getNewItems().size() == 1) {
|
||||
int raw = event.getNewItems().keySet().iterator().next();
|
||||
int slot = event.getView().convertSlot(raw);
|
||||
if (ownInventory(player, event.getView().getInventory(raw)) && slot >= 9 && slot < 36) {
|
||||
// QUICK_CRAFT temporarily debits carried before event dispatch; DENY
|
||||
// restores it afterwards. Confirm that live cursor only in the deferred task.
|
||||
placeMove(player, event.getView(), slot, event.getOldCursor());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (var entry : event.getNewItems().entrySet()) {
|
||||
int raw = entry.getKey();
|
||||
if (special(event.getView().getItem(raw)) || destinationBlocked(player,
|
||||
event.getView().getInventory(raw), event.getView().convertSlot(raw), entry.getValue())) {
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void drop(org.bukkit.event.player.PlayerDropItemEvent event) {
|
||||
if (special(event.getItemDrop().getItemStack())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
// isCancelled() is only the block decision on this event. Denying item use must
|
||||
// also run for RIGHT_CLICK_AIR / an existing block veto, without changing that veto.
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void use(org.bukkit.event.player.PlayerInteractEvent event) {
|
||||
boolean allowed = event.useItemInHand() != org.bukkit.event.Event.Result.DENY;
|
||||
if (special(event.getItem()) || active(event.getPlayer()) && forbiddenEquip(event.getItem())) {
|
||||
// Do not veto an unrelated door/chest interaction or undo another plugin's block veto.
|
||||
event.setUseItemInHand(org.bukkit.event.Event.Result.DENY);
|
||||
}
|
||||
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|
||||
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
|
||||
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|
||||
? event.getPlayer().getTargetEntity(64)
|
||||
: selected.ability().equals("inspire")
|
||||
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
|
||||
: null;
|
||||
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
}
|
||||
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
|
||||
}
|
||||
}
|
||||
|
||||
private void beginDraw(Player player, ItemStack item, EquipmentSessions.SelectedControl selected) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed || draws.containsKey(owner) || player.hasActiveItem()) return;
|
||||
var draw = new Draw(player, item.clone(), selected);
|
||||
draws.put(owner, draw);
|
||||
try {
|
||||
// Keep the native bow animation/timing, without BowItem.use's ammunition lookup.
|
||||
player.startUsingItem(EquipmentSlot.HAND);
|
||||
if (closed || draws.get(owner) != draw || !sessions.selectedControl(player, draw.item()).filter(selected::equals).isPresent()
|
||||
|| !draw.item().equals(player.getActiveItem())) cancelDraw(draw);
|
||||
} catch (RuntimeException failure) {
|
||||
cancelDraw(draw);
|
||||
report(owner, selected.session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void release(io.papermc.paper.event.player.PlayerStopUsingItemEvent event) {
|
||||
var player = event.getPlayer();
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() != player) draw = null;
|
||||
if (draw != null) draws.remove(owner, draw); // One-use, before a native setter or recipient can reenter.
|
||||
try {
|
||||
boolean matching = draw != null && draw.item().equals(event.getItem())
|
||||
&& draw.item().equals(player.getActiveItem()) && event.getTicksHeldFor() >= 0;
|
||||
// Purpur 2618 reloads LivingEntity.useItem AFTER this event, then calls releaseUsing.
|
||||
// Clear it first: cancelling EntityShootBowEvent alone is too late to own ammo draw.
|
||||
if (special(event.getItem()) && event.getItem().equals(player.getActiveItem())) player.clearActiveItem();
|
||||
if (matching) emit(player, draw.item(), draw.selected(), ControlInput.Gesture.DRAW_RELEASE,
|
||||
null, event.getTicksHeldFor(), 0);
|
||||
} catch (RuntimeException failure) {
|
||||
// Unknown native state is not a completed cast or authority to replay an input.
|
||||
report(owner, draw == null ? null : draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void held(org.bukkit.event.player.PlayerItemHeldEvent event) {
|
||||
var draw = draws.get(event.getPlayer().getUniqueId());
|
||||
if (draw != null && draw.player() == event.getPlayer() && event.getPreviousSlot() != event.getNewSlot()) cancelDraw(draw);
|
||||
}
|
||||
|
||||
private void cancelCustody(UUID owner) {
|
||||
try { cancelMove(owner); }
|
||||
finally {
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null) cancelDraw(draw);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelDraw(Draw draw) {
|
||||
draws.remove(draw.player().getUniqueId(), draw);
|
||||
try {
|
||||
if (draw.item().equals(draw.player().getActiveItem())) draw.player().clearActiveItem();
|
||||
} catch (RuntimeException failure) {
|
||||
report(draw.player().getUniqueId(), draw.selected().session().session(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean useGesture(EquipmentSessions.SelectedControl selected) {
|
||||
return selected.slot() == 0 || selected.slot() >= 2
|
||||
&& !selected.ability().equals("great_sword") && !selected.ability().equals("longbow");
|
||||
}
|
||||
|
||||
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
|
||||
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
|
||||
var captured = item.clone();
|
||||
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
|
||||
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
|
||||
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
|
||||
gesture, target, heldTicks, strength,
|
||||
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
|
||||
if (!input.stillCurrent()) return;
|
||||
try { inputs.accept(input); }
|
||||
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void useEntity(org.bukkit.event.player.PlayerInteractEntityEvent event) {
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItem(event.getHand());
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (event.getHand() == EquipmentSlot.HAND) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && useGesture(selected)) {
|
||||
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
|
||||
? player : event.getRightClicked();
|
||||
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void attack(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event) {
|
||||
// The ability/basic-attack adapter owns intentional damage. Never also execute
|
||||
// vanilla Player.attack with the icon's material, enchantments or attributes.
|
||||
var player = event.getPlayer();
|
||||
var item = player.getInventory().getItemInMainHand();
|
||||
boolean willAttack = event.willAttack();
|
||||
if (special(item)) event.setCancelled(true);
|
||||
if (willAttack) {
|
||||
var selected = sessions.selectedControl(player, item).orElse(null);
|
||||
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword")
|
||||
|| selected.ability().equals("shield_bash"))) {
|
||||
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void armorStand(org.bukkit.event.player.PlayerArmorStandManipulateEvent event) {
|
||||
if (special(event.getPlayerItem()) || special(event.getArmorStandItem())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void consume(org.bukkit.event.player.PlayerItemConsumeEvent event) {
|
||||
if (special(event.getItem())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void place(org.bukkit.event.block.BlockPlaceEvent event) {
|
||||
if (special(event.getItemInHand())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void transfer(org.bukkit.event.inventory.InventoryMoveItemEvent event) {
|
||||
if (special(event.getItem())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void attemptPickup(org.bukkit.event.player.PlayerAttemptPickupItemEvent event) {
|
||||
if (pickupBlocked(event.getPlayer(), event.getItem().getItemStack(), event.getRemaining())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void pickup(org.bukkit.event.entity.EntityPickupItemEvent event) {
|
||||
if (pickupBlocked(event.getEntity(), event.getItem().getItemStack(), event.getRemaining())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void inventoryPickup(org.bukkit.event.inventory.InventoryPickupItemEvent event) {
|
||||
var item = event.getItem().getItemStack();
|
||||
if (pickupClaim(item, "control_") || pickupClaim(item, "storage_")) event.setCancelled(true);
|
||||
}
|
||||
|
||||
private boolean pickupBlocked(org.bukkit.entity.LivingEntity entity, ItemStack item, int remaining) {
|
||||
if (pickupClaim(item, "control_")) return true;
|
||||
if (!pickupClaim(item, "storage_")) return false;
|
||||
var box = box(item);
|
||||
// ItemEntity.playerTouch temporarily narrows the stack to canHold before entity
|
||||
// pickup dispatch, then restores count + remaining before Inventory.add. Never
|
||||
// mistake a partial/oversized claim for one genuine nonstackable box.
|
||||
if (box == null || item.getAmount() != 1 || item.getMaxStackSize() != 1 || remaining != 0
|
||||
|| !item.getPersistentDataContainer().has(BOX_SLOTS, PersistentDataType.INTEGER_ARRAY)
|
||||
|| !Arrays.equals(BOX_SLOT_MAP, item.getPersistentDataContainer().get(BOX_SLOTS, PersistentDataType.INTEGER_ARRAY))
|
||||
|| !(entity instanceof Player player) || !box.owner().equals(player.getUniqueId())
|
||||
|| player.getInventory().getMaxStackSize() < 1) return true;
|
||||
// Purpur 2618 Inventory.add(-1, stack): factory nonstackable boxes cannot merge;
|
||||
// getFreeSlot scans 0..35, NOT the selected slot or only main storage. Read only:
|
||||
// leave native insertion to the caller, and never invent a recovery destination.
|
||||
for (int slot = 0; slot < 36; slot++) {
|
||||
if (empty(player.getInventory().getItem(slot))) return slot < 9;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Pickup-only fail-closed claims: malformed/future metadata must not turn protected
|
||||
// remnants into ordinary loot. This grants no session/recovery authority and does not
|
||||
// change the existing cursor/click policy or EquipmentStorage's payload validation.
|
||||
private static boolean pickupClaim(ItemStack item, String prefix) {
|
||||
if (empty(item)) return false;
|
||||
return item.getPersistentDataContainer().getKeys().stream()
|
||||
.anyMatch(key -> key.getNamespace().equals("fantasystick") && key.getKey().startsWith(prefix));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void launch(com.destroystokyo.paper.event.player.PlayerLaunchProjectileEvent event) {
|
||||
if (special(event.getItemStack())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void shoot(org.bukkit.event.entity.EntityShootBowEvent event) {
|
||||
if (special(event.getBow()) || special(event.getConsumable())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void dispense(org.bukkit.event.block.BlockDispenseArmorEvent event) {
|
||||
if (special(event.getItem()) || event.getTargetEntity() instanceof Player player
|
||||
&& active(player) && (!cosmetic(event.getItem()) || forbiddenEquip(event.getItem()))) event.setCancelled(true);
|
||||
}
|
||||
|
||||
private static boolean forbiddenEquip(ItemStack item) {
|
||||
if (empty(item)) return false;
|
||||
var equippable = item.getData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE);
|
||||
if (equippable == null) return false;
|
||||
return switch (equippable.slot()) {
|
||||
case HEAD -> !cosmetic(item);
|
||||
case CHEST, LEGS, FEET -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean destinationBlocked(Player player, Inventory inventory, int slot, ItemStack item) {
|
||||
if (control(item)) return true;
|
||||
var box = box(item);
|
||||
if (box != null) return !ownerMain(player, inventory, slot, box.owner());
|
||||
if (!active(player) || !ownInventory(player, inventory)) return false;
|
||||
if (slot >= 0 && slot < 9) return true;
|
||||
return !empty(item) && slot >= 36 && slot <= 39 && (slot != 39 || !cosmetic(item));
|
||||
}
|
||||
|
||||
private boolean active(Player player) { return sessions.active(player.getUniqueId()).isPresent(); }
|
||||
private boolean control(ItemStack item) { return pickupClaim(item, "control_move_") || controls.identify(item).isPresent(); }
|
||||
private boolean special(ItemStack item) { return control(item) || box(item) != null; }
|
||||
private static boolean ownInventory(Player player, Inventory inventory) {
|
||||
return inventory instanceof PlayerInventory && inventory.equals(player.getInventory());
|
||||
}
|
||||
private static boolean ownerMain(Player player, Inventory inventory, int slot, UUID owner) {
|
||||
return owner.equals(player.getUniqueId()) && ownInventory(player, inventory) && slot >= 9 && slot < 36;
|
||||
}
|
||||
private static int mainCapacity(Player player, ItemStack item) {
|
||||
int capacity = 0;
|
||||
for (int slot = 9; slot < 36; slot++) {
|
||||
var current = player.getInventory().getItem(slot);
|
||||
if (empty(current)) capacity += item.getMaxStackSize();
|
||||
else if (current.isSimilar(item)) capacity += Math.max(0, item.getMaxStackSize() - current.getAmount());
|
||||
}
|
||||
return capacity;
|
||||
}
|
||||
|
||||
// Identity only, not restoration validation. The slot map and actual contents remain
|
||||
// exclusively EquipmentStorage's responsibility. Names never confer protected identity.
|
||||
private record Box(UUID owner, UUID session) { }
|
||||
private static final NamespacedKey BOX_VERSION = new NamespacedKey("fantasystick", "storage_version");
|
||||
private static final NamespacedKey BOX_OWNER = new NamespacedKey("fantasystick", "storage_owner_id");
|
||||
private static final NamespacedKey BOX_SESSION = new NamespacedKey("fantasystick", "storage_session_id");
|
||||
private static final NamespacedKey BOX_SLOTS = new NamespacedKey("fantasystick", "storage_slots");
|
||||
private static final int[] BOX_SLOT_MAP = {0, 1, 2, 3, 4, 5, 6, 7, 8, 36, 37, 38, 39};
|
||||
private static Box box(ItemStack item) {
|
||||
if (empty(item) || !item.getType().name().endsWith("SHULKER_BOX")) return null;
|
||||
var data = item.getPersistentDataContainer();
|
||||
if (!data.has(BOX_VERSION, PersistentDataType.INTEGER) || !data.has(BOX_OWNER, PersistentDataType.STRING)
|
||||
|| !data.has(BOX_SESSION, PersistentDataType.STRING)
|
||||
|| !Integer.valueOf(1).equals(data.get(BOX_VERSION, PersistentDataType.INTEGER))) return null;
|
||||
String owner = data.get(BOX_OWNER, PersistentDataType.STRING);
|
||||
String session = data.get(BOX_SESSION, PersistentDataType.STRING);
|
||||
if (owner == null || session == null) return null;
|
||||
try {
|
||||
UUID ownerId = UUID.fromString(owner), sessionId = UUID.fromString(session);
|
||||
return ownerId.toString().equals(owner) && sessionId.toString().equals(session) ? new Box(ownerId, sessionId) : null;
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
private static boolean cosmetic(ItemStack item) {
|
||||
if (empty(item)) return false;
|
||||
boolean head = switch (item.getType()) {
|
||||
case PLAYER_HEAD, ZOMBIE_HEAD, CREEPER_HEAD, DRAGON_HEAD, PIGLIN_HEAD,
|
||||
SKELETON_SKULL, WITHER_SKELETON_SKULL, CARVED_PUMPKIN -> true;
|
||||
default -> false;
|
||||
};
|
||||
if (!head) return false;
|
||||
var modifiers = item.getAttributeModifiers(EquipmentSlot.HEAD);
|
||||
if (modifiers != null && (modifiers.containsKey(Attribute.ARMOR) || modifiers.containsKey(Attribute.ARMOR_TOUGHNESS))) return false;
|
||||
return !item.containsEnchantment(Enchantment.PROTECTION) && !item.containsEnchantment(Enchantment.FIRE_PROTECTION)
|
||||
&& !item.containsEnchantment(Enchantment.BLAST_PROTECTION) && !item.containsEnchantment(Enchantment.PROJECTILE_PROTECTION)
|
||||
&& !item.containsEnchantment(Enchantment.FEATHER_FALLING);
|
||||
}
|
||||
private static boolean empty(ItemStack item) { return item == null || item.isEmpty(); }
|
||||
|
||||
private void beginMove(Player player, InventoryView view, ItemStack box) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var active = sessions.active(owner).orElse(null);
|
||||
if (closed || active == null || writing.contains(owner) || moves.containsKey(owner) || swaps.containsKey(owner)
|
||||
|| box.getAmount() != 1 || box.getMaxStackSize() != 1
|
||||
|| !new Box(owner, active.session()).equals(box(box))) return;
|
||||
var move = new BoxMove(player, view, active, box);
|
||||
moves.put(owner, move);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (moves.get(owner) != move || move.issued) return;
|
||||
writing.add(owner);
|
||||
try {
|
||||
if (!moveCurrent(move) || !empty(player.getItemOnCursor()) || source(move) < 0) {
|
||||
cancelMove(move); return;
|
||||
}
|
||||
move.issued = true;
|
||||
player.setItemOnCursor(move.control.clone());
|
||||
if (!issuedCurrent(move)) { cancelMove(move); return; }
|
||||
player.updateInventory();
|
||||
if (!issuedCurrent(move)) cancelMove(move);
|
||||
} catch (RuntimeException failure) { cancelMove(move); report(owner, active.session(), failure); }
|
||||
finally { writing.remove(owner); }
|
||||
});
|
||||
} catch (RuntimeException failure) { cancelMove(move); report(owner, active.session(), failure); }
|
||||
}
|
||||
|
||||
private void placeMove(Player player, InventoryView view, int destination, ItemStack presented) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var move = moves.get(owner);
|
||||
if (move == null || move.player != player || move.view != view || !move.issued || move.placing
|
||||
|| writing.contains(owner) || !moveCurrent(move) || !move.control.equals(presented)) return;
|
||||
var displaced = copy(player.getInventory().getItem(destination));
|
||||
if (special(displaced) && !move.box.equals(displaced)) return;
|
||||
move.placing = true;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (moves.get(owner) != move || !move.placing) {
|
||||
// A cancelled native drag can restore its old harmless carried token AFTER
|
||||
// a reentrant close. Revoke/clean only this issued token, never a replacement.
|
||||
cancelMove(move); return;
|
||||
}
|
||||
move.placing = false; // One-use callback, including native exceptions.
|
||||
writing.add(owner);
|
||||
try {
|
||||
int source = source(move);
|
||||
if (!moveCurrent(move) || source < 0 || !move.control.equals(player.getItemOnCursor())
|
||||
|| !Objects.equals(displaced, copy(player.getInventory().getItem(destination)))) return;
|
||||
// Cursor setters can synchronously reenter close/exit or change either slot.
|
||||
// Remove the harmless control FIRST, then revalidate before touching gear.
|
||||
player.setItemOnCursor(null);
|
||||
if (!moveCurrent(move) || !empty(player.getItemOnCursor()) || source(move) != source
|
||||
|| !Objects.equals(displaced, copy(player.getInventory().getItem(destination)))) return;
|
||||
if (source != destination) {
|
||||
NativeMainInventorySwap.swap(player.getInventory(), source, destination);
|
||||
}
|
||||
moves.remove(owner, move);
|
||||
player.updateInventory(); // No planned writes after this reentrant boundary.
|
||||
} catch (RuntimeException failure) { report(owner, move.active.session(), failure); }
|
||||
finally { cancelMove(move); writing.remove(owner); reconcile(player); }
|
||||
});
|
||||
} catch (RuntimeException failure) { cancelMove(move); report(owner, move.active.session(), failure); }
|
||||
}
|
||||
|
||||
private boolean issuedCurrent(BoxMove move) {
|
||||
return moveCurrent(move) && move.control.equals(move.player.getItemOnCursor()) && source(move) >= 0;
|
||||
}
|
||||
|
||||
private boolean moveCurrent(BoxMove move) {
|
||||
return !closed && moves.get(move.active.player()) == move && move.player.isOnline()
|
||||
&& move.player.getOpenInventory() == move.view
|
||||
&& sessions.active(move.active.player()).filter(move.active::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Follow the unique current box, not its index before a sorting operation. */
|
||||
private int source(BoxMove move) {
|
||||
int found = -1;
|
||||
var expected = new Box(move.active.player(), move.active.session());
|
||||
var inventory = move.player.getInventory();
|
||||
for (int slot = 0; slot < inventory.getSize(); slot++) {
|
||||
var item = inventory.getItem(slot);
|
||||
if (!expected.equals(box(item))) continue;
|
||||
if (found != -1 || slot < 9 || slot >= 36 || !move.box.equals(item)) return -1;
|
||||
found = slot;
|
||||
}
|
||||
return expected.equals(box(move.player.getItemOnCursor())) ? -1 : found;
|
||||
}
|
||||
|
||||
private void cancelMove(UUID owner) {
|
||||
var move = moves.get(owner);
|
||||
if (move != null) cancelMove(move);
|
||||
}
|
||||
|
||||
private void cancelMove(BoxMove move) {
|
||||
UUID owner = move.active.player();
|
||||
moves.remove(owner, move); // Invalidate before a cursor setter can reenter; never revoke a replacement.
|
||||
if (!move.issued) return;
|
||||
boolean reserved = writing.add(owner);
|
||||
try {
|
||||
if (move.control.equals(move.player.getItemOnCursor())) move.player.setItemOnCursor(null);
|
||||
} catch (RuntimeException failure) { report(owner, move.active.session(), failure); }
|
||||
finally { if (reserved) writing.remove(owner); }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
|
||||
UUID owner = event.getPlayer().getUniqueId();
|
||||
cancelCustody(owner);
|
||||
swaps.remove(owner);
|
||||
sessions.invalidateTail(owner);
|
||||
}
|
||||
|
||||
private boolean reservedExchange(Player player, int slot) {
|
||||
if (slot < 0 || slot > 8) return false;
|
||||
var active = sessions.active(player.getUniqueId()).orElse(null);
|
||||
return active != null && sheets.identify(player.getInventory().getItemInOffHand()).filter(active.character()::equals).isPresent()
|
||||
&& controls.owned(player.getInventory().getItem(slot), active.player(), active.session());
|
||||
}
|
||||
|
||||
private void exchange(Player player, int slot, InventoryView view) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed || writing.contains(owner) || swaps.containsKey(owner)) return;
|
||||
var active = sessions.active(owner).orElseThrow();
|
||||
var before = snapshot(player);
|
||||
var cursor = copy(player.getItemOnCursor());
|
||||
var heldBox = box(cursor);
|
||||
// A successful restore consumes exactly this own cursor box, replacing it with
|
||||
// the pre-restore head (or empty). Do not also accept the old box afterwards.
|
||||
var move = moves.get(owner);
|
||||
var restoredCursor = move != null && move.issued && move.control.equals(cursor) ? null
|
||||
: heldBox != null && heldBox.owner().equals(owner) && heldBox.session().equals(active.session())
|
||||
? copy(before[39]) : cursor;
|
||||
var token = new Intent();
|
||||
submit(owner, active.session(), token, () -> {
|
||||
if (closed || !player.isOnline()
|
||||
|| !sessions.active(owner).filter(active::equals).isPresent()
|
||||
|| !Arrays.equals(before, snapshot(player))
|
||||
|| !Objects.equals(cursor, copy(player.getItemOnCursor()))
|
||||
|| (view != null ? player.getOpenInventory() != view : player.getInventory().getHeldItemSlot() != slot)) return;
|
||||
queued.remove(owner);
|
||||
writing.add(owner);
|
||||
try {
|
||||
if (sessions.exit(player) != EquipmentStorage.RestoreResult.RESTORED) return;
|
||||
// Only this still-current, contained hotbar/offhand request is replayed.
|
||||
// Ordinary cursor/slot moves are already complete and never enter this path.
|
||||
if (closed || swaps.get(owner) != token || !Objects.equals(before[40], copy(player.getInventory().getItemInOffHand()))
|
||||
|| !Objects.equals(restoredCursor, copy(player.getItemOnCursor()))
|
||||
|| view != null && player.getOpenInventory() != view) return;
|
||||
var ordinary = copy(player.getInventory().getItem(slot));
|
||||
var sheet = copy(player.getInventory().getItemInOffHand());
|
||||
player.getInventory().setItem(slot, sheet);
|
||||
player.getInventory().setItemInOffHand(ordinary);
|
||||
// An actual exit happened even if the restored ordinary stack is a sheet
|
||||
// for the same character. The coordinator must check the next entry.
|
||||
observed.put(owner, null);
|
||||
player.updateInventory();
|
||||
} finally { writing.remove(owner); }
|
||||
reconcile(player);
|
||||
});
|
||||
}
|
||||
|
||||
private void dropSheet(Player player, InventoryView view, boolean all) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed || writing.contains(owner) || swaps.containsKey(owner)) return;
|
||||
var active = sessions.active(owner);
|
||||
var marker = EquipmentSessionMarker.read(player);
|
||||
var before = snapshot(player);
|
||||
var cursor = copy(player.getItemOnCursor());
|
||||
var token = new Intent();
|
||||
java.util.function.BooleanSupplier current = () -> !closed && swaps.get(owner) == token && player.isOnline()
|
||||
&& EquipmentSessionMarker.read(player).equals(marker) && sessions.active(owner).equals(active) && player.getOpenInventory() == view
|
||||
&& Arrays.equals(before, snapshot(player)) && Objects.equals(cursor, copy(player.getItemOnCursor()));
|
||||
UUID session = active.map(EquipmentSessions.Active::session).orElse(null);
|
||||
submit(owner, session, token, () -> {
|
||||
if (!current.getAsBoolean()) return;
|
||||
writing.add(owner);
|
||||
try {
|
||||
if (empty(before[40]) || before[40].getAmount() > before[40].getMaxStackSize()) {
|
||||
throw new IllegalArgumentException("Offhand sheet references exceed the native stack limit");
|
||||
}
|
||||
var selected = before[40].clone();
|
||||
selected.setAmount(all ? selected.getAmount() : 1);
|
||||
var result = new PlayerSheetDrops(player, event -> player.getServer().getPluginManager().callEvent(event))
|
||||
.dropReferences(selected, current);
|
||||
if (result == ManualDropDelivery.Status.COMPLETE && current.getAsBoolean()) {
|
||||
int remaining = before[40].getAmount() - selected.getAmount();
|
||||
if (remaining > 0) {
|
||||
var retained = before[40].clone();
|
||||
retained.setAmount(remaining);
|
||||
player.getInventory().setItemInOffHand(retained);
|
||||
} else {
|
||||
queued.remove(owner);
|
||||
sessions.cancelEntry(owner);
|
||||
player.getInventory().setItemInOffHand(null);
|
||||
observed.put(owner, null);
|
||||
sessions.exit(player);
|
||||
}
|
||||
player.updateInventory();
|
||||
} else if (result == ManualDropDelivery.Status.UNCONFIRMED || result == ManualDropDelivery.Status.COMPLETE) {
|
||||
report(owner, session, new IllegalStateException(
|
||||
"Sheet drop outcome is uncertain; retained current inventory, never replayed or charged stale contents"));
|
||||
}
|
||||
} finally { writing.remove(owner); reconcile(player); }
|
||||
});
|
||||
}
|
||||
|
||||
private void submit(UUID owner, UUID session, Intent token, Runnable action) {
|
||||
swaps.put(owner, token);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (swaps.get(owner) != token || token.started) return;
|
||||
token.started = true;
|
||||
try { action.run(); }
|
||||
catch (RuntimeException failure) { report(owner, session, failure); }
|
||||
finally { swaps.remove(owner, token); }
|
||||
});
|
||||
} catch (RuntimeException failure) { swaps.remove(owner, token); report(owner, session, failure); }
|
||||
}
|
||||
|
||||
private void report(UUID owner, UUID session, Throwable failure) {
|
||||
try { diagnostics.accept(new EquipmentSessions.Failure(owner, session, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize retries or abort custody cleanup. */ }
|
||||
}
|
||||
|
||||
private static ItemStack copy(ItemStack item) { return item == null || item.isEmpty() ? null : item.clone(); }
|
||||
private static ItemStack[] snapshot(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessionEvents::copy).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
private void reconcile(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var draw = draws.get(owner);
|
||||
if (draw != null && draw.player() == player
|
||||
&& !sessions.selectedControl(player, draw.item()).filter(draw.selected()::equals).isPresent()) cancelDraw(draw);
|
||||
if (closed || writing.contains(owner) || !player.isOnline()) return;
|
||||
var marker = EquipmentSessionMarker.read(player).orElse(null);
|
||||
if (marker != null && (marker.phase() == EquipmentSessionMarker.Phase.PACKING
|
||||
|| marker.phase() == EquipmentSessionMarker.Phase.RESTORING)) return;
|
||||
UUID character = sheets.identify(player.getInventory().getItemInOffHand()).orElse(null);
|
||||
var active = sessions.active(owner).orElse(null);
|
||||
boolean initial = !observed.containsKey(owner);
|
||||
boolean changed = initial || !Objects.equals(observed.get(owner), character);
|
||||
observed.put(owner, character);
|
||||
// A terminal recovery/client-sync observation is not a fresh offhand move.
|
||||
if (initial && active == null && marker != null) return;
|
||||
if (active != null && !active.character().equals(character)) {
|
||||
queued.remove(owner);
|
||||
writing.add(owner);
|
||||
try { sessions.exit(player); }
|
||||
finally { writing.remove(owner); }
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
queued.remove(owner);
|
||||
sessions.cancelEntry(owner);
|
||||
if (character == null || sessions.active(owner).isPresent()) return;
|
||||
Object token = new Object();
|
||||
var entryMarker = EquipmentSessionMarker.read(player);
|
||||
queued.put(owner, token);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!queued.remove(owner, token) || closed || !player.isOnline()
|
||||
|| !EquipmentSessionMarker.read(player).equals(entryMarker)
|
||||
|| sessions.active(owner).isPresent()
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(character::equals).isPresent()) return;
|
||||
sessions.enter(player);
|
||||
});
|
||||
} catch (RuntimeException failure) { queued.remove(owner, token); throw failure; }
|
||||
}
|
||||
|
||||
/** Invalidate deferred native actions before closing the coordinator/stores on disable. */
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var owner : java.util.List.copyOf(moves.keySet())) cancelMove(owner);
|
||||
for (var draw : java.util.List.copyOf(draws.values())) cancelDraw(draw);
|
||||
queued.clear();
|
||||
swaps.clear();
|
||||
observed.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/**
|
||||
* Small versioned proof carried with native player state; owner is implicitly that player.
|
||||
* Server-thread only. One PDC value avoids partially written fields, not cross-save/crash
|
||||
* atomicity. No equipment copy, forced player save, or external recovery journal lives here.
|
||||
*/
|
||||
final class EquipmentSessionMarker {
|
||||
enum Phase { PACKING, PACKED, RESTORING, RESTORED }
|
||||
record Token(UUID session, UUID character, Phase phase) {
|
||||
Token {
|
||||
Objects.requireNonNull(session);
|
||||
Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(phase);
|
||||
}
|
||||
boolean matches(UUID sessionId, UUID characterId) {
|
||||
return session.equals(sessionId) && character.equals(characterId);
|
||||
}
|
||||
}
|
||||
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "equipment_session");
|
||||
|
||||
static boolean present(Player player) { return player.getPersistentDataContainer().has(KEY); }
|
||||
|
||||
static Optional<Token> read(Player player) {
|
||||
var data = player.getPersistentDataContainer();
|
||||
if (!data.has(KEY, PersistentDataType.STRING)) return Optional.empty();
|
||||
String value = data.get(KEY, PersistentDataType.STRING);
|
||||
if (value == null || value.length() > 100) return Optional.empty();
|
||||
String[] fields = value.split("\\|", -1);
|
||||
if (fields.length != 4 || !fields[0].equals("1")) return Optional.empty();
|
||||
try {
|
||||
return Optional.of(new Token(uuid(fields[1]), uuid(fields[2]), Phase.valueOf(fields[3])));
|
||||
} catch (IllegalArgumentException invalid) { return Optional.empty(); }
|
||||
}
|
||||
|
||||
static void write(Player player, UUID session, UUID character, Phase phase) {
|
||||
var token = new Token(session, character, phase);
|
||||
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
|
||||
"1|" + token.session() + "|" + token.character() + "|" + token.phase());
|
||||
}
|
||||
|
||||
static void clear(Player player) { player.getPersistentDataContainer().remove(KEY); }
|
||||
|
||||
private static UUID uuid(String value) {
|
||||
UUID parsed = UUID.fromString(value);
|
||||
if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Noncanonical UUID");
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.items.SessionControls;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/**
|
||||
* Server-thread equipment custody, optional native vitals overlay and intercepted defeat.
|
||||
* Rewards and active food overrides remain separate. Presentation is independently cleaned. No inventory/file transaction,
|
||||
* automatic retry or snapshot rollback is promised. The scheduler must defer onto
|
||||
* the server thread; diagnostics must be thread-safe and must not use Player APIs.
|
||||
*/
|
||||
public final class EquipmentSessions implements AutoCloseable {
|
||||
public record Notice(UUID player, String code, String message) { }
|
||||
public record Failure(UUID player, UUID session, Throwable cause) { }
|
||||
public record Active(UUID player, UUID session, UUID character) { }
|
||||
public record SelectedControl(Active session, int slot, String ability) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private Supplier<Duration> switchDelay;
|
||||
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Notice> notices;
|
||||
private final Consumer<Failure> diagnostics;
|
||||
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
|
||||
private final SessionClaims claims = new SessionClaims();
|
||||
private final EquipmentStorage equipment = new EquipmentStorage();
|
||||
private SessionControls controls = new SessionControls();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
// Covers reentrant inventory setters and client sync, not just asynchronous work.
|
||||
private final Map<UUID, Attempt> transitioning = new HashMap<>();
|
||||
private Consumer<UUID> cursorCustody = ignored -> { };
|
||||
private boolean cursorCustodyAttached;
|
||||
private boolean closed;
|
||||
private java.util.function.BooleanSupplier adventureMode = () -> false;
|
||||
private AdventureModeLease gameModes = new AdventureModeLease();
|
||||
private SessionVitals vitals;
|
||||
private boolean closeSweepFinished;
|
||||
private int persistenceOperations;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player;
|
||||
final UUID owner;
|
||||
UUID character;
|
||||
CharacterRecord record;
|
||||
SessionClaims.Claim claim;
|
||||
UUID session;
|
||||
ItemStack[] snapshot;
|
||||
ItemStack cursor;
|
||||
EquipmentSessionMarker.Token entryMarker;
|
||||
boolean markerValidated;
|
||||
boolean acknowledged;
|
||||
boolean saving;
|
||||
boolean effectsStarted;
|
||||
String previousGameMode;
|
||||
boolean modeStarted;
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
boolean healthAcknowledged = true;
|
||||
boolean nativeRestored;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
boolean presentationInstalled;
|
||||
boolean cleaning;
|
||||
boolean cleanupAttempted;
|
||||
boolean uncertainSave;
|
||||
boolean nativeUncertain;
|
||||
boolean exitRequested;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
Attempt recoveryOwner;
|
||||
org.bukkit.Location entryLocation;
|
||||
boolean defeated;
|
||||
boolean deathResolved;
|
||||
boolean gearAcknowledged;
|
||||
boolean bookAttempted;
|
||||
boolean bookDelivered;
|
||||
boolean deliveryInvalidated;
|
||||
boolean conversionReported;
|
||||
ItemStack deathReference;
|
||||
org.bukkit.inventory.InventoryView deathView;
|
||||
Attempt(Player player, UUID owner, UUID character) {
|
||||
this.player = player; this.owner = owner; this.character = character;
|
||||
}
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
|
||||
this(characters, receipts, switchDelay, Clock.systemUTC(), nextTick, notices, diagnostics);
|
||||
}
|
||||
|
||||
/** Supply the same wall clock as persistence when testing entry/exit deadline boundaries. */
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.switchDelay = () -> initialSwitchDelay;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.notices = Objects.requireNonNull(notices);
|
||||
this.diagnostics = Objects.requireNonNull(diagnostics);
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation) {
|
||||
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics);
|
||||
this.presentation = Objects.requireNonNull(presentation);
|
||||
}
|
||||
|
||||
public EquipmentSessions(CharacterStorage characters, EquipmentSessionStore receipts, Duration switchDelay, Clock clock,
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics,
|
||||
games.dmg.fantasystick.presentation.SessionPresentation presentation,
|
||||
java.util.function.BooleanSupplier adventureMode, AdventureModeLease gameModes) {
|
||||
this(characters, receipts, switchDelay, clock, nextTick, notices, diagnostics, presentation);
|
||||
this.adventureMode = Objects.requireNonNull(adventureMode);
|
||||
this.gameModes = Objects.requireNonNull(gameModes);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
|
||||
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
switchDelay = Objects.requireNonNull(policy);
|
||||
if (switchDelay.get().isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
}
|
||||
|
||||
/** Install before exposing entry so pending and new activation work revalidates availability. */
|
||||
public synchronized void attachRoleplayDisabled(java.util.function.BooleanSupplier policy) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
roleplayDisabled = Objects.requireNonNull(policy);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
controls = new SessionControls(Objects.requireNonNull(registry));
|
||||
}
|
||||
|
||||
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
|
||||
public synchronized void attachVitals(SessionVitals value) {
|
||||
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
|
||||
vitals = Objects.requireNonNull(value);
|
||||
}
|
||||
|
||||
/** One server-thread custody bridge per coordinator; never called by persistence workers. */
|
||||
synchronized void attachCursorCustody(Consumer<UUID> invalidate) {
|
||||
if (cursorCustodyAttached) throw new IllegalStateException("Cursor custody already attached");
|
||||
cursorCustody = Objects.requireNonNull(invalidate);
|
||||
cursorCustodyAttached = true;
|
||||
}
|
||||
|
||||
private void invalidateCursor(UUID owner) {
|
||||
try { cursorCustody.accept(owner); }
|
||||
catch (RuntimeException failure) { diagnostic(new Failure(owner, null, failure)); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes after close's native sweep and all owned receipt/death mutations finish,
|
||||
* including failed or ambiguous outcomes. Retained receipts/conversions are not outstanding operations.
|
||||
* Observers cannot complete/cancel the lifetime authority. Does not await queued server tasks.
|
||||
*/
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
// All accounting, submissions and completion handlers share the lifecycle monitor. A handler
|
||||
// may submit its follow-up before its own reservation is released, including after close.
|
||||
private <T> void persist(Supplier<CompletionStage<T>> operation, BiConsumer<T, Throwable> completion) {
|
||||
persistenceOperations++;
|
||||
CompletionStage<T> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { work = CompletableFuture.failedStage(failure); }
|
||||
work.whenComplete((value, failure) -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
try { completion.accept(value, failure); }
|
||||
finally {
|
||||
persistenceOperations--;
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishDrain() {
|
||||
if (closeSweepFinished && persistenceOperations == 0 && transitioning.isEmpty()) drained.complete(null);
|
||||
}
|
||||
|
||||
public synchronized Optional<Active> active(UUID player) {
|
||||
var attempt = active.get(player);
|
||||
return attempt == null ? Optional.empty() : Optional.of(new Active(player, attempt.session, attempt.character));
|
||||
}
|
||||
|
||||
/** Server-thread effect authorization: registry identity alone is not live custody. */
|
||||
public synchronized boolean current(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return !closed && attempt != null && attempt.session.equals(session.session())
|
||||
&& attempt.character.equals(session.character()) && attempt.player.isOnline()
|
||||
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
|
||||
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
|
||||
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
|
||||
}
|
||||
|
||||
/** Current transferable character snapshot for a still-authorized server-thread action. */
|
||||
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
return current(session) && attempt != null && attempt.record != null
|
||||
? Optional.of(attempt.record) : Optional.empty();
|
||||
}
|
||||
|
||||
/** Apply a persisted progression revision to the still-current active session. */
|
||||
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
|
||||
var attempt = active.get(session.player());
|
||||
if (closed || attempt == null || attempt.session == null || attempt.record == null
|
||||
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|
||||
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|
||||
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
|
||||
var previous = attempt.record;
|
||||
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
|
||||
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
|
||||
if (!previous.loadout().equals(saved.loadout())) {
|
||||
var refreshed = controls.create(saved, attempt.owner, attempt.session);
|
||||
for (int slot = 2; slot < 9; slot++) {
|
||||
if (active.get(attempt.owner) != attempt || !current(session)) return false;
|
||||
attempt.player.getInventory().setItem(slot, refreshed[slot]);
|
||||
}
|
||||
attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Server-thread authorization for the currently held, issued control and saved loadout. */
|
||||
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
|
||||
var attempt = active.get(player.getUniqueId());
|
||||
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
|
||||
|| !sheets.identify(player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
var actual = player.getInventory().getItem(slot);
|
||||
var identity = controls.identify(actual).orElse(null);
|
||||
if (identity == null || identity.slot() != slot || !identity.owner().equals(attempt.owner)
|
||||
|| !identity.session().equals(attempt.session) || !Objects.equals(actual, reported)) return Optional.empty();
|
||||
String ability = "";
|
||||
if (slot >= 2) {
|
||||
ability = controls.ability(actual).orElse("");
|
||||
if (ability.isEmpty() || !ability.equals(attempt.record.loadout().get(slot - 2))) return Optional.empty();
|
||||
}
|
||||
return Optional.of(new SelectedControl(new Active(attempt.owner, attempt.session, attempt.character), slot, ability));
|
||||
}
|
||||
|
||||
public synchronized void enter(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
|
||||
return;
|
||||
}
|
||||
if (transitioning.containsKey(owner)) {
|
||||
notice(owner, "BUSY", "An equipment transition is still completing; retry afterwards."); return;
|
||||
}
|
||||
if (active.containsKey(owner)) return;
|
||||
cancelEntry(owner);
|
||||
if (owned.containsKey(owner)) {
|
||||
notice(owner, "BUSY", "Equipment persistence or recovery is pending; retry after it completes.");
|
||||
return;
|
||||
}
|
||||
var character = sheets.identify(player.getInventory().getItemInOffHand());
|
||||
if (!player.isOnline() || character.isEmpty()) {
|
||||
notice(owner, "INVALID_SHEET", "Hold a valid live character sheet in your off hand.");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player, owner, character.orElseThrow());
|
||||
if (adventureMode.getAsBoolean() && player.getGameMode() != org.bukkit.GameMode.ADVENTURE) {
|
||||
attempt.previousGameMode = player.getGameMode().name();
|
||||
}
|
||||
attempt.snapshot = snapshot(player);
|
||||
attempt.cursor = copy(player.getItemOnCursor());
|
||||
pending.put(owner, attempt);
|
||||
characters.find(attempt.character).thenCombine(receipts.find(owner), Loaded::new)
|
||||
.whenComplete((loaded, failure) -> schedule(attempt, () -> loaded(attempt, loaded, failure)));
|
||||
}
|
||||
|
||||
private record Loaded(Optional<CharacterRecord> character, Optional<EquipmentSessionStore.State> state) { }
|
||||
|
||||
private boolean entryNativeSafe(Attempt attempt, EquipmentSessionStore.State state) {
|
||||
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)
|
||||
|| vitals != null && !vitals.available(attempt.player)) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (EquipmentSessionMarker.present(attempt.player)) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
if (marker == null || marker.phase() != EquipmentSessionMarker.Phase.RESTORED
|
||||
|| state == null || state.pending() != null || exit == null
|
||||
|| !marker.matches(exit.sessionId(), exit.characterId())) return false;
|
||||
}
|
||||
attempt.entryMarker = marker;
|
||||
attempt.markerValidated = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean hasRemnants(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).anyMatch(EquipmentSessions::remnant)
|
||||
|| remnant(player.getItemOnCursor());
|
||||
}
|
||||
|
||||
private static boolean remnant(ItemStack item) {
|
||||
if (item == null || item.isEmpty() || !item.hasItemMeta()) return false;
|
||||
// Even malformed/foreign remnants are unsafe to pack as ordinary equipment.
|
||||
return item.getItemMeta().getPersistentDataContainer().getKeys().stream().anyMatch(key ->
|
||||
key.getNamespace().equals("fantasystick")
|
||||
&& (key.getKey().startsWith("storage_") || key.getKey().startsWith("control_")));
|
||||
}
|
||||
|
||||
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
|
||||
if (!entryCurrent(attempt) || attempt.claim != null) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { fail(attempt, failure); return; }
|
||||
var record = loaded.character().orElse(null);
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is missing, dead or has no health."); return;
|
||||
}
|
||||
if (loaded.state().isPresent()) {
|
||||
var state = loaded.state().orElseThrow();
|
||||
if (state.pending() != null) {
|
||||
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
|
||||
Duration remaining = Duration.between(now, state.lastExit().deadline());
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
|
||||
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
|
||||
+ state.lastExit().deadline() + "); the same character may be resumed now."); return;
|
||||
}
|
||||
}
|
||||
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
|
||||
refuse(attempt, "NATIVE_RECOVERY_REQUIRED", "Native equipment marker or control/box remnants require inspection; entry did not change items.");
|
||||
return;
|
||||
}
|
||||
var refusal = equipment.refusal(attempt.player.getInventory(), attempt.owner, UUID.randomUUID());
|
||||
if (refusal.isPresent()) { refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return; }
|
||||
// Restart receipts can belong to dead/missing characters, so they cannot acquire a
|
||||
// live SessionClaims token. Their unresolved equipment outcome still excludes entry.
|
||||
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
|
||||
refuse(attempt, "CHARACTER_BUSY", "This character has an unresolved equipment session."); return;
|
||||
}
|
||||
attempt.claim = claims.tryClaim(attempt.owner, record).orElse(null);
|
||||
if (attempt.claim == null) { refuse(attempt, "CHARACTER_BUSY", "This character or player already has a session."); return; }
|
||||
attempt.session = attempt.claim.sessionId();
|
||||
owned.put(attempt.owner, attempt);
|
||||
controls.create(record, attempt.owner, attempt.session); // Validate all nine controls before preparation/packing.
|
||||
if (vitals != null) attempt.ordinaryVitals = vitals.capture(attempt.player);
|
||||
attempt.saving = true;
|
||||
persist(() -> receipts.prepare(attempt.owner, attempt.session, attempt.character, attempt.previousGameMode, attempt.ordinaryVitals), (saved, error) -> {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
if (!current(attempt)) { abandon(attempt); return; }
|
||||
// Queue the cross-worker read under lifecycle ownership: close cannot finish
|
||||
// its drain boundary and then have this callback submit fresh character IO.
|
||||
characters.find(attempt.character).whenComplete((fresh, readError) ->
|
||||
schedule(attempt, () -> complete(attempt, fresh, readError)));
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void complete(Attempt attempt, Optional<CharacterRecord> fresh, Throwable failure) {
|
||||
if (!entryCurrent(attempt)) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { fail(attempt, failure); return; }
|
||||
var record = fresh.orElse(null);
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
refuse(attempt, "UNAVAILABLE_CHARACTER", "The saved character is no longer live."); return;
|
||||
}
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
boolean packed = false;
|
||||
try {
|
||||
var items = controls.create(record, attempt.owner, attempt.session);
|
||||
attempt.entryLocation = attempt.player.getLocation().clone(); // Server thread, before activation.
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
if (!gameModes.enter(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false; // Returned refusal is not an uncertain setter exception.
|
||||
refuse(attempt, "GAME_MODE_REFUSED", "Adventure mode was refused; entry did not pack equipment."); return;
|
||||
}
|
||||
if (!entryCurrent(attempt) || attempt.exitRequested) return;
|
||||
attempt.effectsStarted = true;
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKING);
|
||||
var result = equipment.pack(attempt.player.getInventory(), attempt.owner, attempt.session);
|
||||
if (result != EquipmentStorage.PackResult.PACKED) {
|
||||
EquipmentSessionMarker.clear(attempt.player); // Adapter refusal proves no inventory writes.
|
||||
attempt.effectsStarted = false;
|
||||
refuse(attempt, result.name(), "Equipment entry refused: " + result); return;
|
||||
}
|
||||
packed = true;
|
||||
try { sheets.applyModel(attempt.player.getInventory().getItemInOffHand()); }
|
||||
catch (RuntimeException cosmeticFailure) { diagnostic(attempt, cosmeticFailure); }
|
||||
if (interrupted(attempt)) return;
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
attempt.player.getInventory().setItem(slot, items[slot]);
|
||||
if (interrupted(attempt)) return;
|
||||
}
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
if (vitals != null) {
|
||||
attempt.vitalsStarted = true;
|
||||
attempt.healthAcknowledged = false;
|
||||
vitals.enter(attempt.player, attempt.session, attempt.ordinaryVitals, record);
|
||||
if (attempt.exitRequested || !current(attempt)) return;
|
||||
}
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.exitRequested) {
|
||||
attempt.record = record;
|
||||
active.put(attempt.owner, attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
|
||||
}
|
||||
attempt.player.updateInventory();
|
||||
} catch (RuntimeException error) {
|
||||
fail(attempt, error); // Effects may have started: never roll back or replay them.
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
|
||||
} finally {
|
||||
if (!active.containsKey(attempt.owner) && attempt.modeStarted) {
|
||||
restoreGameMode(attempt);
|
||||
if (!attempt.effectsStarted && attempt.exitRequested) pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.effectsStarted && !current(attempt)) abandon(attempt);
|
||||
}
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
// The deferred exit must reserve persistence before drain can be signalled.
|
||||
try {
|
||||
if (packed && !attempt.nativeUncertain && !attempt.defeated && attempt.exitRequested) restoreExit(attempt);
|
||||
} finally { finishDrain(); }
|
||||
}
|
||||
}
|
||||
|
||||
private boolean interrupted(Attempt attempt) {
|
||||
if (current(attempt) || attempt.exitRequested) return false;
|
||||
notice(attempt.owner, "ENTRY_INTERRUPTED", "Entry was cancelled during packing; the genuine box and receipt were retained for recovery.");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends mode without awaiting IO. Reentrant calls during an owned native transition request
|
||||
* teardown at that transition's boundary and return NOT_FOUND (no separate restore ran).
|
||||
* A native exception returns INVALID_BOX with NATIVE_UNCERTAIN notice/diagnostic; never retry it.
|
||||
*/
|
||||
public synchronized EquipmentStorage.RestoreResult exit(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var transition = transitioning.get(owner);
|
||||
if (transition != null) {
|
||||
captureExitDelay(transition);
|
||||
active.remove(owner, transition);
|
||||
withdrawPresentation(transition);
|
||||
transition.exitRequested = true;
|
||||
invalidateCursor(owner);
|
||||
return EquipmentStorage.RestoreResult.NOT_FOUND;
|
||||
}
|
||||
cancelEntry(owner);
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
|
||||
captureExitDelay(attempt);
|
||||
return restoreExit(attempt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper dispatches death before inventory clearing/drop spawning. Cancellation returns from
|
||||
* ServerPlayer.die; keepInventory is neither needed nor sufficient for this transition.
|
||||
* Only an active session owns defeat. Later ordinary deaths and prior vetoes stay ordinary.
|
||||
*/
|
||||
public synchronized void defeat(org.bukkit.event.entity.PlayerDeathEvent event) {
|
||||
if (closed || event.isCancelled()) return;
|
||||
UUID owner = event.getEntity().getUniqueId();
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) {
|
||||
var transition = transitioning.get(owner);
|
||||
if (transition != null && transition.defeated) {
|
||||
// Contain only reentrance inside this native defeat, not a later ordinary
|
||||
// lethal outcome. This is not a grace period or post-defeat invulnerability.
|
||||
event.setCancelled(true);
|
||||
event.setReviveHealth(Objects.requireNonNull(event.getEntity()
|
||||
.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue());
|
||||
}
|
||||
return;
|
||||
}
|
||||
withdrawPresentation(attempt);
|
||||
attempt.defeated = true;
|
||||
event.setCancelled(true);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
|
||||
// closes CharacterStorage. No dependent character work is submitted after this point.
|
||||
persist(() -> characters.recordDeath(attempt.character), (dead, failure) -> {
|
||||
if (failure != null) { diagnostic(attempt, failure); conversionUncertain(attempt, "Death save failed or is uncertain"); return; }
|
||||
if (dead.isEmpty()) { conversionUncertain(attempt, "Character was missing or already dead; no new Tail authorized"); return; }
|
||||
attempt.deathResolved = true;
|
||||
if (closed) { conversionUncertain(attempt, "Shutdown before Tail conversion"); return; }
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) { deliverTail(attempt, dead.orElseThrow()); }
|
||||
});
|
||||
} catch (RuntimeException error) { diagnostic(attempt, error); conversionUncertain(attempt, "Tail callback rejected"); }
|
||||
});
|
||||
try {
|
||||
attempt.deathReference = copy(attempt.player.getInventory().getItemInOffHand());
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
restoreExit(attempt);
|
||||
// Read the current maximum after owned overlay cleanup, including legitimate
|
||||
// ordinary modifiers. Never reconstruct attributes from an entry snapshot.
|
||||
double maximum = Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();
|
||||
event.setReviveHealth(maximum);
|
||||
attempt.player.setHealth(maximum); // Native cancellation only fills health when <= 0.
|
||||
notice(attempt.owner, "DEFEAT", "§cYour character was defeated.");
|
||||
try {
|
||||
if (!attempt.player.teleport(attempt.entryLocation.clone())) {
|
||||
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location was refused; you remain here.");
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
notice(attempt.owner, "DEFEAT_RETURN_FAILED", "Return to the session entry location failed; you remain here.");
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
conversionUncertain(attempt, "Native defeat transition failed; inspect retained equipment and death state");
|
||||
} finally {
|
||||
if (reserved) transitioning.remove(attempt.owner, attempt);
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
|
||||
/** No automatic delivery after quit/respawn, including a reconnect of the same Player ID. */
|
||||
public synchronized void invalidateContext(UUID owner) {
|
||||
invalidateCursor(owner);
|
||||
cancelEntry(owner);
|
||||
invalidateTail(owner);
|
||||
}
|
||||
|
||||
/** Invalidates deferred conversion only; does not move a cursor, box, sheet or controls. */
|
||||
public synchronized void invalidateTail(UUID owner) {
|
||||
var attempt = owned.get(owner);
|
||||
if (attempt != null && attempt.defeated) attempt.deliveryInvalidated = true;
|
||||
}
|
||||
|
||||
private void deliverTail(Attempt attempt, CharacterRecord dead) {
|
||||
if (attempt.bookAttempted) return;
|
||||
attempt.bookAttempted = true; // Even an exception after a native write never permits replay.
|
||||
try {
|
||||
if (!tailContext(attempt)
|
||||
|| !Objects.equals(attempt.deathReference, copy(attempt.player.getInventory().getItemInOffHand()))
|
||||
|| !sheets.identify(attempt.deathReference).filter(attempt.character::equals).isPresent()
|
||||
|| attempt.deathReference.getAmount() < 1
|
||||
|| attempt.deathReference.getAmount() > attempt.deathReference.getMaxStackSize()) {
|
||||
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
|
||||
}
|
||||
// An exact captured stack of copied references represents ONE character, not N
|
||||
// deaths. Replace that whole stack with ONE book; never scan/delete other copies,
|
||||
// move items, overwrite a changed stack, or grant into an available slot.
|
||||
var book = new games.dmg.fantasystick.items.TailBooks().create(dead);
|
||||
attempt.player.getInventory().setItemInOffHand(book);
|
||||
if (!tailContext(attempt) || !book.equals(attempt.player.getInventory().getItemInOffHand())) {
|
||||
conversionUncertain(attempt, "Context changed during Tail write; delivery may already have happened"); return;
|
||||
}
|
||||
attempt.bookDelivered = true;
|
||||
release(attempt);
|
||||
sync(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
diagnostic(attempt, failure);
|
||||
conversionUncertain(attempt, "Tail native write failed or is uncertain; never replay");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tailContext(Attempt attempt) {
|
||||
if (closed || attempt.deliveryInvalidated || owned.get(attempt.owner) != attempt
|
||||
|| !attempt.player.isOnline() || attempt.player.getOpenInventory() != attempt.deathView) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
return marker != null && marker.matches(attempt.session, attempt.character)
|
||||
&& marker.phase() == EquipmentSessionMarker.Phase.RESTORED;
|
||||
}
|
||||
|
||||
private void conversionUncertain(Attempt attempt, String reason) {
|
||||
if (attempt.conversionReported) return;
|
||||
attempt.conversionReported = true;
|
||||
attempt.deliveryInvalidated = true;
|
||||
diagnostic(attempt, new IllegalStateException(reason + "; character " + attempt.character
|
||||
+ ". Retain current items and authoritative state; no automatic reissue or revival."));
|
||||
}
|
||||
|
||||
private void withdrawPresentation(Attempt attempt) {
|
||||
if (!attempt.presentationInstalled) return;
|
||||
attempt.presentationInstalled = false;
|
||||
try { presentation.withdraw(attempt.owner, attempt.session); }
|
||||
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private boolean restoreGameMode(Attempt attempt) {
|
||||
if (attempt.previousGameMode == null) return true;
|
||||
try {
|
||||
if (gameModes.restore(attempt.player, attempt.session, attempt.previousGameMode)) {
|
||||
attempt.modeStarted = false;
|
||||
return true;
|
||||
}
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
notice(attempt.owner, "GAME_MODE_RECOVERY", "Game-mode restoration was refused or ambiguous; retained receipt requires inspection.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
|
||||
captureExitDelay(attempt);
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
try {
|
||||
invalidateCursor(attempt.owner);
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
EquipmentStorage.RestoreResult result;
|
||||
try {
|
||||
result = restoreMarked(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.nativeUncertain = true;
|
||||
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
|
||||
persistExit(attempt, false);
|
||||
diagnostic(attempt, failure);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
}
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else sync(attempt);
|
||||
return result;
|
||||
} finally {
|
||||
if (reserved) transitioning.remove(attempt.owner, attempt);
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
|
||||
private void mark(Attempt attempt, EquipmentSessionMarker.Phase phase) {
|
||||
EquipmentSessionMarker.write(attempt.player, attempt.session, attempt.character, phase);
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreMarked(Attempt attempt) {
|
||||
invalidateCursor(attempt.owner);
|
||||
var marker = EquipmentSessionMarker.read(attempt.player);
|
||||
// Only a successful restoration observed by this process for this exact Player
|
||||
// permits finishing a mode-only retry. Serialized RESTORED alone proves nothing.
|
||||
if (attempt.gearRestoredForModeRecovery && !hasRemnants(attempt.player)
|
||||
&& marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.RESTORED).isPresent()) {
|
||||
return EquipmentStorage.RestoreResult.RESTORED;
|
||||
}
|
||||
if (marker.filter(token -> token.matches(attempt.session, attempt.character)
|
||||
&& token.phase() == EquipmentSessionMarker.Phase.PACKED).isEmpty()) {
|
||||
notice(attempt.owner, "NATIVE_MARKER", "Missing, malformed, mismatched or ambiguous native equipment marker; "
|
||||
+ "expected session " + attempt.session + ", character " + attempt.character + ", observed "
|
||||
+ marker.map(Object::toString).orElse("missing/malformed")
|
||||
+ ". Items and receipt retained. RESTORED receipts require manual reconciliation too.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
|
||||
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
item -> controls.owned(item, attempt.owner, attempt.session),
|
||||
attempt.player.getItemOnCursor(), attempt.player::setItemOnCursor);
|
||||
// Returned refusals are read-only. Exceptions must retain the ambiguous RESTORING phase.
|
||||
mark(attempt, result == EquipmentStorage.RestoreResult.RESTORED
|
||||
? EquipmentSessionMarker.Phase.RESTORED : EquipmentSessionMarker.Phase.PACKED);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void sync(Attempt attempt) {
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private void saveRemainingHealth(Attempt attempt) {
|
||||
if (!attempt.vitalsStarted || attempt.defeated || attempt.healthSaveRequested) return;
|
||||
attempt.healthSaveRequested = true; // Failed/uncertain writes are not authority for an automatic replay.
|
||||
try {
|
||||
var remaining = vitals.remaining(attempt.player, attempt.session, attempt.ordinaryVitals);
|
||||
var limit = NativeHealthLimit.read();
|
||||
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
|
||||
if (failure != null || saved.isEmpty()) {
|
||||
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
|
||||
return;
|
||||
}
|
||||
attempt.healthAcknowledged = true;
|
||||
if (attempt.nativeRestored) persistExit(attempt, true);
|
||||
});
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private boolean restoreVitals(Attempt attempt) {
|
||||
if (vitals == null || attempt.ordinaryVitals == null) return true;
|
||||
try {
|
||||
if (vitals.restore(attempt.player, attempt.session, attempt.ordinaryVitals)) return true;
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
notice(attempt.owner, "VITALS_RECOVERY", "Health/nutrition restoration was refused or ambiguous; retained receipt requires inspection.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void captureExitDelay(Attempt attempt) {
|
||||
if (attempt.exitDelay != null) return;
|
||||
var delay = Objects.requireNonNull(switchDelay.get());
|
||||
if (delay.isNegative()) throw new IllegalStateException("Negative switch delay policy");
|
||||
attempt.exitDelay = delay;
|
||||
}
|
||||
|
||||
private void persistExit(Attempt attempt, boolean restored) {
|
||||
captureExitDelay(attempt); // Recovery attempts have no process-local exit request to capture earlier.
|
||||
attempt.nativeRestored = restored;
|
||||
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
|
||||
attempt.saving = true;
|
||||
persist(() -> complete
|
||||
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) diagnostic(attempt, failure);
|
||||
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel pending activation and route every active player through ordinary restoration. */
|
||||
public synchronized void disableRoleplay() {
|
||||
for (var attempt : java.util.List.copyOf(pending.values())) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
}
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
}
|
||||
|
||||
public synchronized void cancelEntry(UUID player) {
|
||||
var attempt = pending.remove(player);
|
||||
if (attempt != null) abandon(attempt);
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.modeStarted || attempt.effectsStarted || attempt.cleanupAttempted || attempt.uncertainSave) return;
|
||||
if (attempt.acknowledged) {
|
||||
attempt.cleaning = true;
|
||||
attempt.cleanupAttempted = true;
|
||||
persist(() -> receipts.clearReceipt(attempt.owner, attempt.session), (state, failure) -> {
|
||||
attempt.cleaning = false;
|
||||
if (failure == null) release(attempt); else diagnostic(attempt, failure);
|
||||
});
|
||||
} else if (!attempt.saving) release(attempt);
|
||||
}
|
||||
|
||||
private void release(Attempt attempt) {
|
||||
if (attempt.defeated && (!attempt.deathResolved || !attempt.gearAcknowledged || !attempt.bookDelivered)) return;
|
||||
owned.remove(attempt.owner, attempt);
|
||||
if (attempt.claim != null) attempt.claim.close();
|
||||
}
|
||||
private boolean current(Attempt attempt) { return !closed && pending.get(attempt.owner) == attempt; }
|
||||
private boolean entryCurrent(Attempt attempt) {
|
||||
if (!current(attempt)) { abandon(attempt); return false; }
|
||||
if (!attempt.player.isOnline()
|
||||
|| attempt.previousGameMode != null && !attempt.modeStarted
|
||||
&& !attempt.player.getGameMode().name().equals(attempt.previousGameMode)
|
||||
|| attempt.ordinaryVitals != null && !attempt.vitalsStarted && !vitals.unchanged(attempt.player, attempt.ordinaryVitals)
|
||||
|| attempt.markerValidated && (EquipmentSessionMarker.present(attempt.player) != (attempt.entryMarker != null)
|
||||
|| !Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null)))
|
||||
|| !Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
|| !Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|
||||
|| !sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent()) {
|
||||
refuse(attempt, "STALE_ENTRY", "Entry changed before preparation completed; retry with your current equipment.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private void refuse(Attempt attempt, String code, String message) {
|
||||
pending.remove(attempt.owner, attempt);
|
||||
abandon(attempt);
|
||||
notice(attempt.owner, code, message);
|
||||
}
|
||||
private void fail(Attempt attempt, Throwable failure) {
|
||||
if (attempt.effectsStarted && owned.get(attempt.owner) == attempt) attempt.nativeUncertain = true;
|
||||
pending.remove(attempt.owner, attempt);
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
if (attempt.vitalsStarted) {
|
||||
// Only native callbacks can reach this phase. Preserve observed character damage
|
||||
// before independent vitals teardown; the uncertain equipment receipt stays owned.
|
||||
saveRemainingHealth(attempt);
|
||||
restoreVitals(attempt);
|
||||
}
|
||||
abandon(attempt);
|
||||
diagnostic(attempt, failure);
|
||||
}
|
||||
private void diagnostic(Attempt attempt, Throwable failure) {
|
||||
diagnostic(new Failure(attempt.owner, attempt.session, failure));
|
||||
}
|
||||
private void diagnostic(Failure failure) {
|
||||
try { diagnostics.accept(failure); }
|
||||
catch (RuntimeException ignored) { /* Observers cannot abort owned cleanup or shutdown. */ }
|
||||
}
|
||||
private void notice(UUID owner, String code, String message) {
|
||||
try { notices.accept(new Notice(owner, code, message)); }
|
||||
catch (RuntimeException failure) { diagnostic(new Failure(owner, null, failure)); }
|
||||
}
|
||||
private synchronized void schedule(Attempt attempt, Runnable callback) {
|
||||
// close already invalidates pending work and owns its never-packed cleanup. Accepted
|
||||
// reads may still finish, but must not require another task on a disabled scheduler.
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
try { callback.run(); }
|
||||
catch (RuntimeException failure) { fail(attempt, failure); }
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException failure) { fail(attempt, failure); }
|
||||
}
|
||||
private static ItemStack copy(ItemStack item) { return item == null ? null : item.clone(); }
|
||||
private static ItemStack[] snapshot(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).map(EquipmentSessions::copy).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
|
||||
public synchronized void recover(Player player) { recover(player, false); }
|
||||
|
||||
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
|
||||
public synchronized void arrive(Player player) { recover(player, true); }
|
||||
|
||||
private void recover(Player player, boolean arrival) {
|
||||
UUID owner = player.getUniqueId();
|
||||
invalidateCursor(owner);
|
||||
if (closed || !player.isOnline()) return;
|
||||
var previous = owned.get(owner);
|
||||
if (previous != null && previous.defeated && (arrival || previous.gearAcknowledged
|
||||
|| previous.bookDelivered || previous.player != player)) {
|
||||
notice(owner, "DEFEAT_PENDING", "Death or Tail conversion is pending/uncertain; retained state requires inspection, not automatic replay."); return;
|
||||
}
|
||||
if (previous != null && previous.nativeUncertain) {
|
||||
notice(owner, "NATIVE_UNCERTAIN", "A native equipment write had an uncertain outcome; automatic restoration is unsafe. Retain items for inspection."); return;
|
||||
}
|
||||
if (transitioning.containsKey(owner) || active.containsKey(owner)
|
||||
|| previous != null && (previous.saving || previous.cleaning)) {
|
||||
notice(owner, "BUSY", "A session transition is still pending; recovery did not change items."); return;
|
||||
}
|
||||
cancelEntry(owner);
|
||||
var attempt = new Attempt(player, owner, null);
|
||||
attempt.recoveryOwner = previous;
|
||||
// A recovered receipt has no process-local never-packed proof.
|
||||
attempt.effectsStarted = true;
|
||||
attempt.arrival = arrival;
|
||||
pending.put(owner, attempt);
|
||||
receipts.find(owner).whenComplete((state, failure) ->
|
||||
schedule(attempt, () -> recovered(attempt, state, failure)));
|
||||
}
|
||||
|
||||
private synchronized void recovered(Attempt attempt, Optional<EquipmentSessionStore.State> state, Throwable failure) {
|
||||
if (!current(attempt)) return;
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.player.isOnline()) return;
|
||||
if (owned.get(attempt.owner) != attempt.recoveryOwner) {
|
||||
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { diagnostic(attempt, failure); return; }
|
||||
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
|
||||
if (receipt == null) {
|
||||
if (attempt.arrival) {
|
||||
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player);
|
||||
else if (EquipmentSessionMarker.present(attempt.player) || hasRemnants(attempt.player)) {
|
||||
notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection.");
|
||||
}
|
||||
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
|
||||
}
|
||||
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.previousGameMode = receipt.previousGameMode();
|
||||
attempt.ordinaryVitals = receipt.ordinaryVitals();
|
||||
attempt.vitalsStarted = attempt.ordinaryVitals != null;
|
||||
attempt.healthAcknowledged = !attempt.vitalsStarted;
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
|
||||
}
|
||||
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
|
||||
// Explicit same-lifetime equipment recovery still uses matching receipt/PACKED
|
||||
// proof. Keep the original death authority and its pending worker completion;
|
||||
// do not replace it with a recovery Attempt that could release its claim early.
|
||||
previous.deliveryInvalidated = true;
|
||||
restoreExit(previous);
|
||||
return;
|
||||
}
|
||||
if (previous != null) {
|
||||
attempt.claim = previous.claim;
|
||||
attempt.gearRestoredForModeRecovery = previous.player == attempt.player && previous.gearRestoredForModeRecovery;
|
||||
}
|
||||
owned.put(attempt.owner, attempt);
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
try {
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
|
||||
persistExit(attempt, modeRestored && vitalsRestored);
|
||||
sync(attempt);
|
||||
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
} catch (RuntimeException error) {
|
||||
fail(attempt, error);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
|
||||
} finally {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
@Override public synchronized void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
for (var attempt : transitioning.values()) {
|
||||
attempt.exitRequested = true;
|
||||
withdrawPresentation(attempt);
|
||||
}
|
||||
// Accepted main-thread tasks may be discarded on disable. Report now as well as
|
||||
// from worker completion; neither path needs a scheduler on a disabled plugin.
|
||||
for (var attempt : java.util.List.copyOf(owned.values())) {
|
||||
if (attempt.defeated && !attempt.bookDelivered) conversionUncertain(attempt, "Shutdown before confirmed Tail conversion");
|
||||
}
|
||||
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
claims.close();
|
||||
} finally {
|
||||
closeSweepFinished = true;
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
|
||||
/**
|
||||
* Read-only access to the pinned runtime's health range; never changes server settings or attributes.
|
||||
* Call on the server thread after registry initialization. The API exposes no range getter, so use the
|
||||
* supported runtime's public Craft/native accessors and fail explicitly if they are incompatible.
|
||||
* This reads the current range on each call, not a guessed default or cached configuration value.
|
||||
* Native session/health application is separate and must revalidate its context before mutation.
|
||||
*/
|
||||
public final class NativeHealthLimit {
|
||||
private NativeHealthLimit() { }
|
||||
|
||||
public static BigDecimal read() {
|
||||
try {
|
||||
Object range = Attribute.MAX_HEALTH.getClass().getMethod("getHandle").invoke(Attribute.MAX_HEALTH);
|
||||
double maximum = ((Number) range.getClass().getMethod("getMaxValue").invoke(range)).doubleValue();
|
||||
if (!Double.isFinite(maximum) || maximum <= 0
|
||||
|| !Float.isFinite((float) maximum) || (float) maximum <= 0) {
|
||||
throw new IllegalStateException("Native maximum health must be positive and representable as Minecraft health");
|
||||
}
|
||||
return BigDecimal.valueOf(maximum);
|
||||
} catch (ReflectiveOperationException | ClassCastException failure) {
|
||||
throw new IllegalStateException("Cannot read the supported native maximum-health range", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
/**
|
||||
* Purpur 2618 main-storage-only exchange, called on the server thread after revalidation.
|
||||
* Public Craft/native accessors (as with NativeHealthLimit), no private-field access.
|
||||
* Native NonNullList.set only replaces a list reference: no item copying, packet emission,
|
||||
* equip notification, menu recomputation or plugin callback between the two writes.
|
||||
* Caller syncs AFTER completing/invalidation of its one-use move. Fail closed on other APIs.
|
||||
* Not a crash-atomic operation or a portable guarantee for arbitrary Inventory adapters.
|
||||
*/
|
||||
final class NativeMainInventorySwap {
|
||||
private NativeMainInventorySwap() { }
|
||||
|
||||
static void swap(PlayerInventory inventory, int source, int destination) {
|
||||
if (source < 9 || source >= 36 || destination < 9 || destination >= 36) {
|
||||
throw new IllegalArgumentException("Only main storage may be exchanged");
|
||||
}
|
||||
try {
|
||||
var craft = Class.forName("org.bukkit.craftbukkit.inventory.CraftInventoryPlayer");
|
||||
var nativeInventory = Class.forName("net.minecraft.world.entity.player.Inventory");
|
||||
Object handle = craft.getMethod("getInventory").invoke(craft.cast(inventory));
|
||||
Object contents = nativeInventory.getMethod("getNonEquipmentItems").invoke(handle);
|
||||
if (contents.getClass() != Class.forName("net.minecraft.core.NonNullList")
|
||||
|| !(contents instanceof List<?> items) || items.size() != 36) {
|
||||
throw new IllegalStateException("Unsupported native main storage");
|
||||
}
|
||||
exchange(items, source, destination);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Native main-storage exchange unavailable", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void exchange(List<T> items, int source, int destination) {
|
||||
T box = Objects.requireNonNull(items.get(source));
|
||||
T displaced = Objects.requireNonNull(items.get(destination));
|
||||
items.set(destination, box);
|
||||
items.set(source, displaced);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
|
||||
|
||||
/**
|
||||
* Purpur 2618 instance-local effective-value lease. Base/modifiers remain in the native
|
||||
* instance, so additions, removals, potion expiry and serialization keep their normal owners.
|
||||
* The pinned dirty callback is synchronous: no polling interval can expose an external maximum.
|
||||
* No global attribute/range is changed. Native compatibility tests must gate runtime upgrades.
|
||||
*/
|
||||
public final class NativeMaximumHealth implements AutoCloseable {
|
||||
private static final Field DIRTY = field("dirty");
|
||||
private static final Field VALUE = field("cachedValue");
|
||||
private static final Field CALLBACK = field("onDirty");
|
||||
private final AttributeInstance attribute;
|
||||
private final Consumer<AttributeInstance> previous;
|
||||
private final Consumer<AttributeInstance> callback = this::changed;
|
||||
private final double maximum;
|
||||
private boolean closed;
|
||||
|
||||
/** Exercise the pinned hook on a detached instance before exposing any player entry. */
|
||||
public static void prepare() {
|
||||
var probe = new AttributeInstance(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH, ignored -> {});
|
||||
double bound = Math.min(10, NativeHealthLimit.read().doubleValue());
|
||||
try (var lease = new NativeMaximumHealth(probe, bound)) {
|
||||
Objects.requireNonNull(lease);
|
||||
if (probe.getValue() != bound) throw new IllegalStateException("Native maximum hook did not apply");
|
||||
probe.setBaseValue(probe.getBaseValue() + 1);
|
||||
if (probe.getValue() != bound) throw new IllegalStateException("Native dirty hook did not retain the maximum");
|
||||
}
|
||||
if (probe.getValue() != probe.getAttribute().value().sanitizeValue(probe.getBaseValue())) {
|
||||
throw new IllegalStateException("Native maximum hook did not restore ordinary calculation");
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public NativeMaximumHealth(AttributeInstance attribute, double maximum) {
|
||||
this.attribute = Objects.requireNonNull(attribute);
|
||||
if (!Double.isFinite(maximum) || maximum <= 0) throw new IllegalArgumentException("Invalid fantasy maximum");
|
||||
this.maximum = maximum;
|
||||
try { previous = (Consumer<AttributeInstance>) CALLBACK.get(attribute); }
|
||||
catch (ReflectiveOperationException failure) { throw incompatible(failure); }
|
||||
try {
|
||||
CALLBACK.set(attribute, callback);
|
||||
changed(attribute);
|
||||
} catch (ReflectiveOperationException | RuntimeException failure) {
|
||||
try { close(); }
|
||||
catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
|
||||
throw incompatible(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void changed(AttributeInstance changed) {
|
||||
if (!closed) {
|
||||
try {
|
||||
VALUE.setDouble(attribute, maximum);
|
||||
DIRTY.setBoolean(attribute, false);
|
||||
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
|
||||
}
|
||||
previous.accept(changed);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
// Never overwrite a later callback installed by someone else. If it wraps ours,
|
||||
// our now-closed callback simply passes through and no longer overrides values.
|
||||
if (CALLBACK.get(attribute) == callback) CALLBACK.set(attribute, previous);
|
||||
DIRTY.setBoolean(attribute, true);
|
||||
previous.accept(attribute);
|
||||
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
|
||||
}
|
||||
|
||||
private static Field field(String name) {
|
||||
try {
|
||||
var field = AttributeInstance.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (ReflectiveOperationException failure) { throw incompatible(failure); }
|
||||
}
|
||||
private static IllegalStateException incompatible(Exception failure) {
|
||||
return new IllegalStateException("Unsupported native maximum-health internals; no fallback health policy", failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
|
||||
public final class NutritionPolicy implements Listener, AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private boolean closed;
|
||||
|
||||
public NutritionPolicy(EquipmentSessions sessions) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
|
||||
if (!active(event.getEntity())) return;
|
||||
event.setCancelled(true);
|
||||
var player = (Player) event.getEntity();
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
|
||||
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
|
||||
&& active(event.getEntity())) event.setCancelled(true);
|
||||
}
|
||||
|
||||
private boolean active(org.bukkit.entity.Entity entity) {
|
||||
return !closed && entity instanceof Player player
|
||||
&& sessions.active(player.getUniqueId()).isPresent();
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Process-local pending/active session exclusion, not persisted character ownership or native activation.
|
||||
* A player can own any number of sheets, but must finish one session before entering another.
|
||||
* The caller retains the claim across asynchronous entry/active callbacks and releases it on failure/exit.
|
||||
* Share one registry across entry routes in this plugin process; separate instances/processes do not coordinate.
|
||||
* No cooldown, equipment, saved-state reconciliation or native health policy is supplied here.
|
||||
*/
|
||||
public final class SessionClaims implements AutoCloseable {
|
||||
private final java.util.Map<UUID, Claim> players = new java.util.HashMap<>();
|
||||
private final java.util.Map<UUID, Claim> characters = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
* The caller must supply current authoritative saved data, not data copied from an item, and validate
|
||||
* sheet possession, lifecycle, switching deadline and native preflight. This method does no file I/O.
|
||||
*/
|
||||
public synchronized Optional<Claim> tryClaim(UUID player, CharacterRecord character) {
|
||||
java.util.Objects.requireNonNull(player);
|
||||
java.util.Objects.requireNonNull(character);
|
||||
if (closed || character.dead() || character.health().signum() == 0
|
||||
|| players.containsKey(player) || characters.containsKey(character.id())) return Optional.empty();
|
||||
var claim = new Claim(player, character.id());
|
||||
players.put(player, claim);
|
||||
characters.put(character.id(), claim);
|
||||
return Optional.of(claim);
|
||||
}
|
||||
|
||||
public final class Claim implements AutoCloseable {
|
||||
private final UUID player;
|
||||
private final UUID character;
|
||||
private final UUID session = UUID.randomUUID();
|
||||
private Claim(UUID player, UUID character) { this.player = player; this.character = character; }
|
||||
public UUID playerId() { return player; }
|
||||
public UUID characterId() { return character; }
|
||||
public UUID sessionId() { return session; }
|
||||
/** Lifetime/identity check only: does not re-read saved liveness or authorize native effects. */
|
||||
public boolean isCurrent() {
|
||||
synchronized (SessionClaims.this) {
|
||||
return players.get(player) == this && characters.get(character) == this;
|
||||
}
|
||||
}
|
||||
@Override public void close() {
|
||||
synchronized (SessionClaims.this) {
|
||||
players.remove(player, this);
|
||||
characters.remove(character, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
players.clear();
|
||||
characters.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Public particles only; never grants gameplay state, protection, health or persistence authority. */
|
||||
public final class SessionVisuals implements AutoCloseable {
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Throwable> failures;
|
||||
private BukkitTask task;
|
||||
private boolean closed;
|
||||
|
||||
public SessionVisuals(EquipmentSessions sessions, Consumer<Throwable> failures) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
public void install(Plugin plugin) {
|
||||
if (closed || task != null) throw new IllegalStateException("Visual lifetime already used");
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (closed) return;
|
||||
for (var player : plugin.getServer().getOnlinePlayers()) {
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId());
|
||||
if (active.isPresent() && sessions.current(active.orElseThrow())) burst(player, Color.BLUE, 3);
|
||||
} catch (RuntimeException failure) { failures.accept(failure); }
|
||||
}
|
||||
}, 1L, 10L);
|
||||
}
|
||||
public static void burst(Player player, Color color, int count) {
|
||||
player.getWorld().spawnParticle(Particle.DUST, player.getLocation().add(0, 1, 0), count,
|
||||
0.3, 0.4, 0.3, 0, new Particle.DustOptions(color, 1));
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (task != null) { task.cancel(); task = null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.OrdinaryVitals;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Server-thread native overlay; native attributes themselves are never snapshotted/replayed. */
|
||||
public final class SessionVitals implements AutoCloseable {
|
||||
private static final NamespacedKey KEY = new NamespacedKey("fantasystick", "vitals_session");
|
||||
private enum Phase { APPLYING, APPLIED, RESTORING, RELEASED }
|
||||
private record Mark(UUID session, Phase phase, double maximum, OrdinaryVitals ordinary) { }
|
||||
private record Lease(UUID session, NativeMaximumHealth maximum) { }
|
||||
private final Map<UUID, Lease> leases = new HashMap<>();
|
||||
private boolean closed;
|
||||
|
||||
public SessionVitals() { NativeMaximumHealth.prepare(); }
|
||||
|
||||
public OrdinaryVitals capture(Player player) {
|
||||
return new OrdinaryVitals(player.getHealth(), player.getFoodLevel(), player.getSaturation(), player.getExhaustion(),
|
||||
player.isHealthScaled(), player.getHealthScale());
|
||||
}
|
||||
|
||||
public boolean available(Player player) {
|
||||
var mark = read(player);
|
||||
return !closed && (!player.getPersistentDataContainer().has(KEY) || mark != null && mark.phase() == Phase.RELEASED);
|
||||
}
|
||||
|
||||
public boolean unchanged(Player player, OrdinaryVitals ordinary) {
|
||||
return ordinary != null && ordinary.health() > 0 && ordinary.equals(capture(player));
|
||||
}
|
||||
|
||||
/** Caller already acknowledged this ordinary snapshot in its player/session receipt. */
|
||||
public void enter(Player player, UUID session, OrdinaryVitals ordinary, CharacterRecord record) {
|
||||
if (!available(player) || !unchanged(player, ordinary) || leases.containsKey(player.getUniqueId())) {
|
||||
throw new IllegalStateException("Stale native vitals entry");
|
||||
}
|
||||
double maximum = record.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
|
||||
write(player, new Mark(session, Phase.APPLYING, maximum, ordinary));
|
||||
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
|
||||
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
try {
|
||||
var lease = new NativeMaximumHealth(nativeAttribute, maximum);
|
||||
leases.put(player.getUniqueId(), new Lease(session, lease));
|
||||
// Equal to the actual native maximum, not virtual health. Native attribute packets
|
||||
// use CraftPlayer's scaled snapshot, without exposing ordinary modifier arithmetic.
|
||||
// Initialize once BEFORE display delivery can reenter damage/healing handlers.
|
||||
player.setHealth(Math.min(record.health().doubleValue(), maximum));
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setHealthScale(maximum);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
player.setFoodLevel(20);
|
||||
player.setExhaustion(0);
|
||||
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
|
||||
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { releaseMaximum(player, session); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreNutrition(player, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
try { restoreScale(player, maximum, ordinary); }
|
||||
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
|
||||
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
|
||||
// is not permission to heal from a stale snapshot or replay the character save.
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
|
||||
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
|
||||
Objects.requireNonNull(previous);
|
||||
Objects.requireNonNull(saved);
|
||||
var mark = read(player);
|
||||
var lease = leases.get(player.getUniqueId());
|
||||
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|
||||
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|
||||
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
|
||||
throw new IllegalStateException("No matching active fantasy vitals revision");
|
||||
}
|
||||
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
|
||||
double gain = Math.max(0, nextMaximum - mark.maximum());
|
||||
if (gain == 0 && nextMaximum == mark.maximum()) return;
|
||||
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
|
||||
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
|
||||
lease.maximum().close();
|
||||
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
|
||||
leases.put(player.getUniqueId(), new Lease(session, replacement));
|
||||
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
|
||||
player.setHealthScale(nextMaximum);
|
||||
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
|
||||
}
|
||||
|
||||
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
|
||||
throw new IllegalStateException("No matching applied fantasy vitals proof");
|
||||
}
|
||||
return BigDecimal.valueOf(Math.max(0, Math.min(player.getHealth(), mark.maximum())));
|
||||
}
|
||||
|
||||
public boolean restore(Player player, UUID session, OrdinaryVitals ordinary) {
|
||||
if (ordinary == null) return true; // Legacy equipment-only receipt, not health restoration authority.
|
||||
var mark = read(player);
|
||||
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary)
|
||||
|| mark.phase() != Phase.APPLIED) {
|
||||
// Process-local hook ownership is sufficient to retire our maximum override,
|
||||
// never to replay ordinary health/nutrition when native setter proof is missing.
|
||||
releaseMaximum(player, session);
|
||||
return mark != null && mark.session().equals(session) && mark.ordinary().equals(ordinary) && mark.phase() == Phase.RELEASED;
|
||||
}
|
||||
write(player, new Mark(session, Phase.RESTORING, mark.maximum(), ordinary));
|
||||
// Reconcile restored equipment's native modifiers before clamping ordinary health.
|
||||
try { ((CraftPlayer) player).getHandle().detectEquipmentUpdates(); }
|
||||
finally { releaseMaximum(player, session); }
|
||||
// On restart there is no hook: native attribute serialization retained ordinary
|
||||
// base/permanent modifiers, and the native effect lifecycle owns potion expiry/removal.
|
||||
// CraftPlayer scale setters send health packets immediately (or queue one for damage
|
||||
// completion). Restore positive health first: a zero-health packet opens a client death
|
||||
// screen even when Paper cancels the death and the server-side player survives.
|
||||
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
|
||||
restoreNutrition(player, ordinary);
|
||||
restoreScale(player, mark.maximum(), ordinary);
|
||||
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void releaseMaximum(Player player, UUID session) {
|
||||
var lease = leases.get(player.getUniqueId());
|
||||
if (lease != null && lease.session().equals(session)) {
|
||||
leases.remove(player.getUniqueId(), lease);
|
||||
lease.maximum().close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
|
||||
player.setFoodLevel(ordinary.food());
|
||||
player.setSaturation(ordinary.saturation());
|
||||
player.setExhaustion(ordinary.exhaustion());
|
||||
}
|
||||
|
||||
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
|
||||
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
|
||||
player.setHealthScale(ordinary.scale());
|
||||
player.setHealthScaled(ordinary.scaled());
|
||||
}
|
||||
}
|
||||
|
||||
/** Only removes this process's effective-value hooks; never replays unknown ordinary snapshots. */
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
RuntimeException errors = null;
|
||||
for (var lease : java.util.List.copyOf(leases.values())) {
|
||||
try { lease.maximum().close(); }
|
||||
catch (RuntimeException | LinkageError failure) {
|
||||
if (errors == null) errors = new IllegalStateException("Native maximum cleanup failed");
|
||||
errors.addSuppressed(failure);
|
||||
}
|
||||
}
|
||||
leases.clear();
|
||||
if (errors != null) throw errors;
|
||||
}
|
||||
|
||||
private static Mark read(Player player) {
|
||||
var data = player.getPersistentDataContainer();
|
||||
if (!data.has(KEY, PersistentDataType.STRING)) return null;
|
||||
try {
|
||||
String encoded = data.get(KEY, PersistentDataType.STRING);
|
||||
if (encoded.length() > 400) return null;
|
||||
String[] fields = encoded.split("\\|", -1);
|
||||
if (fields.length != 5 || !fields[0].equals("1")) return null;
|
||||
UUID session = UUID.fromString(fields[1]);
|
||||
double maximum = Double.parseDouble(fields[3]);
|
||||
if (!session.toString().equals(fields[1]) || !Double.isFinite(maximum) || maximum <= 0) return null;
|
||||
return new Mark(session, Phase.valueOf(fields[2]), maximum, OrdinaryVitals.decode(fields[4]));
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
private static void write(Player player, Mark mark) {
|
||||
player.getPersistentDataContainer().set(KEY, PersistentDataType.STRING,
|
||||
"1|" + mark.session() + "|" + mark.phase() + "|" + mark.maximum() + "|" + mark.ordinary().encode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
admin-revive-selection-timeout-seconds: 60
|
||||
|
||||
# Real-time delay before this player may enter a different character after exit.
|
||||
character-swap-cooldown-seconds: 900
|
||||
roleplay-disabled: false
|
||||
party-max-size: 6
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
xp-per-level: 100
|
||||
maximum-level: 30
|
||||
|
||||
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
|
||||
passive-xp: 1
|
||||
ordinary-xp: 10
|
||||
boss-xp: 100
|
||||
restricted-kill-spacing-blocks: 3
|
||||
|
||||
# Fireball defaults from the reviewed starter catalogue.
|
||||
fireball-base-damage: 6
|
||||
fireball-cooldown-seconds: 4
|
||||
fireball-range-blocks: 32
|
||||
fireball-lifetime-seconds: 5
|
||||
|
||||
# Great Sword defaults from the reviewed starter catalogue.
|
||||
great-sword-base-damage: 7
|
||||
great-sword-recovery-seconds: 1.2
|
||||
|
||||
# Frost Shard defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
frost-shard-base-damage: 4
|
||||
frost-shard-cooldown-seconds: 5
|
||||
frost-shard-range-blocks: 32
|
||||
frost-shard-lifetime-seconds: 5
|
||||
frost-shard-projectile-speed-blocks-per-tick: 1
|
||||
frost-shard-slowness-amplifier: 0
|
||||
frost-shard-slowness-duration-seconds: 2
|
||||
|
||||
# Longbow defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
longbow-base-damage: 5
|
||||
longbow-recovery-seconds: 1
|
||||
longbow-minimum-draw-ticks: 3
|
||||
longbow-full-draw-ticks: 20
|
||||
longbow-range-blocks: 64
|
||||
longbow-lifetime-seconds: 10
|
||||
longbow-full-projectile-speed-blocks-per-tick: 3
|
||||
|
||||
# Quickstep defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
quickstep-cooldown-seconds: 5
|
||||
quickstep-distance-blocks: 4
|
||||
quickstep-speed-blocks-per-tick: 1
|
||||
|
||||
# Second Wind defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
second-wind-base-healing: 6
|
||||
second-wind-cooldown-seconds: 15
|
||||
|
||||
# Stone Skin defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
stone-skin-cooldown-seconds: 14
|
||||
stone-skin-duration-seconds: 4
|
||||
stone-skin-damage-reduction-percent: 25
|
||||
|
||||
# Shield Bash defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
shield-bash-base-damage: 4
|
||||
shield-bash-cooldown-seconds: 6
|
||||
shield-bash-range-blocks: 3
|
||||
shield-bash-horizontal-knockback: 0.8
|
||||
shield-bash-minimum-vertical-knockback: 0.35
|
||||
|
||||
# Hex defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
hex-cooldown-seconds: 10
|
||||
hex-range-blocks: 12
|
||||
hex-duration-seconds: 4
|
||||
hex-damage-reduction-percent: 20
|
||||
|
||||
# Inspire defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fantasystick config <key> [value]
|
||||
inspire-base-temporary-health: 4
|
||||
inspire-cooldown-seconds: 15
|
||||
inspire-range-blocks: 12
|
||||
inspire-duration-seconds: 6
|
||||
@@ -2,5 +2,15 @@ name: FantasyStick
|
||||
version: ${version}
|
||||
main: games.dmg.fantasystick.FantasyStickPlugin
|
||||
api-version: "26.2"
|
||||
description: Transferable RPG characters for SoMC; bootstrap only, gameplay not implemented.
|
||||
depend: [ProtocolLib]
|
||||
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,225 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityDefinition;
|
||||
import games.dmg.fantasystick.ability.AbilityRegistry;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AbilityEngineNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void executionContractsRevalidateTargetsBoundAreasReportOnlyEffectsAndCleanOwnedWork() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = new AbilityRegistry();
|
||||
var reports = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityReport>();
|
||||
var delayed = new java.util.ArrayList<Runnable>();
|
||||
var closedResources = new AtomicInteger();
|
||||
var delayedEffects = new AtomicInteger();
|
||||
var selectionEffects = new AtomicInteger();
|
||||
var allyEffects = new AtomicInteger();
|
||||
var exceptionalEffects = new AtomicInteger();
|
||||
var areaRecipients = new java.util.ArrayList<org.bukkit.entity.Entity>();
|
||||
var areaCandidates = new java.util.concurrent.atomic.AtomicReference<List<? extends org.bukkit.entity.Entity>>(List.of());
|
||||
registry.register(definition("report_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, execution -> {
|
||||
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
|
||||
execution.report(type, execution.input().target(), BigDecimal.valueOf(2));
|
||||
}
|
||||
}));
|
||||
registry.register(definition("area_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.AREA, execution -> areaRecipients.addAll(execution.eligible(areaCandidates.get(), 2))));
|
||||
registry.register(definition("delayed_test", AbilityDefinition.Activation.DELAYED,
|
||||
AbilityDefinition.TargetPolicy.PARTY, execution -> execution.later(Duration.ofSeconds(1), () -> {
|
||||
delayedEffects.incrementAndGet(); execution.finish();
|
||||
})));
|
||||
registry.register(definition("selection_test", AbilityDefinition.Activation.SELECTION,
|
||||
AbilityDefinition.TargetPolicy.SELECTION, execution -> execution.later(Duration.ofSeconds(1), () -> {
|
||||
selectionEffects.incrementAndGet(); execution.finish();
|
||||
})));
|
||||
registry.register(definition("projectile_test", AbilityDefinition.Activation.PROJECTILE,
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, execution -> execution.own(closedResources::incrementAndGet)));
|
||||
registry.register(definition("ally_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.ALLY, execution -> allyEffects.incrementAndGet()));
|
||||
registry.register(definition("exception_test", AbilityDefinition.Activation.IMMEDIATE,
|
||||
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT, execution -> exceptionalEffects.incrementAndGet()));
|
||||
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
prepare(data, a, List.of("report_test", "area_test", "delayed_test", "selection_test", "projectile_test", "ally_test", "exception_test"));
|
||||
f.activate(a); f.activate(b);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var parties = PartyLifecycleNativeTest.parties(f);
|
||||
var party = parties.create(a.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
|
||||
games.dmg.fantasystick.ability.AbilityScheduler scheduler = (delay, action) -> {
|
||||
delayed.add(action); return closedResources::incrementAndGet;
|
||||
};
|
||||
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry,
|
||||
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
|
||||
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
|
||||
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
|
||||
var active = sessions.active(a.core.id).orElseThrow();
|
||||
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
|
||||
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
cast(engine, f, a, active, 2, "report_test", hostile);
|
||||
assertEquals(java.util.Set.copyOf(java.util.Arrays.asList(games.dmg.fantasystick.ability.AbilityReport.Type.values())),
|
||||
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(5, reports.size());
|
||||
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
|
||||
|
||||
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
|
||||
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
areaCandidates.set(List.of(hostile, ordinary, b.player));
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 3, "area_test", null);
|
||||
assertEquals(List.of(hostile, b.player), areaRecipients);
|
||||
assertEquals(5, reports.size(), "Casting and no-op/recipient discovery do not manufacture reports");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 7, "ally_test", b.player);
|
||||
assertEquals(1, allyEffects.get());
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 8, "exception_test", ordinary);
|
||||
assertEquals(1, exceptionalEffects.get(), "Explicit non-combat policy may select an ordinary player");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 4, "delayed_test", b.player);
|
||||
assertEquals(1, delayed.size());
|
||||
assertTrue(parties.leave(a.core.id, party.id()));
|
||||
delayed.removeFirst().run();
|
||||
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 5, "selection_test", b.player);
|
||||
assertEquals(1, delayed.size());
|
||||
engine.remove(a.core.record.id(), "selection_test");
|
||||
delayed.removeFirst().run();
|
||||
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
|
||||
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(stored.cooldowns().containsKey("selection_test"));
|
||||
|
||||
active = sessions.active(a.core.id).orElseThrow();
|
||||
cast(engine, f, a, active, 6, "projectile_test", hostile);
|
||||
int beforeExit = closedResources.get();
|
||||
PartyCombatNativeTest.exit(f, a);
|
||||
engine.sweep();
|
||||
assertEquals(beforeExit + 1, closedResources.get(), "Session end cleans the projectile definition's owned resource");
|
||||
engine.close();
|
||||
assertEquals(beforeExit + 1, closedResources.get(), "Repeated engine cleanup cannot close owned state twice");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredTestDefinitionUsesRealBookPersistenceControlAndCastRoutes() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
var effects = new AtomicInteger();
|
||||
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
|
||||
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
|
||||
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
|
||||
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(0);
|
||||
|
||||
PartyBookNativeTest.open(f, a);
|
||||
String available = PartyBookNativeTest.text(a);
|
||||
String compactAvailable = available.replaceAll("\\s+", "");
|
||||
assertTrue(compactAvailable.contains("TestFocus"), available);
|
||||
assertTrue(compactAvailable.contains("AMETHYST_SHARD"), available);
|
||||
assertTrue(compactAvailable.contains("Cost:1"), available);
|
||||
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
|
||||
var learn = PartyBookNativeTest.button(a, "[Learn]");
|
||||
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var learned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(java.util.Set.of("test_focus"), learned.learned());
|
||||
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
|
||||
|
||||
var assign = PartyBookNativeTest.button(a, "[3]");
|
||||
PartyBookNativeTest.click(f, a, assign); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals("test_focus", assigned.loadout().getFirst());
|
||||
var control = a.core.inventory.getItem(2);
|
||||
assertEquals(Material.AMETHYST_SHARD, control.getType());
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
|
||||
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, control,
|
||||
null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
|
||||
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
|
||||
assertEquals(1, effects.get());
|
||||
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
|
||||
.ability(a.core.inventory.getItem(2)).orElseThrow());
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
|
||||
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
PartyBookNativeTest.drain(f);
|
||||
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
|
||||
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
|
||||
}
|
||||
}
|
||||
|
||||
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
|
||||
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
|
||||
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
|
||||
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
|
||||
Map.of(), targetPolicy, effect);
|
||||
}
|
||||
|
||||
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> loadout) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var learned = loadout.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
|
||||
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), learned, loadout,
|
||||
old.cooldowns(), old.restrictedKills(), false, null, null);
|
||||
repository.replace(saved, old.revision());
|
||||
}
|
||||
|
||||
private static void cast(games.dmg.fantasystick.ability.AbilityEngine engine,
|
||||
FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int slot, String id,
|
||||
org.bukkit.entity.Entity target) throws Exception {
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
engine.accept(new ControlInput(actor.player, active, slot, id, ControlInput.Kind.ABILITY,
|
||||
ControlInput.Gesture.USE, target, 0, 0, () -> sessions.current(active)));
|
||||
PartyBookNativeTest.drain(fixture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityDefinition;
|
||||
import games.dmg.fantasystick.ability.AbilityRegistry;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AbilityRegistryNativeTest {
|
||||
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception {
|
||||
games.dmg.fantasystick.NativeRuntime.bootstrap();
|
||||
}
|
||||
@Test void registersCompleteStableDefinitionsAndRejectsDuplicateIds() {
|
||||
var registry = new AbilityRegistry();
|
||||
var definition = definition("test_bolt");
|
||||
registry.register(definition);
|
||||
assertSame(definition, registry.find("test_bolt").orElseThrow());
|
||||
assertEquals(List.of(definition), registry.definitions());
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("test_bolt")));
|
||||
assertTrue(registry.find("unknown_saved_id").isEmpty());
|
||||
assertTrue(registry.find("basic_attack").isEmpty(), "The built-in stick is not a learned/refundable definition");
|
||||
}
|
||||
|
||||
@Test void rejectsMalformedMetadataRequirementsAndNumericParametersBeforeRegistration() {
|
||||
var valid = definition("valid");
|
||||
assertAll(
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
|
||||
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
|
||||
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
|
||||
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
|
||||
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
|
||||
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
|
||||
);
|
||||
}
|
||||
|
||||
@Test void effectReportsRequireActualPositiveFiniteEvidence() {
|
||||
var character = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID(); var target = java.util.UUID.randomUUID();
|
||||
var now = java.time.Instant.parse("2026-09-19T20:00:00Z");
|
||||
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
|
||||
assertDoesNotThrow(() -> new games.dmg.fantasystick.ability.AbilityReport(type, character, session, target,
|
||||
BigDecimal.ONE, now));
|
||||
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
|
||||
character, session, target, BigDecimal.ZERO, now));
|
||||
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
|
||||
character, session, target, new BigDecimal("1E1000000"), now));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void definitionsSupportEveryBoundedExecutionStyleAndTargetBoundary() {
|
||||
for (var activation : AbilityDefinition.Activation.values()) {
|
||||
for (var policy : AbilityDefinition.TargetPolicy.values()) {
|
||||
var base = definition("x");
|
||||
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
|
||||
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
|
||||
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static AbilityDefinition definition(String id) {
|
||||
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
|
||||
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
|
||||
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
|
||||
}
|
||||
|
||||
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
|
||||
Map<String, BigDecimal> parameters, Duration cooldown) {
|
||||
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
|
||||
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user