Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea6e254eb | ||
|
|
09dd5ded57 | ||
|
|
9896194932 | ||
|
|
167573f953 | ||
|
|
22c46f6780 | ||
|
|
ea760815bd | ||
|
|
01e6ac6e5a | ||
|
|
0acf90909b | ||
|
|
3a184d5298 | ||
|
|
403dd4b78a | ||
|
|
d4ae188501 | ||
|
|
5e5e21374f | ||
|
|
7353436a77 | ||
|
|
a19909f152 | ||
|
|
7b6ea939a5 | ||
|
|
f2f0dd742f | ||
|
|
c0b5393646 | ||
|
|
cd4d5a1360 | ||
|
|
6563f61f19 | ||
|
|
def83dcb32 | ||
|
|
1a1b1e0693 | ||
|
|
f1b3fc5094 | ||
|
|
6dc706b6ff | ||
|
|
831c0ff3f6 | ||
|
|
18769f4003 | ||
|
|
d2090bd35a | ||
|
|
cd51ef127d | ||
|
|
b62b7952e6 | ||
|
|
39de8516c8 | ||
|
|
3031a30d2c | ||
|
|
b107c08dc3 | ||
|
|
efd64f4f8b | ||
|
|
3b9cb407f0 | ||
|
|
b4c8e09936 | ||
|
|
a630490f5b | ||
|
|
441037f5ff | ||
|
|
7dc23161a3 | ||
|
|
715f3cc6ef | ||
|
|
496b20a3a4 | ||
|
|
a040115275 | ||
|
|
34e02860c0 | ||
|
|
5185501d0d |
No files matched your search
@@ -43,8 +43,9 @@ jobs:
|
||||
run: |
|
||||
npx -y -p @commitlint/cli -p @commitlint/config-conventional \
|
||||
commitlint --from "$BASE_SHA" --to "$HEAD_SHA" --extends @commitlint/config-conventional
|
||||
- name: Build and test
|
||||
run: ./gradlew clean check jar
|
||||
# Full clean checks are required locally for this exact candidate before integration.
|
||||
- name: Compile and package
|
||||
run: ./gradlew clean jar
|
||||
- name: Upload development artifact
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
with:
|
||||
|
||||
@@ -39,8 +39,9 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- name: Build and test before release
|
||||
run: ./gradlew clean check jar
|
||||
# Full clean checks are required locally for this exact candidate before integration.
|
||||
- name: Compile and package before release
|
||||
run: ./gradlew clean jar
|
||||
- name: Capture previous tag
|
||||
id: previous
|
||||
run: |
|
||||
@@ -86,11 +87,14 @@ jobs:
|
||||
else
|
||||
printf 'created=false\n' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Verify versioned plugin and release tooling
|
||||
- name: Package versioned plugin and compile release tooling
|
||||
if: steps.release.outputs.created == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: ./gradlew clean check jar -PreleaseVersion="$VERSION"
|
||||
run: |
|
||||
./gradlew clean jar -PreleaseVersion="$VERSION"
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npm run build
|
||||
- name: Upload release workflow artifact
|
||||
if: steps.release.outputs.created == 'true'
|
||||
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
|
||||
@@ -7,7 +7,7 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
## 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`.
|
||||
- Purpur API/runtime `26.3.build.2651-stable`; `plugin.yml` declares API `26.3`.
|
||||
- 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).
|
||||
|
||||
@@ -16,6 +16,19 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
./gradlew check jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
The full local suite remains mandatory before integrating a candidate. Retain passing reports against its exact Git tree and unchanged verification inputs; changes require renewed verification. This requirement is not satisfied by a packaging-only CI pass.
|
||||
|
||||
For packaging without running tests:
|
||||
|
||||
```sh
|
||||
./gradlew clean jar
|
||||
./gradlew clean jar -PreleaseVersion=0.1.0
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run build` compiles the TypeScript publication tools without executing tests; `npm test` still runs the complete tooling suite.
|
||||
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime and loads vanilla registries/components **without starting a listening server or creating a gameplay world**. The item tests decode signed written books with Minecraft's real codec, without a plugin instance or YAML lookup. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Startup registers the manual crafting controller and automated-Crafter guard before the character-sheet recipe. Equipment sessions and permission-gated administration are registered; spell execution remains absent.
|
||||
@@ -26,6 +39,8 @@ Fantasy Stick independently writes compact CloudEvents 1.0 JSON objects as UTF-8
|
||||
|
||||
A stable plugin-owned server UUID and per-lifetime boot/sequence identity distinguish records. Events rotate at UTC midnight, on each writer lifetime and before 128 MiB; an oversized record stays whole in its own segment. Accepted work is first retained in internal pending files and exported at least once with unchanged IDs, so consumers must deduplicate by `(source, id)`. Startup recovers pending work, quarantines malformed/interrupted tails outside `*.jsonl`, and reports incomplete prior lifetimes without inventing a shutdown. Queue overload and pre-durable native-observation crashes remain truthful loss windows; logging never rolls back or replays gameplay.
|
||||
|
||||
Feature-outbox aggregate overload diagnostics are bounded, rate-limited and off the gameplay thread. Each outbox gives diagnostics only the existing five-second closing-attempt budget: afterward it stops new aggregate callbacks, interrupts its own cooperative handler and retires maintenance bookkeeping once its intent worker finishes. Late rejected observations remain counted but cannot restart aggregate callbacks or resurrect that closed diagnostic owner; they are never durable/exported. Durable pending intents remain available for replay. A completed closing attempt is **not** proof that aggregate diagnostics were logged or that a supplied handler terminated. An interruption-ignoring trusted in-process handler can still retain its executing thread/callback/plugin classloader (and can stall shared diagnostics while running); Java cannot safely kill it. Pending or interrupted aggregate reports are best-effort, not durable event export acknowledgments. Other blocking storage/sink/error callbacks are not made safely cancellable by this safeguard.
|
||||
|
||||
These files have no automatic deletion, compression or retention limit, so disk use grows until operators manage it under a separately approved policy. Current generic records include stable account/character/session IDs and precise locations; later feature-owned event slices may include complete public and recognized private-message text. Restrict event files and backups to operators accordingly. They are not encrypted or cryptographically tamper-evident, and feedback/particle preferences do not disable auditing.
|
||||
|
||||
## Character storage and keepsakes
|
||||
@@ -66,7 +81,7 @@ This is **not a transaction commit**. Eleven native planner/composition tests us
|
||||
|
||||
## 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.
|
||||
`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 2651 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.
|
||||
|
||||
@@ -175,14 +190,43 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad
|
||||
|
||||
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.
|
||||
|
||||
## Dirt Path travel
|
||||
|
||||
An alive, non-downed active character automatically receives native **Speed I** while grounded on foot on ordinary Dirt Paths, including their lowered surface. The character book explains this existing-sheet-and-road acquisition: no point cost, learned passive, slot, personal unlock or XP reward. Jumping, swimming, flying, gliding, mounting, inactive characters and ordinary players are ineligible. Reconciliation uses current custody/position and loaded terrain only, not proposed movement/teleport destinations or chunk loading.
|
||||
|
||||
Road Speed does not stack with equal sources. Learned Speed I and Rallying Cry coordinate their persistent ownership with the short road lease; foreign/equal/stronger native effects and hidden chains are preserved, never surgically rewritten. Leaving eligibility requests only proved road-layer cleanup. Native refusal retains cleanup responsibility; unknown ownership conservatively relinquishes mutation authority. Interrupted cleanup can leave a finite lease until native ticking expires it, but cancellations can delay removal. Offline time is not native ticking, and ownership is not reconstructed across restarts.
|
||||
|
||||
`/fstickadmin config <key> [value]` requires `fantasystick.admin`; omission reads effective policy:
|
||||
|
||||
| Key | Default | Accepted values |
|
||||
| --- | --- | --- |
|
||||
| `path-speed-enabled` | `true` | `true`, `false` |
|
||||
| `path-speed-lease-ticks` | `20` | Integer `1–200` native ticks |
|
||||
|
||||
Saves run off the tick thread, preserve unrelated YAML and queued keys, and require durable acknowledgment plus current callback authority before runtime publication. Lease changes affect subsequent eligible applications/refreshes, not an existing captured budget. False prevents grants/renewals and requests owned cleanup; true never activates a character. Completion is read-only. Startup rejects invalid typed settings and waits asynchronously for prior accepted configuration writes.
|
||||
|
||||
JSONL `effect.applied`/`effect.ended` records use `feature_id=dirt_path_speed` with captured player/character/session/application identity and explicit native disposition. Continuous renewals are not new applications or movement logs. Logging failure/replay cannot authorize or replay speed. Native tests use explicit server/player/terrain/transport ports; no live movement, client rendering or deployment is claimed.
|
||||
|
||||
## Passive potion cleanup boundaries
|
||||
|
||||
Speed I and Low-Light Vision retain their exact holder/session, native effect-instance identity and displaced-effect evidence while native removal is cancelled or unconfirmed. Bounded current-lifetime sweeps retry after downed containment ends; legitimate rescue preserves the same current lease. Equal-looking foreign instances and stronger/unrelated effects are not cleanup authority. A prior snapshot can be restored only after confirmed owned removal into an empty destination and within its remaining lifetime; fixed native tiers and replacement policies are unchanged.
|
||||
|
||||
A read-only adapter for **pinned Purpur 2651** checks native potion ticking and instance identity; bounded stack inspection refuses reentrant mutation during event/native-effect dispatch. This prevents queued by-type requests from later deleting a replacement and avoids treating mutable, in-flight event flags as completed mutation. Native maps, flags and event cancellation are never overridden. Runtime upgrades require renewed compatibility tests; an unavailable phase/identity is not permission for an unsafe fallback. No potion-event listener is needed, including for partial installation cleanup.
|
||||
|
||||
Owner close cancels its sweep timer and attempts only safe cleanup. Closing during native dispatch can schedule one post-dispatch attempt while the plugin is enabled, not an endless shutdown retry. Cancelled/unreadable/unsafe cleanup remains unresolved in the owner, with no false effect-end acknowledgment; shutdown or permanently denied removal does not guarantee cleanup. There is no cross-restart effect-ownership persistence, automatic recovery command or forced removal policy. Audit availability never authorizes native mutation or discards ownership evidence.
|
||||
|
||||
## 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 supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
`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 2651 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.
|
||||
|
||||
Second Wind captures native health/maximum and the current character/session revision, honors the native healing event, and refuses changed state rather than replaying an absolute saved target over intervening damage. Its durable target uses a representable native float step no greater than the accepted healing budget. A worker-owned operation token is forced to `<plugin data directory>/second-wind-health/<character UUID>.pending` before the provisional character save. Confirmed native publication or revision-checked, stable-native refusal reconciliation retires only that operation's proof. Failed/ambiguous saves, changed sessions, conflicting newer writes, rejected callbacks or interrupted cleanup leave proof intact; later character loading/entry, including restart, fails closed. Administrative raw inspection remains read-only. There is no automatic proof deletion, stale-heal replay or recovery command for this uncertainty: do not manually delete pending files, downgrade to an older binary that ignores them, or treat record visibility as native-publication proof. Recovery requires a separately reviewed plan. This is not crash-atomic across Minecraft and plugin files, and it does not change Second Wind's formula, configuration, cooldown ownership or no-self-support policy.
|
||||
|
||||
Health Steal similarly uses current native health, not a stale saved baseline, and rounds its saved target down to a representable native float within the accepted healing budget. Consumed defeats and repeated publication/reconciliation callbacks cannot replay a heal. Its independent operation token is forced to `<plugin data directory>/health-steal-health/<character UUID>.pending` before a provisional save, including an unchanged saved target. Only confirmed native publication or exact-snapshot, stable-native reconciliation retires that operation's proof. Reconciliation may preserve newer unrelated progress that still carries this operation's health, but never overwrites another owner's newer health. Uncertain writes, session/lifecycle changes, rejected callbacks or conflicting/interrupted cleanup retain proof and block later loading/entry, including after restart, even when a character record is visible through administrative raw inspection. This deliberately trades availability for safety: a character may remain unavailable pending a separately reviewed recovery plan. Do not manually delete proof, downgrade to a binary that ignores it, or replay the consumed defeat. No automatic recovery command or crash-atomic guarantee across Minecraft and plugin files is provided. Successful healing audit records describe observed native healing, not a guarantee that subsequent proof cleanup completed. Configuration and normal heal amounts are unchanged.
|
||||
|
||||
An intercepted active-character lethal outcome now creates one durable downed episode rather than immediately recording death. The episode captures its original session, holder, lethal attribution, downed anchor, entry-return location and absolute deadline. While it remains pending, native damage, healing, movement, displacement, inventory/control changes and ordinary gameplay actions are contained; logout and shutdown restore only safe ordinary-holder state while retaining the original countdown. Reconnect rebinds the same proved session at its saved anchor without resetting time. The bounded death menu can accept death, explicitly consume one character-owned Phoenix Down, or wait for the learned, aimed, any-party Revive. Phoenix Down is a persistent Rare Items product with global midnight-UTC stock 0–2, base price 1000 and the shared ±10% pricing policy; a newly delivered product appears only at the next scheduled or authorized restock. Purchase has no immediate effect and the character Inventory instructs use only while downed. An enabled exact-menu choice reserves one item against that episode, competes through the same single-outcome authority, restores full current fantasy health in place and applies the captured `rare-phoenix-down-invulnerability-seconds` deadline (default 5, zero allowed). Logout, restart, waiting, menu reopen, Accept Death, timeout and ordinary Inventory use never consume one automatically. A learned Revive or final death that wins leaves the item intact.
|
||||
|
||||
Accept Death and timeout compete with Revive for one persisted outcome. Successful Revive saves full current fantasy health, resumes the same active character in place and uses one absolute configurable protection deadline. Final death records the decision time—or the original expiry for delayed offline reconciliation—then applies frozen PvP reward/coin shares and party consequences exactly once before guarded Tail conversion. Ordinary health is restored before health-scale setters can send or queue client health packets, and return uses only the captured session-entry location. Refused or unavailable destinations retain explicit recovery evidence. New Tails retain the existing signed, authoritative death identity and readable static character history; stale or ambiguous conversion never overwrites another item or automatically reissues a book.
|
||||
@@ -213,7 +257,7 @@ Automated party tests exercise actual packaged listeners, native book custom-cli
|
||||
|
||||
## CI and publication
|
||||
|
||||
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
Gitea CI compiles/packages pushes and PRs without rerunning test suites; main pushes run semantic-release, package the exact version, compile publication tooling and can publish immutable versioned JARs. Complete local test proof for the exact candidate remains required before integration; verify that the fetched merge tree equals the locally tested tree. Remote success is packaging/publication evidence, not remote test evidence. This temporary separation does not delete tests, alter their limits, waive explicit CI-test criteria, or clear security findings. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
|
||||
The release credential must be a repository-write credential authorized for `dmg/purpur-fantasy-stick`. Provision it through the approved protected-file/stdin workflow; never paste it into chat, commit it, or supply it as a command argument.
|
||||
|
||||
|
||||
+7
-7
@@ -26,10 +26,10 @@ tasks.withType<JavaCompile>().configureEach {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-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("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
@@ -77,18 +77,18 @@ tasks.test {
|
||||
// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base.
|
||||
val nativeRuntime = layout.buildDirectory.dir("native-runtime")
|
||||
val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
|
||||
val launcher = nativeRuntime.map { it.file("purpur-26.2-2618.jar") }
|
||||
val launcher = nativeRuntime.map { it.file("purpur-26.3-2651.jar") }
|
||||
outputs.file(launcher)
|
||||
doLast {
|
||||
val file = launcher.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
|
||||
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
val bytes = connection.getInputStream().use { it.readBytes() }
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
|
||||
"Purpur 2618 checksum mismatch"
|
||||
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
|
||||
"Purpur 2651 checksum mismatch"
|
||||
}
|
||||
file.writeBytes(bytes)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
|
||||
val prepareNativeRuntime = tasks.register<JavaExec>("prepareNativeRuntime") {
|
||||
dependsOn(downloadNativeRuntime)
|
||||
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
|
||||
classpath = files(nativeRuntime.map { it.file("purpur-26.2-2618.jar") })
|
||||
classpath = files(nativeRuntime.map { it.file("purpur-26.3-2651.jar") })
|
||||
mainClass = "io.papermc.paperclip.Main"
|
||||
jvmArgs("-Dpaperclip.patchonly=true")
|
||||
workingDir(nativeRuntime)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"engines": { "node": ">=20" },
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
// Execute the actual checked-in run blocks against strict external build-tool ports.
|
||||
// Real Gradle packaging is verified separately on the final candidate; these ports prove wiring.
|
||||
function buildCommands(workflow: string): string[] {
|
||||
const lines = readFileSync(`.gitea/workflows/${workflow}`, "utf8").split("\n");
|
||||
const commands: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = /^ {8}run: (.+)$/.exec(lines[i]!);
|
||||
if (!match) continue;
|
||||
let command = match[1]!;
|
||||
if (command === "|") {
|
||||
const body: string[] = [];
|
||||
while (i + 1 < lines.length && /^ {10}/.test(lines[i + 1]!)) body.push(lines[++i]!.slice(10));
|
||||
command = body.join("\n");
|
||||
}
|
||||
if (command.includes("./gradlew")) commands.push(command);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), "fantasy-packaging-port-"));
|
||||
mkdirSync(join(root, "bin"));
|
||||
writeFileSync(join(root, "gradlew"), `#!/bin/sh
|
||||
set -eu
|
||||
version=0.1.0-SNAPSHOT
|
||||
printf 'gradle %s\\n' "$*" >> "$CALLS"
|
||||
if [ "$FAIL_PACKAGE" = true ]; then exit 87; fi
|
||||
for arg do
|
||||
case "$arg" in
|
||||
clean|jar) ;;
|
||||
-PreleaseVersion=*) version="\${arg#-PreleaseVersion=}" ;;
|
||||
*) printf 'Unexpected task: %s\\n' "$arg" >&2; exit 91 ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p build/libs build/distributions
|
||||
printf 'external packaging port\\n' > "build/libs/purpur-fantasy-stick-$version.jar"
|
||||
printf 'external resource-pack port\\n' > "build/distributions/purpur-fantasy-stick-$version-resource-pack.zip"
|
||||
`, { mode: 0o700 });
|
||||
writeFileSync(join(root, "bin/npm"), `#!/bin/sh
|
||||
set -eu
|
||||
printf 'npm %s\\n' "$*" >> "$CALLS"
|
||||
case "$*" in
|
||||
'ci --ignore-scripts --no-audit --no-fund') ;;
|
||||
'run build')
|
||||
[ "$BUILD_SCRIPT" = tsc ] || { printf 'Missing compile-only release-tool script\\n' >&2; exit 92; }
|
||||
mkdir -p build/release-tools
|
||||
printf 'external TypeScript compiler port\\n' > build/release-tools/publish-release.js
|
||||
;;
|
||||
*) printf 'Unexpected npm command: %s\\n' "$*" >&2; exit 93 ;;
|
||||
esac
|
||||
`, { mode: 0o700 });
|
||||
const metadata = JSON.parse(readFileSync("package.json", "utf8")) as { scripts: Record<string, string> };
|
||||
return {
|
||||
root,
|
||||
run(command: string, failPackage = false) {
|
||||
return spawnSync("/bin/sh", ["-eu", "-c", command], {
|
||||
cwd: root, encoding: "utf8", timeout: 10_000,
|
||||
env: { PATH: `${join(root, "bin")}:/usr/bin:/bin`, CALLS: join(root, "calls"), VERSION: "3.2.1",
|
||||
BUILD_SCRIPT: metadata.scripts.build ?? "", FAIL_PACKAGE: String(failPackage) },
|
||||
});
|
||||
},
|
||||
close() { rmSync(root, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
|
||||
test("CI packages its development artifact without dispatching verification tasks", () => {
|
||||
const commands = buildCommands("ci.yml");
|
||||
assert.equal(commands.length, 1);
|
||||
const port = fixture();
|
||||
try {
|
||||
const result = port.run(commands[0]!);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(existsSync(join(port.root, "build/libs/purpur-fantasy-stick-0.1.0-SNAPSHOT.jar")));
|
||||
assert.equal(readFileSync(join(port.root, "calls"), "utf8"), "gradle clean jar\n");
|
||||
} finally { port.close(); }
|
||||
});
|
||||
|
||||
test("release packages its exact version and compiles publication tools without running suites", () => {
|
||||
const commands = buildCommands("release.yml");
|
||||
assert.equal(commands.length, 2);
|
||||
const port = fixture();
|
||||
try {
|
||||
for (const command of commands) {
|
||||
const result = port.run(command);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
assert.ok(existsSync(join(port.root, "build/libs/purpur-fantasy-stick-3.2.1.jar")));
|
||||
assert.ok(existsSync(join(port.root, "build/distributions/purpur-fantasy-stick-3.2.1-resource-pack.zip")));
|
||||
assert.ok(existsSync(join(port.root, "build/release-tools/publish-release.js")));
|
||||
assert.equal(readFileSync(join(port.root, "calls"), "utf8"),
|
||||
"gradle clean jar\ngradle clean jar -PreleaseVersion=3.2.1\nnpm ci --ignore-scripts --no-audit --no-fund\nnpm run build\n");
|
||||
} finally { port.close(); }
|
||||
});
|
||||
|
||||
test("failed versioned packaging stops before preparing publication tools", () => {
|
||||
const port = fixture();
|
||||
try {
|
||||
const result = port.run(buildCommands("release.yml")[1]!, true);
|
||||
assert.equal(result.status, 87);
|
||||
assert.equal(readFileSync(join(port.root, "calls"), "utf8"), "gradle clean jar -PreleaseVersion=3.2.1\n");
|
||||
assert.ok(!existsSync(join(port.root, "build/release-tools/publish-release.js")));
|
||||
} finally { port.close(); }
|
||||
});
|
||||
@@ -66,6 +66,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
|
||||
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge characterRecoveryEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> characterRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
|
||||
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
|
||||
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
|
||||
@@ -131,6 +133,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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.PathSpeedSettings pathSpeedSettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
@@ -162,8 +165,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PassivePotionEventBridge passivePotionEvents;
|
||||
private games.dmg.fantasystick.events.PathSpeedEventBridge pathSpeedEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> pathSpeedAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private java.util.concurrent.CompletionStage<Void> passivePotionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.PathSpeed pathSpeed;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
@@ -182,6 +188,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> recoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminActionEvents;
|
||||
private games.dmg.fantasystick.events.AdminCommandEntryObserver adminActionObserver;
|
||||
private java.util.concurrent.CompletionStage<Void> adminActionDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
|
||||
@@ -189,6 +198,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private java.util.concurrent.CompletionStage<Void> combatRewardAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.StickKnockbackEventBridge stickKnockbackEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> stickKnockbackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.QuestTrackingEventBridge questTrackingEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> questTrackingDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> characterBossDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonOwnerEvents;
|
||||
@@ -220,6 +231,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private java.util.concurrent.CompletionStage<Void> partyCapacityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> sheetResourcePackAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.AdminCoinsEventBridge adminCoinsEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminCoinsAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.AdminExperienceEventBridge adminExperienceEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> adminExperienceAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
@@ -259,6 +272,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 pathInitial = games.dmg.fantasystick.persistence.PathSpeedSettings.load(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PathSpeedSettings.ENABLED),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PathSpeedSettings.LEASE));
|
||||
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
@@ -554,7 +570,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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));
|
||||
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem), getServer()::getPlayer);
|
||||
revive.attachDowned(downedController,downedSettings::protection);
|
||||
abilityRegistry.register(fireball.definition());
|
||||
abilityRegistry.register(frostShard.definition());
|
||||
@@ -602,6 +618,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
|
||||
combatFeedback);
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
pathSpeedSettings = new games.dmg.fantasystick.persistence.PathSpeedSettings(getDataFolder().toPath(), pathInitial);
|
||||
pathSpeed = new games.dmg.fantasystick.passive.PathSpeed(sessions, pathInitial);
|
||||
speedI.attachRoad(pathSpeed);
|
||||
rallyingCry.attachRoad(pathSpeed);
|
||||
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.definition());
|
||||
@@ -629,7 +649,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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));
|
||||
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem), getServer()::getPlayer);
|
||||
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
|
||||
Clock.systemUTC(), nextTick, abilityScheduler, report -> {var current=dungeonController;if(current!=null)current.accept(report);}, combatFeedback, trainingDummyController,
|
||||
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
|
||||
@@ -735,8 +755,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Admin experience grant for character " + problem.character(), problem.cause()), particleDelivery);
|
||||
admin.attachExperienceGrant(grantService);
|
||||
var coinsService = new games.dmg.fantasystick.session.AdminCharacterCoins(characters, sessions, nextTick);
|
||||
admin.attachCharacterCoins(coinsService);
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPathSpeed(pathSpeedSettings, pathSpeed);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachPartyEconomySettings(partyEconomySettings);
|
||||
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
|
||||
@@ -907,6 +930,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure));
|
||||
speedI.attachEvents(passivePotionEvents);
|
||||
lowLightVision.attachEvents(passivePotionEvents);
|
||||
pathSpeedEvents = new games.dmg.fantasystick.events.PathSpeedEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Road travel event intent failed", failure));
|
||||
pathSpeed.attachEvents(pathSpeedEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
|
||||
@@ -929,6 +956,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
campAuditEvents = new games.dmg.fantasystick.events.CampEventBridge(eventLog::emit, getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Camp event intent failed", failure));
|
||||
var dungeonActorCapture = campAuditEvents;
|
||||
questTrackingEvents = new games.dmg.fantasystick.events.QuestTrackingEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
sender -> dungeonActorCapture.actor(sender, null), failure -> getLogger().log(Level.WARNING, "Quest tracking event intent failed", failure));
|
||||
adventureQuestController.attachQuestTrackingEvents(questTrackingEvents);
|
||||
dungeonOwnerEvents = new games.dmg.fantasystick.events.DungeonAuditEventBridge(eventLog::emit, getDataFolder().toPath(),
|
||||
sender -> dungeonActorCapture.actor(sender, null), failure -> getLogger().log(Level.WARNING, "Dungeon owner event intent failed", failure));
|
||||
admin.attachDungeonEvents(dungeonOwnerEvents);
|
||||
@@ -1007,6 +1037,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Admin experience event intent failed", failure));
|
||||
grantService.attachEvents(adminExperienceEvents);
|
||||
adminCoinsEvents = new games.dmg.fantasystick.events.AdminCoinsEventBridge(eventLog::emit,
|
||||
Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin coins event intent failed", failure));
|
||||
coinsService.attachEvents(adminCoinsEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
@@ -1099,6 +1133,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
|
||||
outOfCombatRecovery.install(this);
|
||||
speedI.install(this);
|
||||
pathSpeed.install(this);
|
||||
lowLightVision.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
@@ -1115,6 +1150,41 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
|
||||
recipeDiscovery.attachEvents(recipeEvents);
|
||||
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
|
||||
adminActionEvents = new games.dmg.fantasystick.events.AdminActionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Admin action event intent failed", failure));
|
||||
var inspectionLog = eventLog;
|
||||
var inspectionEvents = new games.dmg.fantasystick.events.ActiveInspectionEventBridge(
|
||||
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
|
||||
books.attachInspectionEvents(inspectionEvents);
|
||||
var savedEvents = new games.dmg.fantasystick.events.SavedCharacterEventBridge(
|
||||
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
|
||||
admin.attachSavedEvents(savedEvents);
|
||||
books.attachSavedEvents(savedEvents);
|
||||
admin.attachAdminAudit(adminActionEvents);
|
||||
characterRecoveryEvents = new games.dmg.fantasystick.events.CharacterRecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Character recovery event intent failed", failure));
|
||||
characterRecovery.attachEvents(characterRecoveryEvents);
|
||||
characters.attachRecoveryEvents(characterRecoveryEvents);
|
||||
var replayCharacterRecoveryEvents = characterRecoveryEvents;
|
||||
var replayCharacterRecoveryStorage = characters;
|
||||
characterRecoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous character recovery audit drain failed", failure);
|
||||
replayCharacterRecoveryEvents.replay();
|
||||
replayCharacterRecoveryStorage.replayRecoveryEvents().whenComplete((unused, replayFailure) -> {
|
||||
if (replayFailure != null) replayCharacterRecoveryEvents.diagnostic(replayFailure);
|
||||
});
|
||||
});
|
||||
eventCapture.excludeOwnedTeleport(adventureQuestCommands::ownsTeleport);
|
||||
eventCapture.excludeOwnedTeleport(dungeonCommands::ownsTeleport);
|
||||
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents, savedEvents);
|
||||
getServer().getPluginManager().registerEvents(adminActionObserver, this);
|
||||
var replayAdminActions = adminActionEvents;
|
||||
adminActionDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin action intent drain failed", failure);
|
||||
replayAdminActions.replay();
|
||||
});
|
||||
var replayNutritionEvents = nutritionAuditEvents;
|
||||
nutritionAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous nutrition event intent drain failed", failure);
|
||||
@@ -1135,11 +1205,21 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous sheet pack event intent drain failed", failure);
|
||||
if (replaySheetPackEvents != null) replaySheetPackEvents.replay();
|
||||
});
|
||||
var replayCoinsEvents = adminCoinsEvents;
|
||||
adminCoinsAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin coins intent drain failed", failure);
|
||||
if (replayCoinsEvents != null) replayCoinsEvents.replay();
|
||||
});
|
||||
var replayGrantEvents = adminExperienceEvents;
|
||||
adminExperienceAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
|
||||
if (replayGrantEvents != null) replayGrantEvents.replay();
|
||||
});
|
||||
var replayRoadEvents = pathSpeedEvents;
|
||||
pathSpeedAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous road event drain failed", failure);
|
||||
if (replayRoadEvents != null) replayRoadEvents.replay();
|
||||
});
|
||||
var replayPassivePotionEvents = passivePotionEvents;
|
||||
passivePotionAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
|
||||
@@ -1155,6 +1235,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous hard-mob XP event drain failed", failure);
|
||||
if (replayHardMobXpEvents != null) replayHardMobXpEvents.replay();
|
||||
});
|
||||
var replayQuestTrackingEvents = questTrackingEvents;
|
||||
questTrackingDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous quest tracking event drain failed", failure);
|
||||
if (replayQuestTrackingEvents != null) replayQuestTrackingEvents.replay();
|
||||
});
|
||||
var replayCharacterBossEvents = characterBossEvents;
|
||||
characterBossDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous character boss event drain failed", failure);
|
||||
@@ -1276,7 +1361,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingAdminActionEvents = adminActionEvents; adminActionEvents = null;
|
||||
var closingAdminActionObserver = adminActionObserver; adminActionObserver = null;
|
||||
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
|
||||
var closingCharacterRecoveryEvents = characterRecoveryEvents; characterRecoveryEvents = null;
|
||||
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
|
||||
var closingParticlesCommand = particlesCommand; particlesCommand = null;
|
||||
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
|
||||
@@ -1292,9 +1380,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingRoleplayEvents = roleplayAvailabilityEvents; roleplayAvailabilityEvents = null;
|
||||
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
|
||||
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
|
||||
var closingCoinsEvents = adminCoinsEvents; adminCoinsEvents = null;
|
||||
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
|
||||
var closingKnockbackEvents = stickKnockbackEvents; stickKnockbackEvents = null;
|
||||
var closingHardMobXpEvents = hardMobXpAuditEvents; hardMobXpAuditEvents = null;
|
||||
var closingQuestTrackingEvents = questTrackingEvents; questTrackingEvents = null;
|
||||
var closingCharacterBossEvents = characterBossEvents; characterBossEvents = null;
|
||||
var closingDungeonOwnerEvents = dungeonOwnerEvents; dungeonOwnerEvents = null;
|
||||
var closingCampEvents = campAuditEvents; campAuditEvents = null;
|
||||
@@ -1306,6 +1396,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingCombatRewardEvents = combatRewardEvents;
|
||||
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
|
||||
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null;
|
||||
var closingPathSpeedEvents = pathSpeedEvents; pathSpeedEvents = null;
|
||||
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
|
||||
@@ -1388,6 +1479,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPathSpeedSettings = pathSpeedSettings; pathSpeedSettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
|
||||
var closingGearSettings=gearSettings;gearSettings=null;
|
||||
@@ -1415,6 +1507,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
|
||||
var closingHealthSteal = healthSteal; healthSteal = null;
|
||||
var closingSpeedI = speedI; speedI = null;
|
||||
var closingPathSpeed = pathSpeed; pathSpeed = null;
|
||||
var closingLowLightVision = lowLightVision; lowLightVision = null;
|
||||
var closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = null;
|
||||
@@ -1455,6 +1548,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 (closingPathSpeedSettings != null) cleanup("road configuration", closingPathSpeedSettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
|
||||
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
|
||||
@@ -1553,6 +1647,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained().toCompletableFuture(),
|
||||
closingPathSpeedSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPathSpeedSettings.drained().toCompletableFuture(),
|
||||
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRoleplaySettings.drained().toCompletableFuture(),
|
||||
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -1589,6 +1685,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
|
||||
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
|
||||
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
|
||||
if (closingPathSpeed != null) cleanup("Dirt Path travel", closingPathSpeed::close);
|
||||
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
|
||||
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
@@ -1657,6 +1754,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSheetPackEvents.drained());
|
||||
});
|
||||
if (closingCoinsEvents != null) cleanup("admin coins event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null ? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
: closingCharacters.drained();
|
||||
adminCoinsAuditDrain = storageDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character drain before coins events failed", failure);
|
||||
closingCoinsEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingCoinsEvents.drained());
|
||||
});
|
||||
if (closingGrantEvents != null) cleanup("admin experience event intent drain", () -> {
|
||||
var storageDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
|
||||
@@ -1667,6 +1772,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingGrantEvents.drained());
|
||||
});
|
||||
if (closingPathSpeedEvents != null) cleanup("road travel event intent drain", () -> {
|
||||
closingPathSpeedEvents.close();
|
||||
pathSpeedAuditDrain = closingPathSpeedEvents.drained();
|
||||
});
|
||||
if (closingPassivePotionEvents != null) cleanup("passive potion event intent drain", () -> {
|
||||
closingPassivePotionEvents.close();
|
||||
passivePotionAuditDrain = closingPassivePotionEvents.drained();
|
||||
@@ -1679,6 +1788,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingHardMobXpEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingHardMobXpEvents.drained());
|
||||
});
|
||||
if (closingQuestTrackingEvents != null) cleanup("quest tracking event intent drain", () -> {
|
||||
var authorityDrained = closingAdventureQuests == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingAdventureQuests.drained();
|
||||
questTrackingDrain = authorityDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Quest tracking authority drain failed", failure);
|
||||
closingQuestTrackingEvents.close(); return null;
|
||||
}).thenCompose(ignored -> closingQuestTrackingEvents.drained());
|
||||
});
|
||||
if (closingCharacterBossEvents != null) cleanup("character boss event intent drain", () -> {
|
||||
var dungeonDrained = closingDungeons == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingDungeons.drained();
|
||||
@@ -1855,13 +1972,44 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingRareEvents.close();return null;
|
||||
}).thenCompose(ignored -> closingRareEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingAdminActionEvents != null) cleanup("admin action event intent drain", () -> {
|
||||
closingAdminActionEvents.close();
|
||||
adminActionDrain = closingAdminActionEvents.drained();
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", () -> {
|
||||
// Preserve the bounded synchronous writer shutdown/lock release at disable return.
|
||||
// Fence the added road owner plus central handoff, not an all-legacy-owner guarantee.
|
||||
var handoff = java.util.concurrent.CompletableFuture.allOf(
|
||||
adminActionDrain.toCompletableFuture(), pathSpeedAuditDrain.toCompletableFuture());
|
||||
var writerClosed = new java.util.concurrent.CompletableFuture<Void>();
|
||||
adminActionDrain = writerClosed;
|
||||
Throwable handoffFailure = null;
|
||||
try {
|
||||
handoff.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
handoffFailure = interrupted;
|
||||
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException unavailable) {
|
||||
handoffFailure = unavailable;
|
||||
}
|
||||
if (handoffFailure != null)
|
||||
getLogger().log(Level.WARNING, "Central administrator audit handoff did not drain", handoffFailure);
|
||||
try {
|
||||
closingEventLog.close();
|
||||
if (handoffFailure == null) writerClosed.complete(null);
|
||||
else writerClosed.completeExceptionally(handoffFailure);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
writerClosed.completeExceptionally(failure);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
// Writer shutdown can still acknowledge already accepted preference exports.
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
unregister(closingChat);
|
||||
unregister(closingEventCapture);
|
||||
unregister(closingAdminActionObserver);
|
||||
unregister(closingMessageAuditEvents);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
@@ -1903,6 +2051,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingCharacterRecoveryEvents != null) cleanup("character recovery event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
characterRecoveryAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character recovery save drain failed", failure);
|
||||
closingCharacterRecoveryEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingCharacterRecoveryEvents.drained());
|
||||
});
|
||||
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
|
||||
@@ -30,22 +30,22 @@ import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
|
||||
/** Deterministic INT-scaled bounded lightning chain with exact decimal decay. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier) {
|
||||
int additionalTargets, BigDecimal jumpMultiplier, BigInteger targetIntStep, int maxTargets) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier); Objects.requireNonNull(targetIntStep);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|
||||
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|
||||
|| additionalTargets < 0 || additionalTargets > 63
|
||||
|| additionalTargets < 0 || additionalTargets > 63 || targetIntStep.signum() <= 0 || maxTargets < 1 || maxTargets > 64
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 4, new BigDecimal("0.75"), BigInteger.valueOf(5), 12);
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
@@ -82,8 +82,9 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
|
||||
+ current.additionalTargets() + " times.",
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, with "
|
||||
+ Math.min(current.maxTargets(), 1 + current.additionalTargets()) + " base total targets at INT 20.",
|
||||
"Gain one target per " + current.targetIntStep() + " purchased INT above 20, capped at " + current.maxTargets() + " total targets.",
|
||||
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
|
||||
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
|
||||
@@ -93,7 +94,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
"jump-multiplier", current.jumpMultiplier(),
|
||||
"target-int-step", new BigDecimal(current.targetIntStep()), "max-targets", BigDecimal.valueOf(current.maxTargets())), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
@@ -105,6 +107,13 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
public static int targetBudget(BigInteger intelligence, Settings captured) {
|
||||
Objects.requireNonNull(intelligence); Objects.requireNonNull(captured);
|
||||
var earned = intelligence.subtract(BigInteger.valueOf(20)).max(BigInteger.ZERO).divide(captured.targetIntStep());
|
||||
return earned.add(BigInteger.valueOf(1L + captured.additionalTargets()))
|
||||
.min(BigInteger.valueOf(captured.maxTargets())).intValueExact();
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && selectInitial(input, settings.initialRange()) != null;
|
||||
}
|
||||
@@ -117,7 +126,7 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
int maximum = targetBudget(execution.character().stats().get(Stat.INT), captured);
|
||||
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
|
||||
@@ -254,7 +263,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"));
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"),
|
||||
p.get("target-int-step").toBigIntegerExact(), p.get("max-targets").intValueExact());
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
|
||||
@@ -47,7 +47,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 48, Duration.ofSeconds(5),
|
||||
3, 0, Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
@@ -150,8 +150,8 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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 "
|
||||
List.of("Launch a frost projectile with a " + BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString() + "-block travel limit.",
|
||||
"Deals 4 * (" + 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.Category.ATK,
|
||||
@@ -187,7 +187,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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);
|
||||
return base.add(modifier).multiply(BigInteger.valueOf(4));
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
|
||||
@@ -54,7 +54,9 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
}
|
||||
for (var entry : eligible.entrySet()) {
|
||||
if (!recipients.containsKey(entry.getKey())) {
|
||||
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
|
||||
var benefit = new Benefit(this, entry.getValue());
|
||||
if (road != null && !eligible(entry.getValue())) benefit.close();
|
||||
else recipients.put(entry.getKey(), benefit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +98,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
final UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
speed = speed(player, area.settings.speedAmplifier(), () -> !closed && !area.closed && area.eligible(player));
|
||||
if (road != null && !area.eligible(player)) {
|
||||
closeResource(speed); bonus = () -> { }; application = null; return;
|
||||
}
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
@@ -118,8 +123,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
}
|
||||
|
||||
private final class SpeedGrant implements AutoCloseable {
|
||||
final SpeedState state; final long sequence; final int amplifier; boolean closed;
|
||||
SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; }
|
||||
final SpeedState state; final long sequence; final int amplifier;
|
||||
final java.util.function.BooleanSupplier eligible; boolean closed;
|
||||
SpeedGrant(SpeedState state, int amplifier, java.util.function.BooleanSupplier eligible) {
|
||||
this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; this.eligible = eligible;
|
||||
}
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class SpeedState {
|
||||
@@ -131,7 +139,7 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) owned = null;
|
||||
SpeedGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null
|
||||
for (var grant : List.copyOf(grants)) if (!grant.closed && (road == null || grant.eligible.getAsBoolean()) && (strongest == null
|
||||
|| grant.amplifier > strongest.amplifier
|
||||
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
if (strongest == null) {
|
||||
@@ -142,6 +150,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
if (!removeOwned()) return;
|
||||
current = null;
|
||||
}
|
||||
if (road != null && road.hasRoadLease(player)) {
|
||||
if (!road.releaseForPersistent(player)) return;
|
||||
if (closed || strongest.closed || !strongest.eligible.getAsBoolean()) return;
|
||||
current = null;
|
||||
}
|
||||
if (current != null && current.getAmplifier() >= strongest.amplifier) return;
|
||||
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
|
||||
strongest.amplifier, false, false, true);
|
||||
@@ -166,6 +179,11 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
private long sequences; private long speedSequences;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
private games.dmg.fantasystick.passive.PathSpeed road;
|
||||
public void attachRoad(games.dmg.fantasystick.passive.PathSpeed value) {
|
||||
if (closed || road != null || !areas.isEmpty()) throw new IllegalStateException("Road coordination already attached or Rally started");
|
||||
road = Objects.requireNonNull(value);
|
||||
}
|
||||
|
||||
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
@@ -211,9 +229,9 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
private AutoCloseable speed(Player player, int amplifier, java.util.function.BooleanSupplier eligible) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
var grant = new SpeedGrant(state, amplifier, eligible); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) {
|
||||
area.endReason = "eligibility_changed"; area.close();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.items.TailRevivalDelivery;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
@@ -48,8 +48,8 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final java.util.function.Function<UUID, Player> players;
|
||||
private final TailBooks tails = new TailBooks();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private games.dmg.fantasystick.session.DownedController downed;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
|
||||
@@ -73,31 +73,36 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
void resolve(String outcome,String stage){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.resolved(cast,outcome,stage);}}
|
||||
void fail(String reason){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.failed(cast,reason);}}
|
||||
void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,target);}}
|
||||
void revived(RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,games.dmg.fantasystick.events.ReviveEventBridge.Carrier carrier){if(cast!=null&&!eventResolved){eventResolved=true;reviveEvents.revived(cast,reservation,dead,restored,carrier);}}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
|
||||
inventory.clear();
|
||||
if(!selecting)resolve("cancelled_or_expired","tail_selection_closed");
|
||||
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
try { if (!selecting) target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
|
||||
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
|
||||
PopupFactory popups, Consumer<Throwable> failures) {
|
||||
PopupFactory popups, Consumer<Throwable> failures, java.util.function.Function<UUID, Player> players) {
|
||||
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);
|
||||
this.players = Objects.requireNonNull(players);
|
||||
}
|
||||
|
||||
public void attachDowned(games.dmg.fantasystick.session.DownedController controller,java.util.function.Supplier<Duration> protection){if(closed||downed!=null)throw new IllegalStateException("Downed Revive already attached or closed");downed=Objects.requireNonNull(controller);downedProtection=Objects.requireNonNull(protection);}
|
||||
public void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(closed||downedEvents!=null)throw new IllegalStateException("Downed Revive events already attached or closed");downedEvents=Objects.requireNonNull(events);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.ReviveEventBridge events){if(closed||reviveEvents!=null)throw new IllegalStateException("Revive events already attached or closed");reviveEvents=Objects.requireNonNull(events);}
|
||||
public void attachAdminRevivalEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events){if(closed||adminRevivalEvents!=null)throw new IllegalStateException("Admin revival events already attached or closed");adminRevivalEvents=Objects.requireNonNull(events);}
|
||||
private void recoveryEvent(RevivalStore.Reservation reservation,CharacterRecord restored,Player target){
|
||||
if(reservation.administrative()){if(adminRevivalEvents!=null)adminRevivalEvents.recovered(reservation,restored,target);}
|
||||
else if(reviveEvents!=null)reviveEvents.recovered(reservation,restored,target);
|
||||
private Runnable recoveryAcknowledgment(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
if (reservation.administrative()) {
|
||||
var snapshot = adminRevivalEvents == null ? null : adminRevivalEvents.snapshot(target);
|
||||
return () -> { if (snapshot != null) adminRevivalEvents.recovered(reservation, restored, snapshot); };
|
||||
}
|
||||
var snapshot = reviveEvents == null ? null : reviveEvents.snapshot(target);
|
||||
return () -> { if (snapshot != null) reviveEvents.recovered(reservation, restored, snapshot); };
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
@@ -216,7 +221,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
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.selecting = true; inventories.remove(view.inventory, view); // Keep the selected operation under quit/close ownership.
|
||||
view.inventory.clear(); view.target.closeInventory();
|
||||
confirm(view, choice);
|
||||
}
|
||||
@@ -236,7 +241,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
@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())) {
|
||||
for (var view : List.copyOf(targets.values())) {
|
||||
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
|
||||
}
|
||||
}
|
||||
@@ -269,15 +274,24 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
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);
|
||||
candidate.slot(), source.getAmount(), destination, false, dead.sheetGeneration());
|
||||
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
|
||||
if (failure != null) {view.fail("reservation_save_failed"); report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
|
||||
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|
||||
|| !unchanged(view.target, candidate)) {
|
||||
|| !unchanged(view.target, candidate)
|
||||
|| !games.dmg.fantasystick.items.TailRevivalDelivery.destinationAvailable(view.target, reservation)) {
|
||||
if (reserved.isPresent()) reservations.cancelReserved(reservation)
|
||||
.exceptionally(error -> { report(error); return false; });
|
||||
view.resolve("stale","tail_reservation");view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
deliver(view, dead, reservation);
|
||||
reservations.beginDelivery(reservation).whenComplete((begun, beginFailure) -> schedule(() -> {
|
||||
if (beginFailure != null || begun.isEmpty()) {
|
||||
if (beginFailure != null) report(beginFailure);
|
||||
view.fail("delivery_fence_unavailable"); view.execution.finish(); return;
|
||||
}
|
||||
deliver(view, candidate, dead, begun.orElseThrow());
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -290,58 +304,45 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
private boolean current(View view) {
|
||||
return !closed && !view.finished && view.execution.stillCurrent()
|
||||
&& TailRevivalDelivery.currentRecipient(view.target, players);
|
||||
}
|
||||
|
||||
private void uncertain(View view) {
|
||||
view.fail("tail_custody_uncertain");
|
||||
view.target.sendMessage("Revive could not acknowledge delivery safely; any pending custody proof is retained.");
|
||||
view.execution.finish();
|
||||
}
|
||||
|
||||
private void deliver(View view, Candidate candidate, 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.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt(), dead.sheetGeneration());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
else {
|
||||
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
|
||||
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
|
||||
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
|
||||
if (!TailRevivalDelivery.deliver(view.target, candidate.captured(), dead, reservation, players, () -> current(view))) {
|
||||
uncertain(view); return;
|
||||
}
|
||||
view.target.updateInventory();
|
||||
} catch (RuntimeException uncertain) {
|
||||
view.fail("native_tail_delivery_uncertain");report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
|
||||
view.execution.finish(); return;
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
} catch (RuntimeException failure) { report(failure); uncertain(view); return; }
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit.read(), reservation.sheetGeneration())
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null || restored.isEmpty()) {
|
||||
view.fail(failure!=null?"character_restore_failed":"character_restore_unavailable");if (failure != null) report(failure);
|
||||
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
|
||||
view.execution.finish(); return;
|
||||
if (failure != null) report(failure);
|
||||
uncertain(view); return;
|
||||
}
|
||||
if (installFinalSheet(view.target, restored.orElseThrow())) {
|
||||
view.revived(reservation,dead,restored.orElseThrow());reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
|
||||
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
|
||||
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
|
||||
} else {view.fail("restored_but_sheet_custody_ambiguous");view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");}
|
||||
view.execution.finish();
|
||||
TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, view.target, reservation, players,
|
||||
() -> current(view), record -> {
|
||||
var carrier = reviveEvents == null ? null : reviveEvents.snapshot(view.target);
|
||||
return () -> {
|
||||
view.revived(reservation, dead, record, carrier);
|
||||
if (current(view)) {
|
||||
view.target.sendMessage("Restored character sheet: " + record.name() + ".");
|
||||
view.execution.input().player().sendMessage("Revive restored " + record.name() + ".");
|
||||
}
|
||||
view.execution.finish();
|
||||
};
|
||||
}, () -> uncertain(view));
|
||||
}));
|
||||
}
|
||||
|
||||
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) -> {
|
||||
@@ -355,36 +356,30 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
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)) {
|
||||
recoveryEvent(reservation,record,target);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())) {
|
||||
recoveryEvent(reservation,restored.orElseThrow(),target);reservations.clear(reservation.character(), reservation.id());
|
||||
}
|
||||
}));
|
||||
if (!TailRevivalDelivery.currentRecipient(target, players) || !TailRevivalDelivery.matchesRecord(record, reservation)) return;
|
||||
if (reservation.phase() == RevivalStore.DeliveryPhase.RESERVED) {
|
||||
if (record.dead() && TailRevivalDelivery.provablyUnperformed(target, reservation))
|
||||
reservations.cancelReserved(reservation).exceptionally(error -> { report(error); return false; });
|
||||
return;
|
||||
}
|
||||
if (reservation.phase() != RevivalStore.DeliveryPhase.DELIVERY_STARTED
|
||||
|| TailRevivalDelivery.uniqueProofSlot(target, reservation) < 0
|
||||
|| !TailRevivalDelivery.consumedTail(target, reservation)) return;
|
||||
if (!record.dead()) { completeRecovery(target, reservation); return; }
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
(reservation.administrative()
|
||||
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit, reservation.sheetGeneration())
|
||||
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit, reservation.sheetGeneration()))
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent()) completeRecovery(target, reservation);
|
||||
}));
|
||||
}
|
||||
|
||||
private void completeRecovery(Player target, RevivalStore.Reservation reservation) {
|
||||
TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, target, reservation, players,
|
||||
() -> !closed && TailRevivalDelivery.currentRecipient(target, players),
|
||||
record -> recoveryAcknowledgment(reservation, record, target), () -> {});
|
||||
}
|
||||
|
||||
private void schedule(Runnable action) {
|
||||
@@ -394,7 +389,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var view : List.copyOf(inventories.values())) view.execution.finish();
|
||||
for (var view : List.copyOf(targets.values())) view.execution.finish();
|
||||
inventories.clear(); targets.clear();
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ public final class SecondWind implements AbilityEffect {
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
@@ -98,44 +99,126 @@ public final class SecondWind implements AbilityEffect {
|
||||
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
if (!execution.stillCurrent() || !execution.character().revision().equals(expected)
|
||||
|| !nativeUnchanged(player, current, maximum)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "state_changed_during_healing_event"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
// The pinned native pool stores floats. Round down, so publication and the durable
|
||||
// target agree without increasing the event's accepted healing budget by a float ULP.
|
||||
float nativeTarget = (float) Math.min(maximum, current + accepted);
|
||||
if (!Float.isFinite(nativeTarget)) nativeTarget = Float.MAX_VALUE;
|
||||
if (nativeTarget > current + accepted || (double) nativeTarget - current > accepted)
|
||||
nativeTarget = Math.nextDown(nativeTarget);
|
||||
if (nativeTarget <= current) {
|
||||
execution.resolve("cancelled", Map.of("reason", "no_positive_representable_heal"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
BigDecimal target = BigDecimal.valueOf((double) nativeTarget);
|
||||
var callback = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.prepareSecondWindHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!callback.compareAndSet(false, true)) return;
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.resolve("failed", Map.of("reason", failure != null ? "health_save_failed" : "health_save_unconfirmed"));
|
||||
if (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)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
var pending = saved.orElseThrow(); var persisted = pending.saved();
|
||||
if (!execution.stillCurrent() || !acknowledge(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed_pending_reconciliation"));
|
||||
return; // No current native authority: retain durable proof, never replay the target.
|
||||
}
|
||||
if (!execution.stillCurrent() || !nativeUnchanged(player, current, maximum)) {
|
||||
reconcile(execution, pending, "interrupted", "native_state_changed_before_healing"); return;
|
||||
}
|
||||
double publish = Math.min(maximum, persisted.health().doubleValue());
|
||||
if (!Double.isFinite(publish) || publish <= current || publish - current > accepted) {
|
||||
reconcile(execution, pending, "cancelled", "no_bounded_native_heal_available"); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
reconcile(execution, pending, "failed", "native_health_publication_failed"); return;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
double restored = nativeAfter - current;
|
||||
if (!execution.stillCurrent() || Double.compare(nativeAfter, publish) != 0) {
|
||||
reconcile(execution, pending, "interrupted", "native_publication_unconfirmed"); return;
|
||||
}
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, current, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
retire(pending, persisted, execution.input().session());
|
||||
execution.report(AbilityReport.Type.HEALING, player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
} catch (RuntimeException rejected) {
|
||||
execution.resolve("failed", Map.of("reason", "scheduler_rejected_pending_reconciliation"));
|
||||
execution.finish(); // The durable load barrier remains in place.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean nativeUnchanged(org.bukkit.entity.Player player, double health, double maximum) {
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
return attribute != null && Double.compare(player.getHealth(), health) == 0
|
||||
&& Double.compare(attribute.getValue(), maximum) == 0;
|
||||
}
|
||||
|
||||
private boolean acknowledge(EquipmentSessions.Active active, BigInteger expected,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord saved) {
|
||||
// A pre-existing identical saved target does not advance a revision. It still needs native validation.
|
||||
return sessions.currentRecord(active).filter(saved::equals).isPresent()
|
||||
|| sessions.acknowledgeProgress(active, expected, saved);
|
||||
}
|
||||
|
||||
private void reconcile(AbilityExecution execution, CharacterStorage.PendingHealth pending,
|
||||
String outcome, String reason) {
|
||||
execution.resolve(outcome, Map.of("reason", reason));
|
||||
if (!execution.stillCurrent()) return;
|
||||
var active = execution.input().session(); var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
double observed = player.getHealth();
|
||||
if (attribute == null || !Double.isFinite(observed) || observed < 0
|
||||
|| !Double.isFinite(attribute.getValue()) || attribute.getValue() <= 0) return;
|
||||
double maximum = attribute.getValue();
|
||||
BigDecimal limit = nativeLimit.get().min(BigDecimal.valueOf(maximum));
|
||||
player.sendMessage("Second Wind was interrupted; no healing was confirmed. Saved health is being reconciled.");
|
||||
var callback = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.reconcileSecondWindHealth(pending, BigDecimal.valueOf(observed), limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!callback.compareAndSet(false, true)) return;
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
reconciliationNotice(active); return;
|
||||
}
|
||||
var settled = saved.orElseThrow();
|
||||
if (!sessions.current(active) || !nativeUnchanged(player, observed, maximum)
|
||||
|| !acknowledge(active, pending.saved().revision(), settled)) {
|
||||
reconciliationNotice(active); return;
|
||||
}
|
||||
retire(pending, settled, active);
|
||||
});
|
||||
} catch (RuntimeException rejected) { /* Retain the load barrier; no native replay. */ }
|
||||
});
|
||||
}
|
||||
|
||||
private void retire(CharacterStorage.PendingHealth pending,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord settled, EquipmentSessions.Active active) {
|
||||
characters.completeSecondWindHealth(pending, settled).whenComplete((completed, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(completed)) {
|
||||
try { nextTick.accept(() -> reconciliationNotice(active)); }
|
||||
catch (RuntimeException rejected) { /* Unresolved proof still blocks later entry. */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void reconciliationNotice(EquipmentSessions.Active active) {
|
||||
sessions.currentPlayer(active).ifPresent(player -> player.sendMessage(
|
||||
"Second Wind health reconciliation is unconfirmed; later character entry remains blocked pending recovery."));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge.Outcome;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
@@ -11,56 +18,181 @@ import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
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.PlayerTeleportEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Shared asynchronous, revalidated administrator teleport for durable adventure locations. */
|
||||
public final class AdventureAdminTeleport {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
public final class AdventureAdminTeleport implements Listener {
|
||||
private static final String PERMISSION = "fantasystick.admin";
|
||||
private final Plugin plugin;
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AdventureLocationPresentation locations;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
private final Map<UUID, Move> nativeMoves = new ConcurrentHashMap<>();
|
||||
private final Map<PlayerTeleportEvent, Move> observed = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private final ThreadLocal<Move> dispatching = new ThreadLocal<>();
|
||||
|
||||
public AdventureAdminTeleport(Plugin plugin,Consumer<Runnable> nextTick){
|
||||
this.server=Objects.requireNonNull(plugin).getServer();
|
||||
this.nextTick=Objects.requireNonNull(nextTick);
|
||||
this.locations=new AdventureLocationPresentation(server);
|
||||
public AdventureAdminTeleport(Plugin plugin, Consumer<Runnable> nextTick) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
server = plugin.getServer();
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
locations = new AdventureLocationPresentation(server);
|
||||
}
|
||||
|
||||
public record Target(UUID id,String name,String type,UUID world,double x,double y,double z,float yaw,float pitch,boolean preserveFacing){
|
||||
public Target{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(type);Objects.requireNonNull(world);}
|
||||
public void attachAdminAudit(AdminActionEventBridge bridge) {
|
||||
if (adminAudit == null) server.getPluginManager().registerEvents(this, plugin);
|
||||
adminAudit = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current){
|
||||
Objects.requireNonNull(sender);Objects.requireNonNull(route);Objects.requireNonNull(expected);Objects.requireNonNull(current);
|
||||
if(!(sender instanceof Player player)){sender.sendMessage(route+" teleport requires a player.");return;}
|
||||
World world=server.getWorld(expected.world());
|
||||
if(world==null){sender.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
public record Target(UUID id, String name, String type, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch, boolean preserveFacing) {
|
||||
public Target { Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(type); Objects.requireNonNull(world); }
|
||||
}
|
||||
|
||||
/** Live native identity stays on the native thread; future callbacks use only copied maps. */
|
||||
private static final class Move {
|
||||
final Player player;
|
||||
final Map<String, Object> from, requested;
|
||||
volatile Map<String, Object> completed = Map.of();
|
||||
volatile boolean cancelled;
|
||||
Move(Player player, Map<String, Object> from, Map<String, Object> requested) {
|
||||
this.player = player; this.from = from; this.requested = requested;
|
||||
}
|
||||
}
|
||||
|
||||
/** Match only this helper's exact player/from/destination native operation, not arbitrary plugin teleports. */
|
||||
public boolean owns(PlayerTeleportEvent event) {
|
||||
try {
|
||||
if (observed.containsKey(event)) return true;
|
||||
if (event.getCause() != PlayerTeleportEvent.TeleportCause.PLUGIN || event.getTo() == null) return false;
|
||||
var candidates = nativeMoves.values().stream().filter(move -> move.player == event.getPlayer()
|
||||
&& samePosition(move.from, event.getFrom()) && samePosition(move.requested, event.getTo())).toList();
|
||||
var direct = dispatching.get();
|
||||
var match = direct != null && candidates.contains(direct) ? direct : candidates.size() == 1 ? candidates.getFirst() : null;
|
||||
if (match == null) return false; // No invented authority for an ambiguous native observation.
|
||||
observed.put(event, match);
|
||||
return true;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observe(PlayerTeleportEvent event) { owns(event); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void resolved(PlayerTeleportEvent event) {
|
||||
var move = observed.remove(event);
|
||||
if (move == null) return;
|
||||
move.cancelled = event.isCancelled();
|
||||
move.completed = snapshot(event.getTo(), "native_event");
|
||||
}
|
||||
|
||||
private static boolean samePosition(Map<String, Object> expected, Location actual) {
|
||||
if (actual == null || actual.getWorld() == null || expected.isEmpty()) return false;
|
||||
return Objects.equals(expected.get("world_id"), actual.getWorld().getUID().toString())
|
||||
&& Objects.equals(expected.get("x"), actual.getX()) && Objects.equals(expected.get("y"), actual.getY())
|
||||
&& Objects.equals(expected.get("z"), actual.getZ());
|
||||
}
|
||||
|
||||
private static Map<String, Object> snapshot(Location location, String quality) {
|
||||
try {
|
||||
var data = new LinkedHashMap<>(AdminActionEventBridge.teleportLocation(location));
|
||||
data.put("quality", quality);
|
||||
return Map.copyOf(data);
|
||||
} catch (RuntimeException unavailable) { return Map.of(); }
|
||||
}
|
||||
|
||||
private static void mark(AdminActionEventBridge audit, AdminActionEventBridge.Action action,
|
||||
String stage, String reason, String nativeOutcome, Map<String, Object> place) {
|
||||
if (audit == null) return;
|
||||
try { audit.teleportObservation(action, stage, reason, nativeOutcome, place); }
|
||||
catch (RuntimeException ignored) { /* Optional audit failure cannot stop a native operation. */ }
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender, String route, Target expected, Supplier<Optional<Target>> current) {
|
||||
teleport(sender, route, expected, current, status -> {});
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender, String route, Target expected, Supplier<Optional<Target>> current,
|
||||
Consumer<Outcome> resolved) {
|
||||
Objects.requireNonNull(resolved); Objects.requireNonNull(sender); Objects.requireNonNull(route);
|
||||
Objects.requireNonNull(expected); Objects.requireNonNull(current);
|
||||
var audit = adminAudit;
|
||||
var action = audit == null ? null : audit.current();
|
||||
if (!(sender instanceof Player player)) {
|
||||
mark(audit, action, "validation", "sender_unsupported", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); sender.sendMessage(route + " teleport requires a player."); return;
|
||||
}
|
||||
if (audit != null) try { audit.teleportTarget(action, expected); } catch (RuntimeException ignored) { }
|
||||
mark(audit, action, "world_lookup", "world_unavailable", "not_requested", Map.of());
|
||||
World world = server.getWorld(expected.world());
|
||||
if (world == null) {
|
||||
resolved.accept(Outcome.REFUSED); sender.sendMessage(route + " teleport refused: destination world is unavailable."); return;
|
||||
}
|
||||
CompletableFuture<?> loading;
|
||||
try{loading=world.getChunkAtAsync(section(expected.x()),section(expected.z()));}
|
||||
catch(RuntimeException failure){sender.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
loading.whenComplete((chunk,loadFailure)->schedule(()->{
|
||||
if(loadFailure!=null){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(!currentPlayer(player)){player.sendMessage(route+" teleport refused: permission or player session changed.");return;}
|
||||
var fresh=current.get();
|
||||
if(fresh.isEmpty()||!fresh.orElseThrow().equals(expected)){
|
||||
player.sendMessage(route+" teleport refused: destination changed or is no longer registered.");return;
|
||||
mark(audit, action, "chunk_request", "chunk_load_failure", "not_requested", Map.of());
|
||||
try { loading = world.getChunkAtAsync(section(expected.x()), section(expected.z())); }
|
||||
catch (RuntimeException failure) {
|
||||
resolved.accept(Outcome.FAILED); sender.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
}
|
||||
loading.whenComplete((chunk, loadFailure) -> schedule(() -> {
|
||||
if (loadFailure != null) {
|
||||
mark(audit, action, "chunk_loading", "chunk_load_failure", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.FAILED); player.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
}
|
||||
World currentWorld=server.getWorld(expected.world());
|
||||
if(currentWorld==null||currentWorld!=world){player.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
var facing=player.getLocation();
|
||||
var destination=new Location(world,expected.x(),expected.y(),expected.z(),
|
||||
expected.preserveFacing()?facing.getYaw():expected.yaw(),expected.preserveFacing()?facing.getPitch():expected.pitch());
|
||||
if (!currentPlayer(player)) {
|
||||
mark(audit, action, "revalidation", "permission_or_session_changed", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: permission or player session changed."); return;
|
||||
}
|
||||
var fresh = current.get();
|
||||
if (fresh.isEmpty() || !fresh.orElseThrow().equals(expected)) {
|
||||
mark(audit, action, "revalidation", "registration_changed", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: destination changed or is no longer registered."); return;
|
||||
}
|
||||
World currentWorld = server.getWorld(expected.world());
|
||||
if (currentWorld == null || currentWorld != world) {
|
||||
mark(audit, action, "revalidation", "world_unavailable", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: destination world is unavailable."); return;
|
||||
}
|
||||
var facing = player.getLocation();
|
||||
var destination = new Location(world, expected.x(), expected.y(), expected.z(),
|
||||
expected.preserveFacing() ? facing.getYaw() : expected.yaw(), expected.preserveFacing() ? facing.getPitch() : expected.pitch());
|
||||
var move = new Move(player, snapshot(facing, "native_request"), snapshot(destination, "requested"));
|
||||
mark(audit, action, "native_request", "native_failure", "pending", move.requested);
|
||||
CompletableFuture<Boolean> moving;
|
||||
try{moving=player.teleportAsync(destination);}
|
||||
catch(RuntimeException failure){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
moving.whenComplete((moved,moveFailure)->schedule(()->{
|
||||
if(moveFailure!=null||!Boolean.TRUE.equals(moved)){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(player.isOnline())player.sendMessage("Teleported to "+expected.name()+" ("+expected.type()+") at "+locations.format(expected.world(),expected.x(),expected.y(),expected.z())+".");
|
||||
}));
|
||||
if (action != null) nativeMoves.put(action.id(), move);
|
||||
dispatching.set(move);
|
||||
try { moving = player.teleportAsync(destination); }
|
||||
catch (RuntimeException failure) {
|
||||
if (action != null) nativeMoves.remove(action.id(), move);
|
||||
mark(audit, action, "native_request", "native_failure", "failed", move.requested);
|
||||
resolved.accept(Outcome.FAILED); player.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
} finally { dispatching.remove(); }
|
||||
moving.whenComplete((moved, moveFailure) -> {
|
||||
if (action != null) nativeMoves.remove(action.id(), move);
|
||||
boolean completed = moveFailure == null && Boolean.TRUE.equals(moved);
|
||||
String reason = completed ? "native_completed" : moveFailure != null ? "native_failure" : move.cancelled ? "native_cancelled" : "native_refused";
|
||||
mark(audit, action, "native_completion", reason, completed ? "completed" : moveFailure != null ? "failed" : move.cancelled ? "cancelled" : "refused",
|
||||
move.completed.isEmpty() ? move.requested : move.completed);
|
||||
// Future completion is authoritative; no native getters, feedback or scheduler acknowledgment needed.
|
||||
resolved.accept(completed ? Outcome.COMPLETED : Outcome.FAILED);
|
||||
schedule(() -> {
|
||||
if (!completed) { player.sendMessage(route + " teleport failed; your location is unchanged."); return; }
|
||||
if (player.isOnline()) player.sendMessage("Teleported to " + expected.name() + " (" + expected.type() + ") at "
|
||||
+ locations.format(expected.world(), expected.x(), expected.y(), expected.z()) + ".");
|
||||
});
|
||||
});
|
||||
}, () -> {
|
||||
mark(audit, action, "main_handoff", "scheduler_unavailable", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.FAILED);
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean currentPlayer(Player player){return player.isOnline()&&player.hasPermission(PERMISSION)&&server.getPlayer(player.getUniqueId())==player;}
|
||||
private void schedule(Runnable task){try{nextTick.accept(task);}catch(RuntimeException ignored){}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private boolean currentPlayer(Player player) { return player.isOnline() && player.hasPermission(PERMISSION) && server.getPlayer(player.getUniqueId()) == player; }
|
||||
private void schedule(Runnable task) { schedule(task, () -> {}); }
|
||||
private void schedule(Runnable task, Runnable rejected) { try { nextTick.accept(task); } catch (RuntimeException unavailable) { rejected.run(); } }
|
||||
private static int section(double coordinate) { return ((int) Math.floor(coordinate)) >> 4; }
|
||||
}
|
||||
@@ -12,18 +12,101 @@ public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
// Warning requests are only linked to an explicit confirmation by the same live sender.
|
||||
private final Map<CommandSender,games.dmg.fantasystick.events.AdminActionEventBridge.Action> rerollWarnings=new WeakHashMap<>();
|
||||
public boolean ownsTeleport(org.bukkit.event.player.PlayerTeleportEvent event){return teleports.owns(event);}
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);teleports.attachAdminAudit(bridge);}
|
||||
private void completedRead(games.dmg.fantasystick.events.AdminActionEventBridge.Action action){if(adminAudit!=null)adminAudit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"read_completed",Map.of());}
|
||||
private void outcome(games.dmg.fantasystick.events.AdminActionEventBridge.Action action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome status, String reason, UUID operation, UUID subject){
|
||||
if(adminAudit==null)return;
|
||||
var facts=new LinkedHashMap<String,Object>();
|
||||
if(operation!=null)facts.put("domain_operation_id",operation);
|
||||
if(subject!=null)facts.put("subject_id",subject);
|
||||
adminAudit.result(action,status,reason,facts);
|
||||
}
|
||||
private void refused(){outcome(adminAudit==null?null:adminAudit.current(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);}
|
||||
private void invalid(){outcome(adminAudit==null?null:adminAudit.current(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"invalid_arguments",null,null);}
|
||||
private void saved(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,Throwable failure,boolean applied,UUID operation,UUID subject){
|
||||
outcome(action,failure!=null?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED:applied?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,failure!=null?"operation_failed":applied?"acknowledged":"owner_refused",applied?operation:null,applied?subject:null);
|
||||
}
|
||||
private games.dmg.fantasystick.events.FishingEventBridge fishingEvents;
|
||||
public void attachFishingEvents(games.dmg.fantasystick.events.FishingEventBridge value){fishingEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.CampEventBridge campEvents;
|
||||
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
|
||||
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();var campAudit=campEvents;var actor=campAudit==null?null:campAudit.actor(sender,null);directory.rerollToday(expected,actor).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null){if(campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));if(championEvents!=null)championEvents.siteRegistered(operation,campActor,value,controller.arena(value));}}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged."));});}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){sender.sendMessage("Adventure site not found.");return;}if(args.length==3){sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED){if(campAudit!=null)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));if(championEvents!=null)championEvents.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}}catch(RuntimeException ignored){}if(audit!=null&¤t.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",UUID.randomUUID(),auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure site mode save failed; the acknowledged mode is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated adventure site "+id+" mode="+result.site().orElseThrow().participationMode().value()+" for future acceptances.";case UNCHANGED->"Adventure site "+id+" mode is already "+requested.value()+".";case STALE->"Adventure site mode update refused: the acknowledged mode changed; inspect and retry.";case NOT_FOUND->"Adventure site not found.";});});});}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeSite(id).whenComplete((removed,failure)->{try{if(failure==null&&removed.isPresent()){if(campAudit!=null)camLine truncated
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var observed=controller.observedGiver(value);boolean nativeReturned=false;try{var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.placed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);UUID operation=UUID.randomUUID();directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{var value=removed.orElseThrow();var observed=controller.observedGiver(value);boolean nativeReturned=false;try{controller.removeGiverLoaded(id);nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.removed",operation,campActor,value,observed,nativeReturned);}sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public void adventure(CommandSender sender,String[] args){
|
||||
if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fstick adventure");return;}
|
||||
if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}
|
||||
if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}
|
||||
if(args.length==2&&args[1].equals("reroll")){
|
||||
if(!authorized(sender))return;
|
||||
if(adminAudit!=null){
|
||||
var warning=adminAudit.current();
|
||||
if(warning!=null){
|
||||
if(rerollWarnings.size()>=256)rerollWarnings.clear();
|
||||
rerollWarnings.put(sender,warning);
|
||||
}
|
||||
}
|
||||
sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fstickadmin adventure reroll confirm to reset the day.");
|
||||
return;
|
||||
}
|
||||
if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){
|
||||
sender.sendMessage("Usage: /fstick adventure [tracking [on|off]] or /fstickadmin adventure reroll [confirm]");
|
||||
return;
|
||||
}
|
||||
var audit=adminAudit;
|
||||
var confirmation=audit==null?null:audit.current();
|
||||
var warning=rerollWarnings.remove(sender);
|
||||
boolean permitted=sender.hasPermission(PERMISSION);
|
||||
if(audit!=null)audit.confirmation(confirmation,permitted,"command_confirmation",warning==null?null:warning.id());
|
||||
if(!authorized(sender)){
|
||||
if(audit!=null)audit.result(warning==null?confirmation:warning,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"permission_changed",Map.of());
|
||||
return;
|
||||
}
|
||||
var action=warning==null?confirmation:warning;
|
||||
var current=directory.snapshot().catalog();
|
||||
UUID expected=current==null?null:current.revision();
|
||||
var campAudit=campEvents;
|
||||
var actor=campAudit==null?null:campAudit.actor(sender,null);
|
||||
directory.rerollToday(expected,actor).whenComplete((result,failure)->{
|
||||
if(failure!=null){
|
||||
if(audit!=null)audit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,"operation_failed",Map.of());
|
||||
respond(sender,()->sender.sendMessage("Adventure reset failed; the current generation is unchanged."));
|
||||
return;
|
||||
}
|
||||
if(result.status()==AdventureQuestDirectory.RerollStatus.STALE){
|
||||
if(audit!=null)audit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"stale",Map.of());
|
||||
respond(sender,()->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again."));
|
||||
return;
|
||||
}
|
||||
var catalog=result.catalog().orElseThrow();
|
||||
// Capture the installed reset operation before native publication clears the pending proof.
|
||||
var reset=directory.snapshot().reset();
|
||||
UUID operation=reset!=null&&reset.generation().equals(catalog.revision())?reset.operation():null;
|
||||
respond(sender,()->controller.publishPendingReset().whenComplete((published,publishFailure)->{
|
||||
if(audit!=null){
|
||||
var status=publishFailure==null&&Boolean.TRUE.equals(published)&&operation!=null
|
||||
?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED
|
||||
:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN;
|
||||
audit.result(action,status,status==games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED
|
||||
?"acknowledged":"operation_uncertain",
|
||||
operation==null?Map.of():Map.of("domain_operation_id",operation));
|
||||
}
|
||||
respond(sender,()->{
|
||||
if(publishFailure!=null||!Boolean.TRUE.equals(published)){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}
|
||||
sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){invalid();usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){invalid();usageSite(sender);return;}var action=adminAudit==null?null:adminAudit.current();completedRead(action);if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()+"; mode="+v.participationMode().value()+siteDetails(v)));}case"place"->{if(args.length<3||!(sender instanceof Player player)){if(args.length<3)invalid();else refused();usageSite(sender);return;}if(args[2].equals("fishing")){placeFishing(sender,player,args);return;}if(!(args[2].equals("camp")||args[2].equals("champion"))){invalid();usageSite(sender);return;}int start=3;var mode=AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args.length>3&&args[3].equals("--mode")){if(args.length<5){invalid();usageSite(sender);return;}try{mode=AdventureQuestState.ParticipationMode.parse(args[4]);}catch(IllegalArgumentException invalid){invalid();usageSite(sender);return;}start=5;}var placement=placement(args,start);if(placement==null){invalid();usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;var action=adminAudit==null?null:adminAudit.current();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena(),mode).whenComplete((value,failure)->{try{if(failure==null){if(campAudit!=null)campAudit.siteRegistered(operation,campActor,value,controller.arena(value));if(championEvents!=null)championEvents.siteRegistered(operation,campActor,value,controller.arena(value));}}catch(RuntimeException ignored){}saved(action,failure,failure==null,operation,failure==null?value.id():null);respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+"; mode="+value.participationMode().value()+".":"Adventure site save failed; desired state is unchanged."));});}case"fishing"->{fishing(sender,args);}case"mode"->{if(args.length<3||args.length>4){invalid();usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:site(id).orElse(null);if(current==null){refused();sender.sendMessage("Adventure site not found.");return;}if(args.length==3){completedRead(adminAudit==null?null:adminAudit.current());sender.sendMessage("Adventure site "+id+" mode="+current.participationMode().value()+".");return;}AdventureQuestState.ParticipationMode requested;try{requested=AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){invalid();usageSite(sender);return;}var audit=fishingEvents;var auditActor=audit==null?null:audit.actor(sender);var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);var action=adminAudit==null?null:adminAudit.current();UUID operation=UUID.randomUUID();directory.setSiteMode(id,current.participationMode(),requested).whenComplete((result,failure)->{try{if(failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED){if(campAudit!=null)campAudit.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));if(championEvents!=null)championEvents.siteModeChanged(operation,campActor,current,result.site().orElseThrow(),controller.arena(result.site().orElseThrow()));}}catch(RuntimeException ignored){}if(audit!=null&¤t.type()==AdventureQuestState.SiteType.FISHING&&failure==null&&result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)audit.site("adventure.site.mode_changed",operation,auditActor,result.site().orElseThrow(),Map.of("mode_before",current.participationMode().value(),"mode_after",requested.value()));if(failure!=null)saved(action,failure,false,null,null);else if(result.status()==AdventureQuestDirectory.ModeStatus.APPLIED)saved(action,null,true,operation,id);else outcome(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,result.status()==AdventureQuestDirectory.ModeStatus.STALE?"stale":result.status()==AdventureQuestDirectory.ModeStatus.UNCHANGED?"no_change":"not_found",null,null);respond(sender,()->{if(failure!=null){sender.sendMessage("AdvLine truncated
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){invalid();usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){invalid();usageGiver(sender);return;}var action=adminAudit==null?null:adminAudit.current();completedRead(action);if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){refused();usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();var action=adminAudit==null?null:adminAudit.current();var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,location);UUID operation=UUID.randomUUID();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){saved(action,failure,false,null,null);sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var observed=controller.observedGiver(value);boolean nativeReturned=false;try{var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.placed",operation,campActor,value,observed,nativeReturned);}saved(action,null,true,operation,value.id());sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){invalid();usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){invalid();usageGiver(sender);return;}var campAudit=campEvents;var campActor=campAudit==null?null:campAudit.actor(sender,null);var action=adminAudit==null?null:adminAudit.current();UUID operation=UUID.randomUUID();directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null){saved(action,failure,false,null,null);sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");}else if(removed.isEmpty()){outcome(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"not_found",null,null);sender.sendMessage("Quest Giver not found.");}else{var value=removed.orElseThrow();var observed=controller.observedGiver(value);boolean nativeReturned=false;try{controller.removeGiverLoaded(id);nativeReturned=true;}finally{auditGiver(campAudit,"adventure.quest_giver.removed",operation,campActor,value,observed,nativeReturned);}saved(action,null,true,operation,id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->{invalid();usageGiver(sender);}}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("fishing","list","mode","place","remove","teleport"):List.of("fishing","list","mode","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion","fishing"),args[2]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[2].equals("fishing"))return fishingOptionCompletion(args);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion"))){var values=new ArrayList<>(List.of("--mode","4","16","64"));return matching(values,args[3]);}if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(modeValues(),args[4]);if(args.length==6&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode"))return matching(List.of("4","16","64"),args[5]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==7&&args[0].equals("adventure-site")&&args[1].equals("place")&&args[3].equals("--mode")&&whole(args[5])!=null)return matching(List.of("8","16","128"),args[6]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("fishing","mode","remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("mode"))return matching(modeValues(),args[3]);if(args.length>=4&&args[0].equals("adventure-site")&&args[1].equals("fishing"))return fishingOptionCompletion(args);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){sender.sendMessage("Fishing site not found.");return;}if(args.length==3){sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",id,actor,result.site().orElseThrow(),changes);}respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){usageSite(sender);}}
|
||||
private void placeFishing(CommandSender sender,Player player,String[] args){var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||!options.keySet().containsAll(Set.of("name","item","required","chance","xp","coins"))){usageSite(sender);return;}try{int radius=options.containsKey("radius")?bounded(options.get("radius"),4,64):directory.defaultRadius();var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;var location=player.getLocation();var action=adminAudit==null?null:adminAudit.current();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);directory.placeFishingSite(options.get("name"),options.get("item"),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),radius,percent(options.get("chance")),positive(options.get("required")),nonnegative(options.get("xp")),nonnegative(options.get("coins")),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)audit.site("adventure.site.registered",value.id(),actor,value,Map.of("definition","created"));saved(action,failure,failure==null,failure==null?value.id():null,failure==null?value.id():null);respond(sender,()->sender.sendMessage(failure==null?"Placed fishing site "+value.id()+" ("+value.displayName()+"); "+fishingDetails(value)+".":"Fishing site save failed; desired state is unchanged."));});}catch(IllegalArgumentException invalid){invalid();usageSite(sender);}}
|
||||
private void fishing(CommandSender sender,String[] args){if(args.length<3){usageSite(sender);return;}UUID id=parse(args[2]);var current=id==null?null:directory.findSite(id).filter(value->value.type()==AdventureQuestState.SiteType.FISHING).orElse(null);if(current==null){refused();sender.sendMessage("Fishing site not found.");return;}if(args.length==3){completedRead(adminAudit==null?null:adminAudit.current());sender.sendMessage(current.id()+" "+current.displayName()+"; "+fishingDetails(current)+".");return;}var options=options(args,3,Set.of("name","item","required","chance","xp","coins","radius","mode"));if(options==null||options.isEmpty()){usageSite(sender);return;}try{var before=current.fishing();var definition=new AdventureQuestState.FishingDefinition(before.itemId(),options.getOrDefault("item",before.itemName()),options.containsKey("required")?positive(options.get("required")):before.required(),options.containsKey("chance")?percent(options.get("chance")):before.chancePercent(),options.containsKey("xp")?nonnegative(options.get("xp")):before.rewardXp(),options.containsKey("coins")?nonnegative(options.get("coins")):before.rewardCoins(),options.containsKey("radius")?bounded(options.get("radius"),4,64):before.radius());var mode=options.containsKey("mode")?AdventureQuestState.ParticipationMode.parse(options.get("mode")):current.participationMode();var action=adminAudit==null?null:adminAudit.current();var audit=fishingEvents;var actor=audit==null?null:audit.actor(sender);UUID operation=UUID.randomUUID();directory.updateFishingSite(id,current,options.getOrDefault("name",current.name()),definition,mode).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED&&audit!=null){var changes=new LinkedHashMap<String,Object>();for(var field:options.keySet())changes.put(field,options.get(field));audit.site("adventure.site.updated",operation,actor,result.site().orElseThrow(),changes);}if(failure!=null)saved(action,failure,false,null,null);else if(result.status()==AdventureQuestDirectory.FishingUpdateStatus.APPLIED)saved(action,null,true,operation,id);else outcome(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,result.status()==AdventureQuestDirectory.FishingUpdateStatus.STALE?"stale":result.status()==AdventureQuestDirectory.FishingUpdateStatus.UNCHANGED?"no_change":"not_found",null,null);respond(sender,()->{if(failure!=null){sender.sendMessage("Fishing site update failed; the acknowledged definition is unchanged.");return;}sender.sendMessage(switch(result.status()){case APPLIED->"Updated fishing site "+id+"; "+fishingDetails(result.site().orElseThrow())+".";case UNCHANGED->"Fishing site "+id+" is unchanged.";case STALE->"Fishing site update refused: the acknowledged definition changed; inspect and retry.";case NOT_FOUND->"Fishing site not found.";});});});}catch(IllegalArgumentException invalid){invalid();usageSite(sender);}}
|
||||
private static String siteDetails(AdventureQuestState.Site site){return site.fishing()==null?"":"; "+fishingDetails(site);}
|
||||
private static String fishingDetails(AdventureQuestState.Site site){var value=site.fishing();return "item=\""+value.itemName()+"\", required="+value.required()+", chance="+value.chancePercent().toPlainString()+"%, reward="+value.rewardXp()+" XP/"+value.rewardCoins()+" coins, radius="+value.radius()+", mode="+site.participationMode().value();}
|
||||
private static Map<String,String> options(String[] args,int start,Set<String> allowed){var result=new LinkedHashMap<String,String>();for(int index=start;index<args.length;index++){String raw=args[index];if(!raw.startsWith("--")||!allowed.contains(raw.substring(2))||result.containsKey(raw.substring(2))||index+1>=args.length)return null;String key=raw.substring(2),value=args[++index];if(value.startsWith("\"")){var joined=new StringBuilder(value);while(!joined.toString().endsWith("\"")&&index+1<args.length)joined.append(' ').append(args[++index]);value=joined.toString();if(value.length()<2||!value.endsWith("\""))return null;value=value.substring(1,value.length()-1);}if(value.isBlank()||value.startsWith("--"))return null;result.put(key,value);}return result;}
|
||||
@@ -34,7 +117,7 @@ public final class AdventureQuestCommands {
|
||||
private static List<String> fishingOptionCompletion(String[] args){String current=args[args.length-1];if(args.length>=2){String previous=args[args.length-2];if(previous.equals("--mode"))return matching(modeValues(),current);if(previous.equals("--radius"))return matching(List.of("4","16","64"),current);if(previous.equals("--chance"))return matching(List.of("0","20","100"),current);if(previous.equals("--required"))return matching(List.of("1","5","2147483647"),current);if(previous.equals("--xp")||previous.equals("--coins"))return matching(List.of("0","10","100","2147483647"),current);}var used=Arrays.stream(args).filter(value->value.startsWith("--")).collect(java.util.stream.Collectors.toSet());return matching(List.of("--chance","--coins","--item","--mode","--name","--radius","--required","--xp").stream().filter(value->!used.contains(value)).toList(),current);}
|
||||
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),switch(site.type()){case CAMP->"monster camp";case CHAMPION->"champion";case FISHING->"fishing site";case DUNGEON->"dungeon entrance";},site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;outcome(adminAudit==null?null:adminAudit.current(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"permission_changed",null,null);sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void auditGiver(games.dmg.fantasystick.events.CampEventBridge bridge,String type,UUID operation,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver,
|
||||
AdventureQuestController.ObservedGiver before,boolean nativeReturned){
|
||||
|
||||
@@ -105,6 +105,14 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
public void attachCampEvents(games.dmg.fantasystick.events.CampEventBridge value){campEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.ChampionEventBridge championEvents;
|
||||
public void attachChampionEvents(games.dmg.fantasystick.events.ChampionEventBridge value){championEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.QuestTrackingEventBridge questTrackingEvents;
|
||||
public void attachQuestTrackingEvents(games.dmg.fantasystick.events.QuestTrackingEventBridge value){questTrackingEvents=Objects.requireNonNull(value);}
|
||||
private games.dmg.fantasystick.events.QuestTrackingEventBridge.Acceptance captureTrackingAcceptance(UUID operation,Player player,Set<UUID> members){
|
||||
var audit=questTrackingEvents;if(audit==null)return null;
|
||||
java.util.Collection<? extends Player> online=List.of();
|
||||
try{online=plugin.getServer().getOnlinePlayers();}catch(RuntimeException unavailable){/* Optional member metadata cannot erase the known initiator. */}
|
||||
try{return audit.acceptance(operation,player,members,online);}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private games.dmg.fantasystick.dungeon.DungeonState.Dungeon captureDungeonDefinition(UUID id){try{return dungeonDefinitions.apply(id).orElse(null);}catch(RuntimeException unavailable){return null;}}
|
||||
private games.dmg.fantasystick.events.DungeonAuditEventBridge dungeonEvents;
|
||||
private java.util.function.Function<UUID,Optional<games.dmg.fantasystick.dungeon.DungeonState.Dungeon>> dungeonDefinitions=ignored->Optional.empty();
|
||||
@@ -118,7 +126,7 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){open(player,requestedPage,null);}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}if(fishing!=null){var turnIn=new ILine truncated
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"legacy_recovery");attemptClaim(player,context.active,pending.orElseThrow(),record,giver,request,fishingEvents==null?null:fishingEvents.actor(player));return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.min(54,Math.max(18,((count+8)/9)*9+9));var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page,giver),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon";case FISHING->"Fishing";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Completion trophy pool: ":"Reward: ";var lore=new ArrayList<net.kyori.adventure.text.Component>();lore.add(net.kyori.adventure.text.Component.text(kind));var fishingTerms=active!=null&&active.site().equals(siteId)&&active.fishing()!=null?active.fishing():site==null||site.fishing()==null?null:site.fishing().terms();if(fishingTerms!=null){lore.add(net.kyori.adventure.text.Component.text("Item: "+fishingTerms.itemName()+" × "+fishingTerms.required()));lore.add(net.kyori.adventure.text.Component.text("Catch chance: "+fishingTerms.chancePercent().toPlainString()+"%"));if(active!=null&&active.site().equals(siteId)&&fishing!=null)lore.add(net.kyori.adventure.text.Component.text("Held: "+fishing.quantity(context.character,fishingTerms.itemId())+" / "+fishingTerms.required()));}lore.add(net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"));if(site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON)lore.add(net.kyori.adventure.text.Component.text("Mob kills: additional variable XP, coins and native loot"));if(site!=null){lore.add(net.kyori.adventure.text.Component.text("Mode: "+site.participationMode().value()));if(active==null&&!rewardsReady&&!directory.characterClaimed(catalog.revision(),siteId,context.character)&&directory.offerOpen(siteId)&&directory.available(site))acceptanceLore(lore,site.participationMode(),context);}lore.add(net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()));lore.add(net.kyori.adventure.text.Component.text(state));meta.lore(lore);meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)Line truncated
|
||||
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
|
||||
private static games.dmg.fantasystick.events.CampEventBridge.Actor championQuestActor(games.dmg.fantasystick.events.CampEventBridge.Actor actor,AdventureQuestState.Giver giver){
|
||||
var facts=new LinkedHashMap<String,Object>(actor==null?Map.of("actor_kind","unavailable"):actor.captured());
|
||||
@@ -130,11 +138,70 @@ public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null)directory.findGiver(id).ifPresent(giver->open(event.getPlayer(),0,giver));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(context.character)&&(type==AdventureQuestState.UnitType.SOLO||current.leader&&Objects.equals(current.partyId,context.partyId)&¤t.members.equals(context.members));if(!same){directory.abandon(quest.id(),quest.unit()).whenComplete((ignored,rollbackFailure)->schedule(()->player.sendMessage(rollbackFailure==null?"Quest acceptance refused: the character or party chLine truncated
|
||||
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page,holder.giver);return;}if(data.has(menuFishingKey,PersistentDataType.INTEGER)&&fishing!=null){var now=directory.snapshot();if(now.reset()!=null||now.catalog()==null||!holder.catalog.equals(now.catalog().revision())){player.sendMessage("Quest item entry refused: the Quest Giver menu expired.");return;}fishing.open(player,holder.giver);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site,holder.giver);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}var audit=campEvents;var championAudit=championEvents;var actor=audit==null?null:audit.actor(player,null);var championActor=championQuestActor(actor,holder.giver);UUID operation=UUID.randomUUID();var capturedSite=directory.findSite(active.site()).orElse(null);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(active.site());directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->{if(failure==null&&changed){if(audit!=null)audit.questTransition("adventure.quest.abandoned",operation,actor,active,capturedSite,null,"player_abandoned");if(championAudit!=null)championAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,null,"player_abandoned");if(dungeonAudit!=null)try{dungeonAudit.questTransition("adventure.quest.abandoned",operation,championActor,active,capturedSite,originalDungeon,"player_abandoned");}catch(RuntimeException ignored){}}schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);});});return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}var offered=directory.findSite(site).orElseThrow();var type=event.isRightClick()?AdventureQuestState.UnitType.PARTY:AdventureQuestState.UnitType.SOLO;if(!offered.participationMode().permits(type)){player.sendMessage("Quest acceptance refused: this site is "+offered.participationMode().value()+".");return;}if(type==AdventureQuestState.UnitType.PARTY&&(context.partyId==null||!context.leader)){player.sendMessage("Only the active party leader may accept a party quest.");return;}var unit=type==AdventureQuestState.UnitType.SOLO?context.character:context.partyId;var members=type==AdventureQuestState.UnitType.SOLO?Set.of(context.character):context.members;var campAudit=campEvents;var championAudit=championEvents;var campActor=campAudit==null?null:campAudit.actor(player,null);var championActor=championQuestActor(campActor,holder.giver);var dungeonAudit=dungeonEvents;var originalDungeon=captureDungeonDefinition(site);UUID operation=UUID.randomUUID();var trackingAudit=questTrackingEvents;var trackingAcceptance=captureTrackingAcceptance(operation,player,members);directory.accept(catalog,site,type,unit,members,offered.participationMode()).whenComplete((result,failure)->{if(failure==null&&result.status()==AdventureQuestDirectory.Acceptance.APPLIED&&trackingAudit!=null&&trackingAcceptance!=null)try{trackingAudit.accepted(trackingAcceptance,result.quest().orElseThrow());}catch(RuntimeException ignored){}schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var quest=result.quest().orElseThrow();var current=context(player);boolean same=current!=null&¤t.active.equals(context.active)&¤t.character.equals(conteLine truncated
|
||||
private void legacyResult(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){
|
||||
if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason);
|
||||
}
|
||||
private void claim(Player player,Context expected,UUID catalog,UUID site,AdventureQuestState.Giver giver){
|
||||
var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"legacy_claim");
|
||||
var actor=fishingEvents==null?null:fishingEvents.actor(player);
|
||||
directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{
|
||||
if(failure!=null||operation.isEmpty()){
|
||||
legacyResult(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");
|
||||
player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;
|
||||
}
|
||||
var current=context(player);
|
||||
if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){
|
||||
legacyResult(request,"refused","stale_or_foreign_character");
|
||||
player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;
|
||||
}
|
||||
var record=sessions.currentRecord(current.active).orElse(null);
|
||||
if(record==null){legacyResult(request,"refused","character_unavailable");player.sendMessage("Reward claim paused: the current character changed.");return;}
|
||||
attemptClaim(player,current.active,operation.orElseThrow(),record,giver,request,actor);
|
||||
}));
|
||||
}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord record,AdventureQuestState.Giver giver,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor){
|
||||
characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){legacyResult(request,"failed","source_write_failed");schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
attemptClaim(player,active,operation,award.record().orElseThrow(),giver,request,actor);return;
|
||||
}
|
||||
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){
|
||||
legacyResult(request,"refused","character_unavailable");schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;
|
||||
}
|
||||
var saved=award.record().orElseThrow();
|
||||
var sources=directory.snapshot().rewards().allocations().stream().filter(value->operation.allocations().contains(value.id())).toList();
|
||||
directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->{
|
||||
if(finishFailure!=null||!finished){
|
||||
legacyResult(request,"uncertain",finishFailure==null?"claim_already_settled":"source_write_failed");
|
||||
schedule(()->player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver."));return;
|
||||
}
|
||||
if(fishingEvents!=null)fishingEvents.legacyClaimed(operation,actor,giver,record,award,sources,request);
|
||||
schedule(()->{
|
||||
var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);
|
||||
if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);
|
||||
var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);
|
||||
if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
|
||||
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
|
||||
public void tracking(Player player,Boolean enabled){
|
||||
var audit=questTrackingEvents;var initial=audit==null?null:audit.request(player,enabled);
|
||||
var context=context(player);if(context==null){if(audit!=null)audit.refused(initial,"inactive_or_dead_character");player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}
|
||||
var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);
|
||||
if(quest==null){if(audit!=null)audit.refused(initial,"no_eligible_active_quest");player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}
|
||||
var request=audit==null?null:audit.bind(initial,quest,context.character);if(audit!=null)audit.observed(request);
|
||||
if(enabled==null){boolean value=directory.trackingEnabled(quest.id(),context.character);if(audit!=null)audit.read(request,value,directory.snapshot().tracking().stream().anyMatch(entry->entry.quest().equals(quest.id())&&entry.character().equals(context.character)));player.sendMessage("Quest tracking is "+(value?"on":"off")+" for the current adventure quest.");return;}
|
||||
directory.setTrackingWithEvidence(quest.id(),context.character,enabled).whenComplete((publication,failure)->{
|
||||
if(audit!=null)try{audit.changed(request,publication,failure);}catch(RuntimeException ignored){}
|
||||
schedule(()->{var status=publication==null?null:publication.status();if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));});
|
||||
});
|
||||
}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt,"catalog_or_utc_expired");attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt,"no_eligible_accepted_character_remained");attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){var type=directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null);if(type==AdventureQuestState.SiteType.DUNGEON||type==AdventureQuestState.SiteType.FISHING)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;var activation=eligibleInside(quest.site()).findAny();if(activation.isPresent())start(quest.site(),quest.catalog(),activation.orElseThrow());}}
|
||||
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)particles.viewer(player,Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
|
||||
|
||||
@@ -17,6 +17,18 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum FishingUpdateStatus { APPLIED, UNCHANGED, STALE, NOT_FOUND }
|
||||
public enum RerollStatus { APPLIED, STALE }
|
||||
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
|
||||
/** Facts from this serialized mutation, never a request-time or later snapshot. */
|
||||
public record TrackingPublication(TrackingStatus status,boolean persistedBefore,Boolean previous,Boolean saved){}
|
||||
/** A failed write retains actual worker-observed before facts, never its candidate saved value. */
|
||||
public static final class TrackingFailure extends IOException {
|
||||
private static final long serialVersionUID=1L;
|
||||
private final transient TrackingPublication before;
|
||||
private TrackingFailure(TrackingPublication candidate,Throwable cause){
|
||||
super("Quest tracking source operation failed",cause);
|
||||
before=candidate==null?null:new TrackingPublication(candidate.status(),candidate.persistedBefore(),candidate.previous(),null);
|
||||
}
|
||||
public TrackingPublication before(){return before;}
|
||||
}
|
||||
public record CatalogPublication(AdventureQuestState.Catalog catalog,UUID operation,String reason,
|
||||
AdventureQuestSettings.Rules settings,List<AdventureQuestState.Site> selected,
|
||||
games.dmg.fantasystick.events.CampEventBridge.Actor actor,boolean originalCapture) {
|
||||
@@ -95,7 +107,25 @@ public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public boolean claimed(UUID catalog,UUID site,UUID character,UUID party){var state=snapshot;return state.characterClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.character().equals(character))||(party!=null&&state.unitClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.type()==AdventureQuestState.UnitType.PARTY&&claim.unit().equals(party)));}
|
||||
public Optional<AdventureQuestState.Quest> activeFor(UUID character,UUID party){return active().stream().filter(q->q.unitType()==AdventureQuestState.UnitType.SOLO?q.unit().equals(character):party!=null&&q.unit().equals(party)&&q.snapshot().contains(character)).findFirst();}
|
||||
public boolean trackingEnabled(UUID quest,UUID character){var active=findQuest(quest).filter(value->value.snapshot().contains(character));if(active.isEmpty())return false;return snapshot.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).map(AdventureQuestState.Tracking::enabled).findFirst().orElse(true);}
|
||||
public CompletableFuture<TrackingStatus> setTracking(UUID quest,UUID character,boolean enabled){return mutate(original->{var state=expire(original);var active=state.quests().stream().filter(value->value.id().equals(quest)&&value.snapshot().contains(character)).findFirst();if(active.isEmpty())return result(state,TrackingStatus.INELIGIBLE);var current=state.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).findFirst();if(current.map(AdventureQuestState.Tracking::enabled).orElse(true)==enabled&¤t.isPresent())return result(state,TrackingStatus.UNCHANGED);var tracking=new HashSet<>(state.tracking());tracking.removeIf(value->value.quest().equals(quest)&&value.character().equals(character));tracking.add(new AdventureQuestState.Tracking(quest,character,enabled));return result(copyTracking(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),TrackingStatus.APPLIED);});}
|
||||
public CompletableFuture<TrackingStatus> setTracking(UUID quest,UUID character,boolean enabled){
|
||||
var future=new CompletableFuture<TrackingStatus>();setTrackingWithEvidence(quest,character,enabled).whenComplete((publication,failure)->{
|
||||
if(failure==null)future.complete(publication.status());else future.completeExceptionally(failure instanceof TrackingFailure observed?observed.getCause():failure);
|
||||
});return future;
|
||||
}
|
||||
public CompletableFuture<TrackingPublication> setTrackingWithEvidence(UUID quest,UUID character,boolean enabled){
|
||||
var captured=new java.util.concurrent.atomic.AtomicReference<TrackingPublication>();
|
||||
var future=new CompletableFuture<TrackingPublication>();
|
||||
this.<TrackingPublication>mutate(original->{
|
||||
var state=expire(original);var active=state.quests().stream().filter(value->value.id().equals(quest)&&value.snapshot().contains(character)).findFirst();
|
||||
if(active.isEmpty()){var publication=new TrackingPublication(TrackingStatus.INELIGIBLE,false,null,null);captured.set(publication);return result(state,publication);}
|
||||
var current=state.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).findFirst();boolean before=current.map(AdventureQuestState.Tracking::enabled).orElse(true);
|
||||
if(before==enabled&¤t.isPresent()){var publication=new TrackingPublication(TrackingStatus.UNCHANGED,true,before,before);captured.set(publication);return result(state,publication);}
|
||||
var tracking=new HashSet<>(state.tracking());tracking.removeIf(value->value.quest().equals(quest)&&value.character().equals(character));tracking.add(new AdventureQuestState.Tracking(quest,character,enabled));
|
||||
var publication=new TrackingPublication(TrackingStatus.APPLIED,current.isPresent(),before,enabled);captured.set(publication);
|
||||
return result(copyTracking(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),publication);
|
||||
}).whenComplete((publication,failure)->{if(failure==null)future.complete(publication);else future.completeExceptionally(new TrackingFailure(captured.get(),failure));});
|
||||
return future;
|
||||
}
|
||||
public CompletableFuture<Boolean> bank(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,false);}
|
||||
public CompletableFuture<Boolean> bankAndComplete(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,true);}
|
||||
private CompletableFuture<Boolean> bank(UUID questId,UUID source,Map<UUID,Reward> rewards,boolean complete){Objects.requireNonNull(source);var requested=Map.copyOf(rewards);return mutate(original->{var state=expire(original);var quest=state.quests().stream().filter(value->value.id().equals(questId)).findFirst().orElse(null);if(quest==null||!quest.snapshot().containsAll(requested.keySet())||complete&&requested.isEmpty())return result(state,false);var allocations=new ArrayList<>(state.rewards().allocations());for(var entry:requested.entrySet()){var reward=Objects.requireNonNull(entry.getValue());if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=rewardId(quest,source,entry.getKey());var existing=allocations.stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(existing!=null){if(!existing.xp().equals(reward.xp())||!existing.coins().equals(reward.coins()))throw new IllegalStateException("Conflicting adventure reward allocation");continue;}allocations.add(new AdventureQuestState.RewardAllocation(id,quest.catalog(),quest.site(),quest.id(),source,entry.getKey(),reward.xp(),reward.coins()));}var quests=state.quests();var units=state.unitClaims();var characters=state.characterClaims();if(complete){quests=state.quests().stream().filter(value->!value.id().equals(quest.id())).toList();var changedUnits=new HashSet<>(units);changedUnits.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));units=Set.copyOf(changedUnits);var changedCharacters=new HashSet<>(characters);for(UUID character:requested.keySet())changedCharacters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),character));characters=Set.copyOf(changedCharacters);}var ledger=new AdventureQuestState.RewardLedger(allocations,state.rewards().operations(),state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),quests,units,characters,state.rosters(),ledger),true);});}
|
||||
|
||||
@@ -70,11 +70,46 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
public boolean earnedTrophy(UUID id){return fishing.trophy(id)
|
||||
.map(value->value.phase()==FishingState.TrophyPhase.EARNED)
|
||||
.orElseGet(()->fishing.snapshot().trophyReceipts().contains(id));}
|
||||
public void open(Player player){open(player,0,null);}
|
||||
public void open(Player player,AdventureQuestState.Giver giver){open(player,0,giver);}
|
||||
public void open(Player player){open(player,null);}
|
||||
public void open(Player player,AdventureQuestState.Giver giver){
|
||||
var request=fishingEvents==null?null:fishingEvents.turnIn(player,giver,"view");
|
||||
boolean eligible=context(player)!=null;
|
||||
open(player,0,giver);
|
||||
if(fishingEvents!=null)fishingEvents.turnInResolved(request,eligible?"viewed":"refused",
|
||||
eligible?null:"inactive_or_dead_character");
|
||||
}
|
||||
private void resolved(games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request,String outcome,String reason){
|
||||
if(fishingEvents!=null)fishingEvents.turnInResolved(request,outcome,reason);
|
||||
}
|
||||
private void open(Player player,int requestedPage,AdventureQuestState.Giver giver){
|
||||
if(giver==null)menuGivers.remove(player.getUniqueId());else menuGivers.put(player.getUniqueId(),giver);var context=context(player);if(context==null){player.sendMessage("Quest item turn-in requires an active living character.");return;}for(var pending:fishing.pendingCatches())if(pending.character().equals(context.record().id()))recoverCatch(pending);var entries=new ArrayList<ItemStack>();var quest=fishingQuest(context);if(quest!=null){var terms=quest.fishing();entries.add(item(Material.EMERALD,"Submit "+terms.itemName(),"submit",List.of("Held: "+fishing.quantity(context.record().id(),terms.itemId())+" / "+terms.required(),"Reward: "+terms.rewardXp()+" XP, "+terms.rewardCoins()+" coins",quest.unitType()==AdventureQuestState.UnitType.PARTY?"One accepted member supplies all items":"Individual submission","Beneficiaries: "+beneficiaries(context,quest).size()+" accepted character(s)")));}for(var trophy:fishing.trophies(context.record().id())){var total=trophy.total();entries.add(item(Material.PLAYER_HEAD,"Redeem "+trophy.name(),"trophy:"+trophy.id(),List.of("Origin: "+trophy.origin(),"Reward pool: "+total.xp()+" XP, "+total.coins()+" coins","Beneficiaries: "+trophy.rewards().size()+" frozen character(s)")));}var legacy=adventures.unclaimedAll(context.record().id());if(legacy.xp().signum()>0||legacy.coins().signum()>0)entries.add(item(Material.NETHER_STAR,"Claim retained adventure rewards","legacy-claim",List.of(legacy.xp()+" XP, "+legacy.coins()+" coins","Previously earned shares do not expire")));var reward=fishing.unclaimed(context.record().id());if(reward.xp().signum()>0||reward.coins().signum()>0)entries.add(item(Material.GOLD_INGOT,"Claim earned quest rewards","claim",List.of(reward.xp()+" XP, "+reward.coins()+" coins","Earned shares do not expire")));int pages=Math.max(1,(entries.size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));UUID nonce=UUID.randomUUID();menus.put(player.getUniqueId(),nonce);var inventory=plugin.getServer().createInventory(new Holder(player.getUniqueId(),context.active().session(),context.record().id(),nonce,page),54,net.kyori.adventure.text.Component.text("Turn in quest items "+(page+1)+"/"+pages));int start=page*45;for(int index=start;index<Math.min(entries.size(),start+45);index++)inventory.setItem(index-start,entries.get(index));if(entries.isEmpty())inventory.setItem(22,item(Material.BARRIER,"No eligible fishing items or rewards",null,List.of()));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","page:"+(page-1),List.of()));if(page+1<pages)inventory.setItem(53,item(Material.ARROW,"Next","page:"+(page+1),List.of()));player.openInventory(inventory);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);if(action==null)return;var context=context(player);if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){player.sendMessage("Quest item action refused: the menu, character or session changed.");return;}if(action.startsWith("page:")){try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));}catch(NumberFormatException invalid){player.sendMessage("Quest item page refused.");}return;}if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7));return;}if(action.equals("legacy-claim")){claimLegacy(player,context);return;}if(action.equals("claim")){claim(player,context);return;}if(action.equals("submit"))submit(player,context);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){
|
||||
if(!(event.getInventory().getHolder() instanceof Holder holder))return;
|
||||
event.setCancelled(true);
|
||||
if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null||closed)return;
|
||||
String action=event.getCurrentItem().getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING);
|
||||
if(action==null)return;
|
||||
String kind=action.startsWith("page:")?"navigation":action.startsWith("trophy:")?"redeem"
|
||||
:action.equals("legacy-claim")?"legacy_claim":action.equals("claim")?"claim":action.equals("submit")?"submit":"unsupported";
|
||||
var request=fishingEvents==null?null:fishingEvents.turnIn(player,menuGivers.get(player.getUniqueId()),kind);
|
||||
var context=context(player);
|
||||
if(context==null||!holder.player().equals(player.getUniqueId())||!holder.session().equals(context.active().session())
|
||||
||!holder.character().equals(context.record().id())||!menus.remove(player.getUniqueId(),holder.nonce())){
|
||||
resolved(request,"refused","stale_or_foreign_menu");
|
||||
player.sendMessage("Quest item action refused: the menu, character or session changed.");return;
|
||||
}
|
||||
if(action.startsWith("page:")){
|
||||
try{open(player,Integer.parseInt(action.substring(5)),menuGivers.get(player.getUniqueId()));resolved(request,"viewed",null);}
|
||||
catch(NumberFormatException invalid){resolved(request,"refused","invalid_page");player.sendMessage("Quest item page refused.");}return;
|
||||
}
|
||||
// Dispatch is not acknowledgment: the owning durable domain record alone denotes business success.
|
||||
resolved(request,"pending","awaiting_authoritative_domain_result");
|
||||
if(action.startsWith("trophy:")){redeemTrophy(player,context,action.substring(7),request);return;}
|
||||
if(action.equals("legacy-claim")){claimLegacy(player,context,request);return;}
|
||||
if(action.equals("claim")){claim(player,context,request);return;}
|
||||
if(action.equals("submit")){submit(player,context,request);return;}
|
||||
resolved(request,"refused","unsupported_action");
|
||||
}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=false)
|
||||
public void fish(PlayerFishEvent event){
|
||||
if(closed)return;
|
||||
@@ -202,10 +237,11 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
data.put("catalog_id",plan.catalog().toString());data.put("site_id",plan.site().toString());
|
||||
data.put("item_id",plan.item().toString());data.put("submitter_id",plan.submitter().toString());
|
||||
data.put("required",plan.quantity().toString());data.put("consumed",plan.quantity().toString());
|
||||
data.put("stock_before",receipt.stockBefore().toString());
|
||||
data.put("stock_after",receipt.stockAfter().toString());
|
||||
if(receipt.stockBefore()!=null&&receipt.stockAfter()!=null){
|
||||
data.put("stock_before",receipt.stockBefore().toString());data.put("stock_after",receipt.stockAfter().toString());
|
||||
}else data.put("stock_status","unavailable_legacy_commit");
|
||||
data.putAll(plan.audit());
|
||||
data.remove("online_roster");
|
||||
data.remove("actor_snapshot");data.remove("online_roster");
|
||||
data.put("roster",plan.rewards().keySet().stream().sorted().map(UUID::toString).toList());
|
||||
if(plan.audit().containsKey("submitter_location"))
|
||||
data.put("submitter_location",locationMap(plan.audit().get("submitter_location")));
|
||||
@@ -229,10 +265,14 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
beneficiaries.add(part);
|
||||
});
|
||||
data.put("beneficiaries",beneficiaries);
|
||||
fishingEvents.durable("adventure.items_submitted",plan.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
var occurred=java.time.Instant.now();
|
||||
if(plan.audit().containsKey("committed_at"))try{occurred=java.time.Instant.parse(plan.audit().get("committed_at"));}
|
||||
catch(java.time.DateTimeException invalid){data.put("commit_time_status","unavailable_legacy_commit");}
|
||||
else data.put("commit_time_status","unavailable_legacy_commit");
|
||||
fishingEvents.durableAt("adventure.items_submitted",plan.id(),actor==null
|
||||
?fishingEvents.actorFromSubmission(plan):actor,data,occurred);
|
||||
}
|
||||
private void submit(Player player,Context context){var quest=fishingQuest(context);if(quest==null){player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
|
||||
private void submit(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){var quest=fishingQuest(context);if(quest==null){resolved(request,"refused","no_eligible_quest");player.sendMessage("Fishing submission refused: no eligible accepted fishing quest.");return;}var beneficiaries=beneficiaries(context,quest);if(beneficiaries.isEmpty()||!beneficiaries.contains(context.record().id())){resolved(request,"refused","party_eligibility_changed");player.sendMessage("Fishing submission refused: party eligibility changed.");return;}var unit=new RewardAllocator.Unit<UUID>(quest.unit(),beneficiaries);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,FishingState.Reward>();for(UUID character:beneficiaries)rewards.put(character,new FishingState.Reward(xp.get(character),coins.get(character)));UUID operation=UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+context.record().id()).getBytes(StandardCharsets.UTF_8));var submissionAudit=new LinkedHashMap<String,String>();
|
||||
submissionAudit.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
submissionAudit.put("unit_id",quest.unit().toString());
|
||||
var onlineRoster=beneficiaries.stream().sorted().filter(character->{
|
||||
@@ -242,34 +282,42 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
}).map(UUID::toString).collect(java.util.stream.Collectors.joining(","));
|
||||
if(onlineRoster.length()<=8192)submissionAudit.put("online_roster",onlineRoster);
|
||||
else submissionAudit.put("online_roster_status","unavailable_large_roster");
|
||||
submissionAudit.put("submitter_session_id",context.active().session().toString());
|
||||
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);
|
||||
submissionAudit.put("actor_snapshot",games.dmg.fantasystick.events.FishingEventBridge.actorSnapshot(auditActor));
|
||||
submissionAudit.put("player_id",player.getUniqueId().toString());
|
||||
submissionAudit.put("player_name",player.getName());
|
||||
submissionAudit.put("character_name",context.record().name());
|
||||
submissionAudit.put("item_name",quest.fishing().itemName());
|
||||
if(request!=null)submissionAudit.put("causation_id",request.id().toString());
|
||||
submissionAudit.put("submitter_location",capture(player.getLocation()));
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
if(giver!=null){submissionAudit.put("giver_id",giver.id().toString());
|
||||
submissionAudit.put("giver_location",giver.world()+","+giver.x()+","+giver.y()+","+giver.z());}
|
||||
var plan=new FishingState.Submission(operation,quest.id(),quest.catalog(),quest.site(),context.record().id(),
|
||||
quest.fishing().itemId(),quest.fishing().required(),rewards,FishingState.SubmissionPhase.PREPARED,submissionAudit);
|
||||
var auditActor=fishingEvents==null?null:fishingEvents.actor(player);fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor);});}
|
||||
private void recoverSubmission(FishingState.Submission plan){if(plan.phase()==FishingState.SubmissionPhase.PREPARED)fishing.commitSubmission(plan.id()).whenComplete((status,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}if(status==FishingDirectory.CommitStatus.APPLIED||status==FishingDirectory.CommitStatus.DUPLICATE)finishRecoveredSubmission(plan);});else finishRecoveredSubmission(plan);}
|
||||
fishing.prepareSubmission(plan).whenComplete((status,failure)->{if(failure!=null){resolved(request,"failed","source_write_failed");report(operation,context.record().id(),"submit-prepare",failure);return;}if(status==FishingDirectory.PrepareStatus.INSUFFICIENT||status==FishingDirectory.PrepareStatus.COMPLETED){resolved(request,"refused",status==FishingDirectory.PrepareStatus.INSUFFICIENT?"insufficient_stock":"generation_already_completed");schedule(()->player.sendMessage(status==FishingDirectory.PrepareStatus.INSUFFICIENT?"Fishing submission refused: the full required quantity is not held.":"Fishing submission refused: this character already completed the offered generation."));return;}commitSubmission(plan,player,auditActor,request);});}
|
||||
private void recoverSubmission(FishingState.Submission plan){
|
||||
fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{
|
||||
if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-commit",failure);return;}
|
||||
if(receipt.status()!=FishingDirectory.CommitStatus.APPLIED&&receipt.status()!=FishingDirectory.CommitStatus.DUPLICATE)return;
|
||||
var committed=fishing.pendingSubmissions().stream().filter(value->value.id().equals(plan.id())).findFirst().orElse(plan);
|
||||
emitSubmission(committed,null,receipt);finishRecoveredSubmission(committed);
|
||||
});
|
||||
}
|
||||
private void finishRecoveredSubmission(FishingState.Submission plan){adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((ignored,failure)->{if(failure!=null){report(plan.id(),plan.submitter(),"submission-recovery-quest",failure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submission-recovery-finish",finishFailure);});});}
|
||||
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED)emitSubmission(plan,actor,receipt);adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded){
|
||||
private void commitSubmission(FishingState.Submission plan,Player player,games.dmg.fantasystick.events.FishingEventBridge.Actor actor,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){fishing.commitSubmissionWithEvidence(plan.id()).whenComplete((receipt,failure)->{var status=receipt==null?null:receipt.status();if(failure!=null){resolved(request,"uncertain","source_write_failed");report(plan.id(),plan.submitter(),"submit-commit",failure);return;}if(status==FishingDirectory.CommitStatus.INSUFFICIENT){resolved(request,"refused","stock_changed");schedule(()->player.sendMessage("Fishing submission refused: stock changed before commitment."));return;}if(status==FishingDirectory.CommitStatus.NOT_FOUND)return;if(status==FishingDirectory.CommitStatus.APPLIED){var committed=fishing.pendingSubmissions().stream().filter(value->value.id().equals(plan.id())).findFirst().orElse(plan);emitSubmission(committed,actor,receipt);}adventures.complete(plan.quest(),plan.rewards().keySet()).whenComplete((completed,questFailure)->{if(questFailure!=null){report(plan.id(),plan.submitter(),"quest-complete",questFailure);return;}fishing.finishSubmission(plan.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null)report(plan.id(),plan.submitter(),"submit-finish",finishFailure);schedule(()->{player.sendMessage("Fishing quest submitted; earned shares are ready at any Quest Giver.");open(player,0,menuGivers.get(player.getUniqueId()));});});});});}
|
||||
private void redeemTrophy(Player player,Context context,String encoded,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
|
||||
UUID trophy;try{trophy=UUID.fromString(encoded);}catch(IllegalArgumentException invalid){
|
||||
player.sendMessage("Trophy redemption refused: invalid authority.");return;}
|
||||
resolved(request,"refused","invalid_receipt");player.sendMessage("Trophy redemption refused: invalid authority.");return;}
|
||||
var captured=fishing.trophy(trophy).orElse(null);
|
||||
var giver=menuGivers.get(player.getUniqueId());
|
||||
var actor=fishingEvents==null?null:fishingEvents.actor(player);
|
||||
var interactionLocation=locationMap(capture(player.getLocation()));
|
||||
fishing.redeemTrophyWithEvidence(trophy,context.record().id()).whenComplete((receipt,failure)->schedule(()->{
|
||||
if(failure!=null){report(trophy,context.record().id(),"trophy-redeem",failure);
|
||||
if(failure!=null){resolved(request,"failed","source_write_failed");report(trophy,context.record().id(),"trophy-redeem",failure);
|
||||
player.sendMessage("Trophy redemption failed; the earned trophy is retained.");return;}
|
||||
var status=receipt==null?null:receipt.status();
|
||||
if(status==FishingDirectory.TrophyRedeemStatus.APPLIED){
|
||||
if(captured!=null&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("receipt_id",captured.id().toString());
|
||||
var data=new LinkedHashMap<String,Object>();if(request!=null)data.put("causation_id",request.id().toString());data.put("receipt_id",captured.id().toString());
|
||||
data.put("trophy_type",captured.type().name().toLowerCase(java.util.Locale.ROOT)+"_trophy");
|
||||
data.put("trophy_name",captured.name());
|
||||
if(captured.type()==FishingState.TrophyType.CAMP){
|
||||
@@ -306,30 +354,64 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
player.sendMessage("Adventure trophy redeemed; frozen shares are ready for each beneficiary.");
|
||||
}else player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");
|
||||
}else {resolved(request,"refused","unavailable_or_foreign_receipt");player.sendMessage("Trophy redemption refused: the receipt is unavailable for this character.");}
|
||||
open(player,0,menuGivers.get(player.getUniqueId()));
|
||||
}));
|
||||
}
|
||||
private void claimLegacy(Player player,Context context){adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;}awardLegacy(player,claim.orElseThrow(),context.record());});}
|
||||
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){awardLegacy(player,claim,award.record().orElseThrow());return;}if(award.record().isEmpty())return;var saved=award.record().orElseThrow();adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{if(finishFailure!=null){report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});});});}
|
||||
private void claim(Player player,Context context){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false);});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery){
|
||||
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){report(claim.id(),claim.character(),"claim-award",failure);return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery);return;
|
||||
private void claimLegacy(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
|
||||
var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());
|
||||
adventures.beginLegacyClaim(context.record().id()).whenComplete((claim,failure)->{
|
||||
if(failure!=null||claim.isEmpty()){
|
||||
resolved(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");
|
||||
if(failure!=null)report(UUID.randomUUID(),context.record().id(),"legacy-claim-prepare",failure);
|
||||
schedule(()->player.sendMessage("Retained adventure reward claim failed; earned shares remain."));return;
|
||||
}
|
||||
if(award.record().isEmpty())return;
|
||||
awardLegacy(player,claim.orElseThrow(),context.record(),actor,giver,request);
|
||||
});
|
||||
}
|
||||
private void awardLegacy(Player player,AdventureQuestState.RewardClaim claim,CharacterRecord record,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
|
||||
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){resolved(request,"failed","source_write_failed");report(claim.id(),claim.character(),"legacy-claim-award",failure);return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
awardLegacy(player,claim,award.record().orElseThrow(),actor,giver,request);return;
|
||||
}
|
||||
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){resolved(request,"refused","character_unavailable");return;}
|
||||
var saved=award.record().orElseThrow();
|
||||
var sources=adventures.snapshot().rewards().allocations().stream().filter(value->claim.allocations().contains(value.id())).toList();
|
||||
adventures.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
|
||||
if(finishFailure!=null){resolved(request,"uncertain","source_write_failed");report(claim.id(),claim.character(),"legacy-claim-finish",finishFailure);return;}
|
||||
if(!finished)return;
|
||||
if(fishingEvents!=null)fishingEvents.legacyClaimed(claim,actor,giver,record,award,sources,request);
|
||||
schedule(()->{
|
||||
var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);
|
||||
if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);
|
||||
var target=current==null?null:plugin.getServer().getPlayer(current.player());
|
||||
if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied adventure reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" retained adventure XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target,0,menuGivers.get(target.getUniqueId()));}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
private void claim(Player player,Context context,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){var actor=fishingEvents==null?null:fishingEvents.actor(player);var giver=menuGivers.get(player.getUniqueId());fishing.beginClaim(context.record().id()).whenComplete((claim,failure)->{if(failure!=null||claim.isEmpty()){resolved(request,failure==null?"refused":"failed",failure==null?"no_unlocked_rewards":"source_write_failed");if(failure!=null)report(UUID.randomUUID(),context.record().id(),"claim-prepare",failure);schedule(()->player.sendMessage("Fishing reward claim failed; earned shares are retained."));return;}awardClaim(player,context.active(),claim.orElseThrow(),context.record(),actor,giver,false,request);});}
|
||||
private void recoverClaim(FishingState.Claim claim){characters.find(claim.character()).whenComplete((record,failure)->{if(failure!=null){report(claim.id(),claim.character(),"claim-load",failure);return;}record.ifPresent(value->awardClaim(null,null,claim,value,null,null,true,null));});}
|
||||
private void awardClaim(Player player,EquipmentSessions.Active active,FishingState.Claim claim,CharacterRecord record,
|
||||
games.dmg.fantasystick.events.FishingEventBridge.Actor actor,AdventureQuestState.Giver giver,boolean recovery,games.dmg.fantasystick.events.FishingEventBridge.TurnInAction request){
|
||||
characters.awardCombatReward(record.id(),record.revision(),claim.id(),claim.xp(),claim.coins(),progression,CombatRewardRules.defaults(),null).whenComplete((award,failure)->{
|
||||
if(failure!=null){resolved(request,"failed","source_write_failed");report(claim.id(),claim.character(),"claim-award",failure);return;}
|
||||
if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){
|
||||
awardClaim(player,active,claim,award.record().orElseThrow(),actor,giver,recovery,request);return;
|
||||
}
|
||||
if(award.record().isEmpty()||award.status()==CharacterStorage.CombatAwardStatus.UNAVAILABLE){resolved(request,"refused","character_unavailable");return;}
|
||||
var saved=award.record().orElseThrow();
|
||||
var before=fishing.snapshot();
|
||||
var sources=before.allocations().stream().filter(value->claim.allocations().contains(value.id()))
|
||||
.sorted(Comparator.comparing(FishingState.Allocation::id)).toList();
|
||||
fishing.finishClaim(claim.id()).whenComplete((finished,finishFailure)->{
|
||||
if(finishFailure!=null){report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
|
||||
if(finishFailure!=null){resolved(request,"uncertain","source_write_failed");report(claim.id(),claim.character(),"claim-finish",finishFailure);return;}
|
||||
if(finished&&fishingEvents!=null){
|
||||
var data=new LinkedHashMap<String,Object>();
|
||||
if(request!=null)data.put("causation_id",request.id().toString());
|
||||
data.put("claim_id",claim.id().toString());data.put("beneficiary_id",claim.character().toString());
|
||||
data.put("requested_xp",claim.xp().toString());data.put("requested_coins",claim.coins().toString());
|
||||
data.put("recovery",recovery);data.put("award_status",award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
@@ -357,7 +439,7 @@ public final class FishingController implements Listener,AutoCloseable,Adventure
|
||||
fishingEvents.durable("adventure.reward_claimed",claim.id(),actor==null
|
||||
?new games.dmg.fantasystick.events.FishingEventBridge.Actor("system",null):actor,data);
|
||||
}
|
||||
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target);}});
|
||||
schedule(()->{var current=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(current!=null&&award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE)sessions.acknowledgeProgress(current,saved.revision().subtract(BigInteger.ONE),saved);var target=current==null?null:plugin.getServer().getPlayer(current.player());if(target!=null){target.sendMessage(award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE?"Finished a previously applied fishing reward claim.":"Claimed "+saved.experience().subtract(record.experience()).max(BigInteger.ZERO)+" quest XP and "+saved.coins().subtract(record.coins()).max(BigInteger.ZERO)+" character coins.");open(target,0,menuGivers.get(target.getUniqueId()));}});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +90,9 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
var submission=state.submissions().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);
|
||||
if(submission==null)return result(state,new SubmissionReceipt(CommitStatus.NOT_FOUND,null,null));
|
||||
if(submission.phase()==FishingState.SubmissionPhase.COMMITTED)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,null,null));
|
||||
return result(state,new SubmissionReceipt(CommitStatus.DUPLICATE,
|
||||
submission.audit().containsKey("stock_before")?new BigInteger(submission.audit().get("stock_before")):null,
|
||||
submission.audit().containsKey("stock_after")?new BigInteger(submission.audit().get("stock_after")):null));
|
||||
var before=quantity(state,submission.submitter(),submission.item());
|
||||
if(before.compareTo(submission.quantity())<0)
|
||||
return result(state,new SubmissionReceipt(CommitStatus.INSUFFICIENT,before,before));
|
||||
@@ -106,7 +108,13 @@ public final class FishingDirectory implements AutoCloseable {
|
||||
allocations.add(new FishingState.Allocation(allocationId(submission.id(),entry.getKey()),submission.id(),
|
||||
submission.catalog(),submission.site(),entry.getKey(),reward.xp(),reward.coins(),"fishing"));
|
||||
}
|
||||
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?value.committed():value).toList();
|
||||
var audit=new LinkedHashMap<>(submission.audit());
|
||||
// Older/custom receipts may already fill the unchanged audit envelope. Logging cannot block their deduction.
|
||||
if(audit.size()<=13){audit.put("stock_before",before.toString());audit.put("stock_after",remaining.toString());
|
||||
audit.put("committed_at",java.time.Instant.now().toString());}
|
||||
var committed=new FishingState.Submission(submission.id(),submission.quest(),submission.catalog(),submission.site(),
|
||||
submission.submitter(),submission.item(),submission.quantity(),submission.rewards(),FishingState.SubmissionPhase.COMMITTED,audit);
|
||||
var submissions=state.submissions().stream().map(value->value.id().equals(operation)?committed:value).toList();
|
||||
return result(copy(state,inventory,state.catches(),state.catchReceipts(),submissions,completions,allocations,state.claims()),
|
||||
new SubmissionReceipt(CommitStatus.APPLIED,before,remaining));
|
||||
});}
|
||||
|
||||
@@ -41,7 +41,17 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
|
||||
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
|
||||
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
|
||||
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
private games.dmg.fantasystick.events.ActiveInspectionEventBridge inspectionEvents;
|
||||
private games.dmg.fantasystick.events.SavedCharacterEventBridge savedEvents;
|
||||
private record AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
|
||||
games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection,
|
||||
games.dmg.fantasystick.events.SavedCharacterEventBridge.Inspection saved) {
|
||||
AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { this(action, null, null); }
|
||||
AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
|
||||
games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection) { this(action, inspection, null); }
|
||||
}
|
||||
private final Map<UUID, AdminLoad> adminPending = new ConcurrentHashMap<>();
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, SavedView> savedViews = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
@@ -66,7 +76,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int previous, int next) { }
|
||||
private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { }
|
||||
private record SavedView(String nonce, UUID administrator, org.bukkit.entity.Player holder,
|
||||
UUID character, int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
|
||||
@@ -123,6 +134,71 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
|
||||
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
|
||||
|
||||
public void attachInspectionEvents(games.dmg.fantasystick.events.ActiveInspectionEventBridge events) {
|
||||
if (closed || inspectionEvents != null) throw new IllegalStateException("Inspection events already attached or books closed");
|
||||
inspectionEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge audit){if(closed||adminAudit!=null)throw new IllegalStateException("Admin audit already attached or controller closed");adminAudit=Objects.requireNonNull(audit);}
|
||||
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge.Action commandAction() {
|
||||
return adminAudit == null ? null : adminAudit.current();
|
||||
}
|
||||
|
||||
private void adminResult(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome,
|
||||
String reason, UUID subject) {
|
||||
if (action != null && adminAudit != null)
|
||||
adminAudit.result(action, outcome, reason, subject == null ? Map.of() : Map.of("subject_id", subject));
|
||||
}
|
||||
|
||||
private void supersedeAdminLoad(UUID administrator, AdminLoad load) {
|
||||
var previous = adminPending.put(administrator, load);
|
||||
if (previous != null) retireAdminLoad(previous, "superseded");
|
||||
}
|
||||
|
||||
private void activeInspectionResult(AdminLoad load,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome, String centralReason,
|
||||
String reason, CharacterRecord record, games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int pages, int volumes) {
|
||||
if (load.inspection() != null) inspectionEvents.resolved(load.inspection(),
|
||||
outcome.name().toLowerCase(java.util.Locale.ROOT), reason, record, active, pages, volumes);
|
||||
if (load.inspection() != null && adminAudit != null) {
|
||||
var facts = new java.util.LinkedHashMap<String, Object>();
|
||||
facts.put("domain_event_id", load.inspection().eventId());
|
||||
if (record != null && outcome == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED)
|
||||
facts.put("subject_id", record.id());
|
||||
adminAudit.result(load.action(), outcome, centralReason, facts);
|
||||
} else adminResult(load.action(), outcome, centralReason, record == null ? null : record.id());
|
||||
}
|
||||
|
||||
private void activeInspectionResult(AdminLoad load,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome, String centralReason, String reason) {
|
||||
activeInspectionResult(load, outcome, centralReason, reason, null, null, 0, 0);
|
||||
}
|
||||
|
||||
private void retireAdminLoad(AdminLoad load, String reason) {
|
||||
if (load.saved() != null) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
reason.equals("superseded") ? "superseded" : "cancelled", reason);
|
||||
return;
|
||||
}
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
"operation_uncertain", reason);
|
||||
}
|
||||
|
||||
/** Command owner refuses before resolving protected character/session data. */
|
||||
public void refuseActiveInspection(String reason) {
|
||||
var action = commandAction();
|
||||
var request = inspectionEvents == null ? null : inspectionEvents.request(action, null, 0, "command");
|
||||
String centralReason = switch (reason) {
|
||||
case "unauthorized", "sender_unsupported", "invalid_arguments", "permission_changed" -> reason;
|
||||
default -> "not_found";
|
||||
};
|
||||
activeInspectionResult(new AdminLoad(action, request),
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, centralReason, reason);
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
|
||||
public void attachProgressionEvents(games.dmg.fantasystick.events.ProgressionEventBridge events){if(closed||progressionEvents!=null)throw new IllegalStateException("Progression events already attached or controller closed");progressionEvents=Objects.requireNonNull(events);}
|
||||
public void attachAbilityEvents(games.dmg.fantasystick.events.AbilityEventBridge events){if(closed||abilityEvents!=null)throw new IllegalStateException("Ability events already attached or controller closed");abilityEvents=Objects.requireNonNull(events);}
|
||||
@@ -132,113 +208,223 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
|
||||
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
|
||||
if (closed) return;
|
||||
var action = commandAction();
|
||||
if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; }
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
refuseActiveInspection("permission_changed");
|
||||
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
|
||||
return;
|
||||
}
|
||||
UUID targetId = target.getUniqueId();
|
||||
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
|
||||
if (active == null) {
|
||||
refuseActiveInspection("target_inactive");
|
||||
administrator.sendMessage("Character view refused: the target has no current active character.");
|
||||
return;
|
||||
}
|
||||
loadReadOnly(administrator, target, active, 0);
|
||||
loadReadOnly(administrator, target, active, 0, action);
|
||||
}
|
||||
|
||||
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
|
||||
if (closed) return;
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
|
||||
loadReadOnly(administrator, target, active, offset, action, "command");
|
||||
}
|
||||
|
||||
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action action, String entryPoint) {
|
||||
if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; }
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
UUID targetId = target.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
var inspection = inspectionEvents == null ? null : inspectionEvents.request(action, targetId, offset, entryPoint);
|
||||
var load = new AdminLoad(action, inspection);
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
supersedeAdminLoad(administratorId, load);
|
||||
characters.find(active.character()).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
if (closed || adminPending.get(administratorId) != load) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !adminPending.remove(administratorId, ticket)) return;
|
||||
if (closed || !adminPending.remove(administratorId, load)) return;
|
||||
var currentTarget = server.getPlayer(targetId);
|
||||
var current = currentTarget == null ? null : sessions.active(targetId)
|
||||
.filter(sessions::current).orElse(null);
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"permission_changed", "permission_changed");
|
||||
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
|
||||
return;
|
||||
}
|
||||
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
|
||||
if (server.getPlayer(administratorId) != administrator
|
||||
|| currentTarget != target || !target.isOnline() || !active.equals(current)) {
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"stale", "stale_session");
|
||||
administrator.sendMessage("Character view refused: the target character session changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"operation_failed", "load_failed");
|
||||
report(new Failure(administratorId, active.character(), failure));
|
||||
administrator.sendMessage("Character view could not be loaded; no state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"not_found", "character_unavailable");
|
||||
administrator.sendMessage("Character view refused: the target character is unavailable.");
|
||||
return;
|
||||
}
|
||||
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
|
||||
currentTarget, active, offset)); }
|
||||
catch (RuntimeException error) {
|
||||
try {
|
||||
var loaded = record.orElseThrow();
|
||||
var book = renderReadOnly(loaded, administrator, currentTarget, active, offset);
|
||||
int pages = ((BookMeta) book.getItemMeta()).getPageCount();
|
||||
int volumes = (overviewPages(loaded, currentTarget, null, false).size() + VOLUME_SIZE - 1) / VOLUME_SIZE;
|
||||
administrator.openBook(book);
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
|
||||
"read_completed", "native_open_acknowledged", loaded, active, pages, volumes);
|
||||
} catch (RuntimeException error) {
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"operation_failed", "open_failed");
|
||||
adminViews.remove(administratorId);
|
||||
report(new Failure(administratorId, active.character(), error));
|
||||
administrator.sendMessage("Character view could not be opened; no state was changed.");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
adminPending.remove(administratorId, ticket);
|
||||
if (adminPending.remove(administratorId, load))
|
||||
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"operation_failed", "scheduler_failed");
|
||||
report(new Failure(administratorId, active.character(), rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
|
||||
if (closed) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required.");
|
||||
return;
|
||||
}
|
||||
loadSavedReadOnly(administrator, character, 0);
|
||||
public void attachSavedEvents(games.dmg.fantasystick.events.SavedCharacterEventBridge events) {
|
||||
if (closed || savedEvents != null) throw new IllegalStateException("Saved events already attached or books closed");
|
||||
savedEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset) {
|
||||
if (closed) return;
|
||||
private void savedInspectionResult(AdminLoad load,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason,
|
||||
CharacterRecord record, int pages, int volumes, int presentedOffset, java.util.List<String> sections) {
|
||||
boolean active = record != null && sessions.activeCharacter(record.id()).filter(sessions::current).isPresent();
|
||||
if (load.saved() != null) savedEvents.inspectionResolved(load.saved(), outcome, reason,
|
||||
record, active, pages, volumes, presentedOffset, sections);
|
||||
if (load.saved() != null && adminAudit != null) {
|
||||
var facts = new java.util.LinkedHashMap<String, Object>();
|
||||
facts.put("domain_event_id", load.saved().eventId());
|
||||
if (central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED && record != null)
|
||||
facts.put("subject_id", record.id());
|
||||
String centralReason = switch (central) {
|
||||
case COMPLETED -> "read_completed";
|
||||
case FAILED -> "operation_failed";
|
||||
case UNCERTAIN -> "operation_uncertain";
|
||||
default -> reason.equals("permission_changed") || reason.equals("sender_unsupported")
|
||||
|| reason.equals("invalid_arguments") || reason.equals("unauthorized") ? reason
|
||||
: outcome.equals("not_found") ? "not_found" : "stale";
|
||||
};
|
||||
adminAudit.result(load.action(), central, centralReason, facts);
|
||||
} else adminResult(load.action(), central,
|
||||
central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED
|
||||
? "read_completed" : "owner_refused", record == null ? null : record.id());
|
||||
}
|
||||
|
||||
private void savedInspectionResult(AdminLoad load,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason) {
|
||||
savedInspectionResult(load, central, outcome, reason, null, 0, 0, 0, java.util.List.of());
|
||||
}
|
||||
|
||||
public void refuseSavedInspection(String reason) {
|
||||
var action = commandAction();
|
||||
var request = savedEvents == null ? null : savedEvents.inspection(action, null, 0, "command");
|
||||
savedInspectionResult(new AdminLoad(action, null, request),
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "refused", reason);
|
||||
}
|
||||
|
||||
public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(character);
|
||||
loadSavedReadOnly(administrator, character, 0, commandAction(), "command");
|
||||
}
|
||||
|
||||
private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action action, String entryPoint) {
|
||||
var request = savedEvents == null ? null : savedEvents.inspection(action, character, offset, entryPoint);
|
||||
var load = new AdminLoad(action, null, request);
|
||||
if (closed) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
"cancelled", "plugin_shutdown"); return;
|
||||
}
|
||||
if (!administrator.hasPermission("fantasystick.admin")) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"refused", "permission_changed"); return;
|
||||
}
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"stale", "sender_lifetime_changed"); return;
|
||||
}
|
||||
adminViews.remove(administratorId);
|
||||
savedViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
supersedeAdminLoad(administratorId, load);
|
||||
characters.inspect(character).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
if (closed || adminPending.get(administratorId) != load) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !adminPending.remove(administratorId, ticket)) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (closed || !adminPending.remove(administratorId, load)) return;
|
||||
if (!administrator.hasPermission("fantasystick.admin")) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"refused", "permission_changed");
|
||||
if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed.");
|
||||
return;
|
||||
}
|
||||
if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"stale", "sender_lifetime_changed"); return;
|
||||
}
|
||||
if (failure != null) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"unreadable", "read_failed");
|
||||
report(new Failure(administratorId, character, failure));
|
||||
administrator.sendMessage("Saved character view could not be loaded; no state was changed.");
|
||||
return;
|
||||
administrator.sendMessage("Saved character view could not be loaded; no state was changed."); return;
|
||||
}
|
||||
if (record.isEmpty()) {
|
||||
administrator.sendMessage("Saved character view refused: character not found.");
|
||||
return;
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"not_found", "not_found");
|
||||
administrator.sendMessage("Saved character view refused: character not found."); return;
|
||||
}
|
||||
try { administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); }
|
||||
catch (RuntimeException error) {
|
||||
try {
|
||||
var loaded = record.orElseThrow();
|
||||
var book = renderSavedReadOnly(loaded, administrator, offset);
|
||||
int count = overviewPages(loaded, administrator, null, true).size();
|
||||
int presentedOffset = Math.min(offset, (count - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
var sections = new java.util.ArrayList<String>();
|
||||
if (presentedOffset == 0) sections.add("saved_identity");
|
||||
sections.add("overview");
|
||||
if (presentedOffset + VOLUME_SIZE >= count) {
|
||||
sections.add("gear");
|
||||
if (!abilities.definitions().isEmpty()) sections.add("active_abilities");
|
||||
if (!passives.definitions().isEmpty()) sections.add("passive_abilities");
|
||||
if (fishing != null || rareInventory != null) sections.add("inventory");
|
||||
sections.add("party");
|
||||
}
|
||||
administrator.openBook(book);
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
|
||||
"opened", "native_open_acknowledged", loaded, ((BookMeta) book.getItemMeta()).getPageCount(),
|
||||
(count + VOLUME_SIZE - 1) / VOLUME_SIZE, presentedOffset, sections);
|
||||
} catch (RuntimeException error) {
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"failed", "open_failed");
|
||||
savedViews.remove(administratorId);
|
||||
report(new Failure(administratorId, character, error));
|
||||
administrator.sendMessage("Saved character view could not be opened; no state was changed.");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
adminPending.remove(administratorId, ticket);
|
||||
if (adminPending.remove(administratorId, load))
|
||||
savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
|
||||
"failed", "scheduler_failed");
|
||||
report(new Failure(administratorId, character, rejected));
|
||||
}
|
||||
});
|
||||
@@ -413,7 +599,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Next pages", view, 1));
|
||||
} else if (savedState) {
|
||||
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(),
|
||||
var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), administrator, record.id(),
|
||||
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
|
||||
savedViews.put(administrator.getUniqueId(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
@@ -437,6 +623,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
? partyControls.pages(input) : savedState
|
||||
? partyControls.readOnlyCharacterPages(record.id())
|
||||
: partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
if (end == allOverview.size() && !savedState) pages.addAll(roadGuidancePages());
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
@@ -445,6 +632,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private java.util.List<Component> inventoryPages(UUID character,ControlInput input){var directory=fishing;var rare=rareInventory;if(directory==null&&rare==null)return java.util.List.of();var values=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.ItemStack>of():directory.inventory(character);var trophies=directory==null?java.util.List.<games.dmg.fantasystick.adventure.FishingState.Trophy>of():directory.trophies(character);var goods=rare==null?java.util.List.<games.dmg.fantasystick.shop.RareInventoryDirectory.Item>of():rare.inventory(character);RareView view=input==null?null:new RareView(UUID.randomUUID().toString(),input,goods.stream().filter(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::available).collect(java.util.stream.Collectors.toUnmodifiableMap(games.dmg.fantasystick.shop.RareInventoryDirectory.Item::id,games.dmg.fantasystick.shop.RareInventoryDirectory.Item::quantity)));if(view!=null&&!view.quantities().isEmpty())rareViews.put(input.session().player(),view);var builder=new PageBuilder();builder.text("Inventory");builder.blank();if(values.isEmpty()&&trophies.isEmpty()&&goods.isEmpty())builder.text("No items.");else{int shown=0;for(var value:values){if(shown++>=256)break;builder.text(value.name()+": "+value.amount());builder.text("Fishing origin: "+value.site());}for(var trophy:trophies){if(shown++>=256)break;builder.text(trophy.name()+": 1");builder.text("Trophy origin: "+trophy.origin());}for(var good:goods){if(shown++>=256)break;boolean phoenix=games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN.id().equals(good.id());builder.inline(good.name()+": "+good.quantity(),view!=null&&good.available()&&!phoenix?rareButton(view,good.id()):null);if(phoenix&&good.available())builder.text("Use Phoenix Down while downed.");if(!good.available())builder.text("Unavailable item type");}if(values.size()+trophies.size()+goods.size()>256)builder.text("More items are retained.");}return builder.pages();}
|
||||
private static Component rareButton(RareView view,String product){return Component.text("[Use]",net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN).clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(RARE_USE,net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\""+view.nonce()+":"+product+"\"")));}
|
||||
|
||||
private java.util.List<Component> roadGuidancePages() {
|
||||
var builder = new PageBuilder();
|
||||
builder.text("Dirt Path travel");
|
||||
builder.text("Your active living character automatically gains native Speed I on Dirt Paths: no points or passive slots.");
|
||||
builder.text("Stay grounded and on foot. Jumping, swimming, flying, gliding, mounting and downing stop road grants.");
|
||||
builder.text("Equal Speed does not stack; other Speed sources are preserved. Administrator-enabled road leases last 1–200 native ticks (default 20), renewed while eligible.");
|
||||
builder.text("Leaving requests owned cleanup. A refused or interrupted cleanup can leave the short finite lease until native expiry; offline ticks are not wall-clock expiry.");
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
|
||||
|
||||
@@ -740,15 +937,42 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (closed || !SAVED_PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var administrator = connection.getPlayer();
|
||||
if (adminAudit == null) navigateSavedInspection(event, administrator, null);
|
||||
else {
|
||||
try (var invocation = adminAudit.openSavedInspectionNavigation(administrator)) {
|
||||
navigateSavedInspection(event, administrator, invocation.action());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateSavedInspection(io.papermc.paper.event.player.PlayerCustomClickEvent event,
|
||||
org.bukkit.entity.Player administrator, games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
|
||||
var refused = new AdminLoad(action, null, savedEvents == null ? null
|
||||
: savedEvents.inspection(action, null, 0, "book_navigation"));
|
||||
if (!administrator.hasPermission("fantasystick.admin")) {
|
||||
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"refused", "permission_changed"); return;
|
||||
}
|
||||
if (!administrator.isOnline() || server.getPlayer(administrator.getUniqueId()) != administrator) {
|
||||
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"stale", "sender_lifetime_changed"); return;
|
||||
}
|
||||
var view = savedViews.get(administrator.getUniqueId());
|
||||
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) return;
|
||||
loadSavedReadOnly(administrator, view.character(), offset);
|
||||
int offset = -1;
|
||||
if (view != null && view.holder() == administrator && event.getTag() != null
|
||||
&& view.administrator().equals(administrator.getUniqueId())) {
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() <= 64) {
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (match.matches() && view.nonce().equals(match.group(1)))
|
||||
offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
}
|
||||
}
|
||||
if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) {
|
||||
savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"refused", "invalid_navigation"); return;
|
||||
}
|
||||
loadSavedReadOnly(administrator, view.character(), offset, action, "book_navigation");
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
@@ -756,15 +980,40 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var administrator = connection.getPlayer();
|
||||
if (adminAudit == null) {
|
||||
navigateActiveInspection(event, administrator, null);
|
||||
} else {
|
||||
try (var invocation = adminAudit.openInspectionNavigation(administrator)) {
|
||||
navigateActiveInspection(event, administrator, invocation.action());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void navigateActiveInspection(io.papermc.paper.event.player.PlayerCustomClickEvent event,
|
||||
org.bukkit.entity.Player administrator, games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
|
||||
var refused = new AdminLoad(action, inspectionEvents == null ? null
|
||||
: inspectionEvents.request(action, null, 0, "book_navigation"));
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
activeInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"permission_changed", "permission_changed");
|
||||
return;
|
||||
}
|
||||
var view = adminViews.get(administrator.getUniqueId());
|
||||
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !adminViews.remove(administrator.getUniqueId(), view)) return;
|
||||
loadReadOnly(administrator, view.target(), view.active(), offset);
|
||||
int offset = -1;
|
||||
if (view != null && event.getTag() != null && view.administrator().equals(administrator.getUniqueId())) {
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() <= 64) {
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (match.matches() && view.nonce().equals(match.group(1)))
|
||||
offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
}
|
||||
}
|
||||
if (offset < 0 || !adminViews.remove(administrator.getUniqueId(), view)) {
|
||||
activeInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
|
||||
"owner_refused", "invalid_navigation");
|
||||
return;
|
||||
}
|
||||
loadReadOnly(administrator, view.target(), view.active(), offset, action, "book_navigation");
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
@@ -971,7 +1220,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
var bookRequest=pendingBookEvents.remove(owner);if(bookRequest!=null)bookEvents.refused(bookRequest,"player_quit");
|
||||
adminPending.remove(owner);
|
||||
var adminLoad = adminPending.remove(owner);
|
||||
if (adminLoad != null) retireAdminLoad(adminLoad, "administrator_quit");
|
||||
adminViews.remove(owner);
|
||||
savedViews.remove(owner);
|
||||
views.remove(owner);
|
||||
@@ -984,6 +1234,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.values().forEach(load -> retireAdminLoad(load, "plugin_shutdown")); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public final class ManualCraftDelivery {
|
||||
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.
|
||||
// Purpur 2651: 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++) {
|
||||
|
||||
@@ -20,42 +20,93 @@ public final class DungeonCommands {
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;private games.dmg.fantasystick.events.DungeonAuditEventBridge ownerEvents;private games.dmg.fantasystick.events.CharacterBossEventBridge characterBossEvents;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
public boolean ownsTeleport(org.bukkit.event.player.PlayerTeleportEvent event){return teleports.owns(event);}
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);teleports.attachAdminAudit(bridge);}
|
||||
private void completedRead(games.dmg.fantasystick.events.AdminActionEventBridge.Action action){adminResult(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"read_completed",null,null);}
|
||||
private void adminResult(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome,String reason,UUID operation,String type){
|
||||
if(adminAudit==null||action==null)return;
|
||||
Map<String,Object> facts=operation==null?Map.of():type==null?Map.of("domain_operation_id",operation):Map.of("domain_operation_id",operation,"domain_event_id",games.dmg.fantasystick.events.DungeonAuditEventBridge.eventId(operation,type));
|
||||
adminAudit.result(action,outcome,reason,facts);
|
||||
}
|
||||
private void bossResult(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,UUID operation,String type){if(adminAudit!=null&&action!=null)adminAudit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"acknowledged",Map.of("domain_operation_id",operation,"domain_event_id",UUID.nameUUIDFromBytes(("fantasystick:character-boss:"+operation+":"+type).getBytes(java.nio.charset.StandardCharsets.UTF_8))));}
|
||||
private void refused(String reason){adminResult(adminAudit==null?null:adminAudit.current(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,reason,null,null);}
|
||||
private void confirm(){if(adminAudit!=null)adminAudit.confirmation(adminAudit.current(),true,"command_confirmation",null);}
|
||||
private void savedResult(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,Throwable failure,UUID operation,String type){
|
||||
Throwable cause=failure;
|
||||
while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();
|
||||
boolean validationRefused=cause instanceof IllegalArgumentException;
|
||||
adminResult(action,failure==null?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED:validationRefused?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,failure==null?"acknowledged":validationRefused?"owner_refused":"operation_failed",failure==null?operation:null,failure==null?type:null);
|
||||
}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DungeonEventBridge events){this.auditEvents=Objects.requireNonNull(events);}
|
||||
public void attachOwnerEvents(games.dmg.fantasystick.events.DungeonAuditEventBridge events){this.ownerEvents=Objects.requireNonNull(events);}
|
||||
public void attachCharacterBossEvents(games.dmg.fantasystick.events.CharacterBossEventBridge events){this.characterBossEvents=Objects.requireNonNull(events);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)try{audit.created(operation,actor,value);}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged."));});}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setModeWithEvidence(id,current.revision(),requested).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.modeChanged(operation,actor,change.before(),change.after());}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+change.after().participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale."));});}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}var request=request(sender);controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->observedSave(request,"administrator_remove",()->directory.remove(id))).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){var request=request(sender);var before=controller.editing(player.getUniqueId()).flatMap(directory::find).orElse(null);boolean ended=controller.done(player.getUniqueId());if(ended&&before!=null&&request.events()!=null)try{request.events().lease("dungeon.edit.ended",request.operation(),request.actor(),before,"administrator_done");}catch(RuntimeException ignored){}sender.sendMessage(ended?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}var request=request(sender);controller.startWithEvidence(player,id,args.length==5).whenComplete((publication,failure)->{var status=publication==null?null:publication.status();if(failure==null&&status==DungeonController.EditStatus.STARTED&&request.events()!=null)try{request.events().lease("dungeon.edit.started",request.operation(),request.actor(),publication.dungeon(),"administrator_start");}catch(RuntimeException ignored){}respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});});});}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}observedSave(sender,"administrator_entrance",()->directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}observedSave(sender,"administrator_room_remove",()->directory.removeRoom(dungeon,room)).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"mode"->mode(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"reward"->reward(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){refused("owner_refused");sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){if(!(sender instanceof Player))refused("sender_unsupported");usage(sender);return;}int nameStart=2;var mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.PARTY_AVAILABLE;if(args[2].equals("--mode")){if(args.length<5){usage(sender);return;}try{mode=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}nameStart=4;}else if(args[2].startsWith("--")){usage(sender);return;}String name=join(args,nameStart);if(name.isBlank()){usage(sender);return;}var location=player.getLocation();var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();var action=adminAudit==null?null:adminAudit.current();directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch(),mode).whenComplete((value,failure)->{if(failure==null&&audit!=null)try{audit.created(operation,actor,value);}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}savedResult(action,failure,operation,"dungeon.created");respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+"); mode="+value.participationMode().value()+".":"Dungeon creation failed; desired state is unchanged."));});}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}var action=adminAudit==null?null:adminAudit.current();var dungeons=directory.snapshot().dungeons();if(dungeons.isEmpty()){sender.sendMessage("No dungeons registered.");completedRead(action);return;}dungeons.stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; mode="+value.participationMode().value()+"; rooms="+value.rooms().size()+"; completion="+(value.rewardConfigured()?value.completionReward().xp()+" XP, "+value.completionReward().coins()+" coins":"not configured")+"."));completedRead(action);}
|
||||
private void mode(CommandSender sender,String[] args){if(args.length<3||args.length>4){usage(sender);return;}UUID id=id(args[2]);var current=id==null?null:directory.find(id).orElse(null);if(current==null){refused("not_found");sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage("Dungeon "+id+" mode="+current.participationMode().value()+".");completedRead(adminAudit==null?null:adminAudit.current());return;}games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode requested;try{requested=games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.parse(args[3]);}catch(IllegalArgumentException invalid){usage(sender);return;}var audit=ownerEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();var action=adminAudit==null?null:adminAudit.current();directory.setModeWithEvidence(id,current.revision(),requested).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.modeChanged(operation,actor,change.before(),change.after());}catch(RuntimeException ignored){/* Audit cannot undo acknowledged authoring. */}if(failure!=null){Throwable cause=failure;while(cause instanceof java.util.concurrent.CompletionException&&cause.getCause()!=null)cause=cause.getCause();adminResult(action,cause instanceof java.io.IOException?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,cause instanceof java.io.IOException?"operation_failed":"stale",null,null);}else if(change.before().equals(change.after()))refusedSave(action);else savedResult(action,null,operation,"dungeon.mode_changed");respond(sender,()->sender.sendMessage(failure==null?"Updated dungeon "+id+" mode="+change.after().participationMode().value()+" for future acceptances.":"Dungeon mode update refused; the acknowledged definition is unchanged or stale."));});}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){refused("owner_refused");sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}confirm();var request=request(sender);controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->observedSave(request,"administrator_remove",()->directory.remove(id))).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){refused("sender_unsupported");usage(sender);return;}if(args.length==3&&args[2].equals("done")){var request=request(sender);var before=controller.editing(player.getUniqueId()).flatMap(directory::find).orElse(null);boolean ended=controller.done(player.getUniqueId());if(ended&&before!=null&&request.events()!=null)try{request.events().lease("dungeon.edit.ended",request.operation(),request.actor(),before,"administrator_done");}catch(RuntimeException ignored){}if(ended&&before!=null)savedResult(request.action(),null,request.operation(),"dungeon.edit.ended");else refused("no_change");sender.sendMessage(ended?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}if(args.length==5)confirm();var request=request(sender);controller.startWithEvidence(player,id,args.length==5).whenComplete((publication,failure)->{var status=publication==null?null:publication.status();if(failure==null&&status==DungeonController.EditStatus.STARTED&&request.events()!=null)try{request.events().lease("dungeon.edit.started",request.operation(),request.actor(),publication.dungeon(),"administrator_start");}catch(RuntimeException ignored){}if(failure!=null)savedResult(request.action(),failure,null,null);else if(status==DungeonController.EditStatus.STARTED)savedResult(request.action(),null,request.operation(),"dungeon.edit.started");else adminResult(request.action(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,status==DungeonController.EditStatus.NOT_FOUND?"not_found":status==DungeonController.EditStatus.CONFIRM_REQUIRED?"confirmation_required":"owner_refused",null,null);respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});});});}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){refused("owner_refused");sender.sendMessage("Dungeon entrance must remain in its world.");return;}observedSave(sender,"administrator_entrance",()->directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){if(!(sender instanceof Player))refused("sender_unsupported");usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));completedRead(adminAudit==null?null:adminAudit.current());}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}observedSave(sender,"administrator_room_remove",()->directory.removeRoom(dungeon,room)).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}observedSave(sender,"administrator_room_create",()->directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height)).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();observedSave(sender,"administrator_mob_add",()->directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_mob_remove",()->directory.removeMob(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){refused("owner_refused");sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();observedSave(sender,"administrator_mob_add",()->directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_mob_remove",()->directory.removeMob(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));completedRead(adminAudit==null?null:adminAudit.current());return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}observedSave(sender,"administrator_effect_add",()->directory.addEffect(dungeon,room.id(),target,key,level,particles)).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_effect_remove",()->directory.removeEffect(dungeon,room.id(),id)).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();observedSave(sender,"administrator_native_boss",()->directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){observedSave(sender,"administrator_native_boss_clear",()->directory.clearBoss(dungeon,room.id())).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}var assignment=directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier);assignment.whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.custodySaved(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Saved custody observation never gates native revalidation or consumption. */}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"saved_character_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change character/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing."));});return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"held_sheet_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change inventory/custody authority. */}respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing."));});return;}player.getInventory().setItemInMainHand(null);if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.assigned(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Optional observation cannot interrupt physical consumption. */}sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var preparation=directory.prepareRecovery(dungeon,player.getUniqueId());preparation.whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts deliveryFacts=null;if(audit!=null)try{var view=DungeonDirectory.publication(preparation).flatMap(publication->publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst()).orElse(definition);var originalRoom=view.rooms().stream().filter(value->value.boss()!=null&&value.boss().character()!=null&&value.boss().character().operation().equals(prepared.operation())).findFirst().orElseThrow();deliveryFacts=audit.custody(request,view,originalRoom.id(),prepared);}catch(RuntimeException ignored){/* Delivery does not depend on optional metadata. */}var facts=deliveryFacts;var completion=directory.completeRecovery(dungeon,operation);completion.whenComplete((completed,completeFailure)->{if(completeFailure==null&&Boolean.TRUE.equals(completed)&&facts!=null)try{DungeonDirectory.publication(completion).ifPresent(publication->audit.recovered(facts,operation,publication,"exact_authorized_sheet_delivered"));}catch(RuntimeException ignored){/* Durable completion stays authoritative independently of feedback. */}if(facts!=null&&(completeFailure!=null||!Boolean.TRUE.equals(completed)))try{audit.recoveryPending(facts,operation,completeFailure!=null?"failed_final_source_save":"completion_not_acknowledged");}catch(RuntimeException ignored){/* Pending recovery observation cannot change custody or native delivery. */}respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");});});}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private void reward(CommandSender sender,String[] args){if(args.length<3||args.length>5){usage(sender);return;}UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null){refused("not_found");sender.sendMessage("Dungeon not found.");return;}if(args.length==3){sender.sendMessage(current.rewardConfigured()?"Dungeon "+dungeon+" completion reward is "+current.completionReward().xp()+" XP and "+current.completionReward().coins()+" coins.":"Dungeon "+dungeon+" completion reward is not configured.");completedRead(adminAudit==null?null:adminAudit.current());return;}if(current.enabled()||adventures.offerOpen(dungeon)||adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon))){refused("owner_refused");sender.sendMessage("Dungeon reward update requires disabled, closed dungeon state without accepted quests.");return;}if(controller.beingEdited(dungeon)&&(!(sender instanceof Player player)||!controller.editing(player.getUniqueId()).filter(dungeon::equals).isPresent())){refused("owner_refused");sender.sendMessage("Dungeon reward update refused: another administrator owns the editing lease.");return;}BigInteger xp=args.length==5?whole(args[3]):null,coins=args.length==5?whole(args[4]):null;if(xp==null||coins==null){usage(sender);return;}var audit=auditEvents;var actor=audit==null?null:audit.actor(sender);var operation=UUID.randomUUID();var action=adminAudit==null?null:adminAudit.current();directory.setCompletionRewardWithEvidence(dungeon,xp,coins).whenComplete((change,failure)->{if(failure==null&&audit!=null)try{audit.rewardChanged(operation,actor,change);}catch(RuntimeException ignored){}if(failure==null&&adminAudit!=null)adminAudit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"acknowledged",Map.of("domain_operation_id",operation,"domain_event_id",UUID.nameUUIDFromBytes(("fantasystick:fishing:"+operation+":dungeon.reward_changed").getBytes(java.nio.charset.StandardCharsets.UTF_8))));else if(failure!=null)savedResult(action,failure,null,null);saved(sender,failure,"Set dungeon completion reward to "+xp+" XP and "+coins+" coins.");});}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){refused("owner_refused");sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();observedSave(sender,"administrator_native_boss",()->directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch())).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){observedSave(sender,"administrator_native_boss_clear",()->directory.clearBoss(dungeon,room.id())).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var sheetIdentity=sheets.identity(held).orElse(null);var character=sheetIdentity==null?null:sheetIdentity.character();if(character==null||held.getAmount()!=1){refused("owner_refused");sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){refused("owner_refused");sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){refused("owner_refused");sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){refused("owner_refused");sender.sendMessage("That character must leave its party before boss assignment.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var adminAction=adminAudit==null?null:adminAudit.current();characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){adminResult(adminAction,failure!=null?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,failure!=null?"operation_failed":"owner_refused",null,null);respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();if(!sheetIdentity.generation().equals(expected.sheetGeneration())||(sheetIdentity.legacy()&&expected.sheetGeneration().signum()!=0)){adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);respond(sender,()->sender.sendMessage("Character boss assignment refused: that sheet generation is no longer authorized."));return;}var assignment=directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier);assignment.whenComplete((custody,saveFailure)->{if(saveFailure!=null){savedResult(adminAction,saveFailure,null,null);respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.custodySaved(audit.assignment(request,publication,dungeon,room.id(),custody)));}catch(RuntimeException ignored){/* Saved custody observation never gates native revalidation or consumption. */}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"saved_character_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change character/custody authority. */}adminResult(adminAction,rollbackFailure==null&&Boolean.TRUE.equals(applied)?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,rollbackFailure==null&&Boolean.TRUE.equals(applied)?"owner_refused":"operation_uncertain",null,null);respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing."));});return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||!sheets.authorizes(current,expected)){var rollback=directory.cancelAssignment(dungeon,custody.operation());rollback.whenComplete((applied,rollbackFailure)->{if(audit!=null)try{DungeonDirectory.publication(assignment).ifPresent(publication->audit.rolledBack(audit.assignment(request,publication,dungeon,room.id(),custody),rollbackFailure==null&&Boolean.TRUE.equals(applied),DungeonDirectory.publication(rollback).orElse(null),"held_sheet_changed"));}catch(RuntimeException ignored){/* Original rollback observation cannot change inventory/custody authority. */}adminResult(adminAction,rollbackFailure==null&&Boolean.TRUE.equals(applied)?games.dmg.fantasystick.events.AdminActionEventBridgeLine truncated
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){if(!(sender instanceof Player))refused("sender_unsupported");usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){refused("owner_refused");sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){refused("owner_refused");sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=authorizedPhysical(custody.character());if(ambiguousPhysical(physical,player)){refused("owner_refused");sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty();if(!delivered&&player.getInventory().firstEmpty()<0){refused("owner_refused");sender.sendMessage("One empty inventory slot is required before recovery.");return;}var audit=characterBossEvents;var request=audit==null?null:audit.request(sender);var adminAction=adminAudit==null?null:adminAudit.current();var preparation=directory.prepareRecovery(dungeon,player.getUniqueId());preparation.whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){savedResult(adminAction,failure,null,null);sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();var currentPhysical=authorizedPhysical(prepared.character());if(ambiguousPhysical(currentPhysical,player)){adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous; prepared custody is retained.");return;}org.bukkit.inventory.ItemStack sheet=currentPhysical.isEmpty()?null:currentPhysical.getFirst().getValue();if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);sheet=player.getInventory().getItem(slot);}if(!sheets.authorizes(sheet,prepared.character())){adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);sender.sendMessage("Character boss recovery is prepared, but authorized native sheet delivery could not be verified; custody is retained.");return;}var finalPhysical=authorizedPhysical(prepared.character());if(finalPhysical.size()!=1||ambiguousPhysical(finalPhysical,player)){adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"owner_refused",null,null);sender.sendMessage("Recovery refused because native physical-sheet delivery is ambiguous; prepared custody is retained.");return;}var item=finalPhysical.getFirst().getValue();games.dmg.fantasystick.events.CharacterBossEventBridge.Facts deliveryFacts=null;if(audit!=null)try{var view=DungeonDirectory.publication(preparation).flatMap(publication->publication.after().dungeons().stream().filter(value->value.id().equals(dungeon)).findFirst()).orElse(definition);var originalRoom=view.rooms().stream().filter(value->value.boss()!=null&&value.boss().character()!=null&&value.boss().character().operation().equals(prepared.operation())).findFirst().orElseThrow();deliveryFacts=audit.custody(request,view,originalRoom.id(),prepared);}catch(RuntimeException ignored){/* Delivery does not depend on optional metadata. */}var facts=deliveryFacts;var completion=directory.completeRecovery(dungeon,operation);completion.whenComplete((completed,completeFailure)->{if(completeFailure==null&&Boolean.TRUE.equals(completed)&&facts!=null)try{DungeonDirectory.publication(completion).ifPresent(publication->audit.recovered(facts,operation,publication,"exact_authorized_sheet_delivered"));}catch(RuntimeException ignored){/* Durable completion stays authoritative independently of feedback. */}if(completeFailure==null&&Boolean.TRUE.equals(completed)){if(facts!=null&&DungeonDirectory.publication(completion).isPresent())bossResult(adminAction,operation,"dungeon.character_boss.recovered");else adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"acknowledged",operation,null);}else adminResult(adminAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",null,null);if(facts!=null&&(compLine truncated
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){refused("sender_unsupported");sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){refused("not_found");sender.sendMessage("Dungeon not found.");return;}var action=adminAudit==null?null:adminAudit.current();teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target),outcome->adminResult(action,outcome,outcome==games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED?"acknowledged":outcome==games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED?"operation_failed":outcome==games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN?"operation_uncertain":"owner_refused",null,null));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}observedSave(sender,"administrator_enable",()->controller.enable(id)).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate completion reward, rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.disableWithEvidence(id,publication->{if(request.events()!=null)try{request.events().definitionSaved(request.operation(),request.actor(),publication,"administrator_disable_saved");}catch(RuntimeException ignored){}}).whenComplete((value,failure)->{respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value.applied()?"Disabled and closed dungeon "+id+".":"Dungeon not found."));});}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);controller.openWithEvidence(id).whenComplete((value,failure)->{if(failure==null&&value.publication().changed()&&request.events()!=null)try{request.events().offer("dungeon.offer.opened",request.operation(),request.actor(),value.dungeon(),value.publication().after(),"administrator_open");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.publication().after().revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached."));});}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var request=request(sender);var definition=directory.find(id).orElse(null);var catalog=adventures.snapshot().catalog();controller.closeOffer(id).whenComplete((value,failure)->{if(failure==null&&Boolean.TRUE.equals(value)&&definition!=null&&catalog!=null&&request.events()!=null)try{request.events().offer("dungeon.offer.closed",request.operation(),request.actor(),definition,catalog,"administrator_close");}catch(RuntimeException ignored){}respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open."));});}
|
||||
private record AuditRequest(games.dmg.fantasystick.events.DungeonAuditEventBridge events,games.dmg.fantasystick.events.CampEventBridge.Actor actor,UUID operation){}
|
||||
private AuditRequest request(CommandSender sender){var events=ownerEvents;return new AuditRequest(events,events==null?null:events.actor(sender),UUID.randomUUID());}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}confirm();var request=request(sender);controller.disableWithEvidence(id,publication->{if(request.events()!=null)try{request.events().definitionSaved(request.operation(),request.actor(),publication,"administrator_disable_saved");}catch(RuntimeException ignored){}}).whenComplete((value,failure)->{if(failure!=null)savedResult(request.action(),failure,null,null);else if(!value.applied())refusedSave(request.action());else{var publication=value.publication();boolean changed=publication!=null&&publication.before().dungeons().stream().anyMatch(before->before.id().equals(id)&&before.enabled())&&publication.after().dungeons().stream().anyMatch(after->after.id().equals(id)&&!after.enabled());savedResult(request.action(),null,request.operation(),changed?"dungeon.disabled":null);}respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value.applied()?"Disabled and closed dungeon "+id+".":"Dungeon not found."));});}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.find(id).isEmpty()){refused("not_found");sender.sendMessage("Dungeon command refused: Dungeon is not enabled.");return;}var request=request(sender);controller.openWithEvidence(id).whenComplete((value,failure)->{if(failure==null&&value.publication().changed()&&request.events()!=null)try{request.events().offer("dungeon.offer.opened",request.operation(),request.actor(),value.dungeon(),value.publication().after(),"administrator_open");}catch(RuntimeException ignored){}if(failure!=null){Throwable cause=failure;while(cause instanceof java.util.concurrent.CompletionException&&cause.getCause()!=null)cause=cause.getCause();adminResult(request.action(),cause instanceof java.io.IOException?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,cause instanceof java.io.IOException?"operation_failed":"owner_refused",null,null);}else if(!value.publication().changed())adminResult(request.action(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"no_change",null,null);else savedResult(request.action(),null,request.operation(),"dungeon.offer.opened");respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.publication().after().revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached."));});}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}confirm();var request=request(sender);var definition=directory.find(id).orElse(null);var catalog=adventures.snapshot().catalog();controller.closeOffer(id).whenComplete((value,failure)->{if(failure==null&&Boolean.TRUE.equals(value)&&definition!=null&&catalog!=null&&request.events()!=null)try{request.events().offer("dungeon.offer.closed",request.operation(),request.actor(),definition,catalog,"administrator_close");}catch(RuntimeException ignored){}if(failure!=null)savedResult(request.action(),failure,null,null);else if(Boolean.TRUE.equals(value))savedResult(request.action(),null,request.operation(),definition!=null&&catalog!=null?"dungeon.offer.closed":null);else refusedSave(request.action());respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open."));});}
|
||||
private record AuditRequest(games.dmg.fantasystick.events.DungeonAuditEventBridge events,games.dmg.fantasystick.events.CampEventBridge.Actor actor,UUID operation,games.dmg.fantasystick.events.AdminActionEventBridge.Action action){}
|
||||
private AuditRequest request(CommandSender sender){var events=ownerEvents;return new AuditRequest(events,events==null?null:events.actor(sender),UUID.randomUUID(),adminAudit==null?null:adminAudit.current());}
|
||||
private <T>java.util.concurrent.CompletionStage<T> observedSave(CommandSender sender,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){return observedSave(request(sender),reason,work);}
|
||||
private <T>java.util.concurrent.CompletionStage<T> observedSave(AuditRequest request,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){var source=work.get();return source.whenComplete((value,failure)->{if(failure==null&&request.events()!=null)try{DungeonDirectory.publication(source).ifPresent(publication->request.events().definitionSaved(request.operation(),request.actor(),publication,reason));}catch(RuntimeException ignored){/* Audit is never authoring authority. */}});}
|
||||
private <T>java.util.concurrent.CompletionStage<T> observedSave(AuditRequest request,String reason,java.util.function.Supplier<? extends java.util.concurrent.CompletionStage<T>> work){
|
||||
java.util.concurrent.CompletionStage<T> source;
|
||||
try{source=work.get();}catch(RuntimeException failure){savedResult(request.action(),failure,null,null);throw failure;}
|
||||
return source.whenComplete((value,failure)->{
|
||||
if(failure!=null){savedResult(request.action(),failure,null,null);return;}
|
||||
var publication=DungeonDirectory.publication(source).orElse(null);
|
||||
if(publication==null||publication.before().equals(publication.after())||value instanceof Optional<?> optional&&optional.isEmpty()||value instanceof Boolean applied&&!applied){refusedSave(request.action());return;}
|
||||
var types=definitionTypes(publication);
|
||||
if(request.events()!=null)try{request.events().definitionSaved(request.operation(),request.actor(),publication,reason);}catch(RuntimeException ignored){/* Audit is never authoring authority. */}
|
||||
if(types.size()==1)savedResult(request.action(),null,request.operation(),types.getFirst());
|
||||
else adminResult(request.action(),games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"acknowledged",request.operation(),null);
|
||||
});
|
||||
}
|
||||
private void refusedSave(games.dmg.fantasystick.events.AdminActionEventBridge.Action action){adminResult(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,"no_change",null,null);}
|
||||
private static List<String> definitionTypes(DungeonDirectory.Publication publication){
|
||||
var before=publication.before().dungeons();var after=publication.after().dungeons();var types=new ArrayList<String>();
|
||||
for(var prior:before){var current=after.stream().filter(value->value.id().equals(prior.id())).findFirst().orElse(null);
|
||||
if(current==null){types.add("dungeon.removed");continue;}if(prior.equals(current))continue;
|
||||
if(!prior.entrance().equals(current.entrance()))types.add("dungeon.entrance_changed");
|
||||
if(prior.enabled()!=current.enabled())types.add(current.enabled()?"dungeon.enabled":"dungeon.disabled");
|
||||
for(var room:prior.rooms())if(current.rooms().stream().noneMatch(value->value.id().equals(room.id())))types.add("dungeon.room.removed");
|
||||
for(var room:current.rooms()){var old=prior.rooms().stream().filter(value->value.id().equals(room.id())).findFirst().orElse(null);
|
||||
if(old==null){types.add("dungeon.room.created");continue;}
|
||||
for(var slot:room.mobs())if(old.mobs().stream().noneMatch(value->value.id().equals(slot.id())))types.add("dungeon.mob_slot.added");
|
||||
for(var slot:old.mobs())if(room.mobs().stream().noneMatch(value->value.id().equals(slot.id())))types.add("dungeon.mob_slot.removed");
|
||||
for(var effect:room.effects())if(old.effects().stream().noneMatch(value->value.id().equals(effect.id())))types.add("dungeon.effect.added");
|
||||
for(var effect:old.effects())if(room.effects().stream().noneMatch(value->value.id().equals(effect.id())))types.add("dungeon.effect.removed");
|
||||
if(!Objects.equals(old.boss(),room.boss()))types.add(room.boss()==null?"dungeon.boss.cleared":"dungeon.boss.configured");
|
||||
}
|
||||
}
|
||||
return types;
|
||||
}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","mode","open","remove","reward","room","teleport"):List.of("close","disable","enable","list","mode","open","remove","reward"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&args[1].equals("create"))return matching(List.of("--mode"),args[2]);if(args.length==4&&args[1].equals("create")&&args[2].equals("--mode"))return matching(modeValues(),args[3]);if(args.length==3&&Set.of("close","disable","enable","mode","open","remove","reward","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&args[1].equals("mode"))return matching(modeValues(),args[3]);if((args.length==4||args.length==5)&&args[1].equals("reward")){UUID dungeon=id(args[2]);var current=dungeon==null?null:directory.find(dungeon).orElse(null);if(current==null)return List.of();var values=new ArrayList<>(List.of("0","10","100","2147483647"));values.add((args.length==4?current.completionReward().xp():current.completionReward().coins()).toString());return matching(values,args[args.length-1]);}if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
private List<Map.Entry<Player,org.bukkit.inventory.ItemStack>> authorizedPhysical(games.dmg.fantasystick.persistence.CharacterRecord character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.authorizes(item,character)).map(item->Map.entry(holder,item))).toList();}
|
||||
private static boolean ambiguousPhysical(List<Map.Entry<Player,org.bukkit.inventory.ItemStack>> physical,Player player){return physical.stream().mapToLong(entry->entry.getValue().getAmount()).sum()>1||physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player;}
|
||||
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
|
||||
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
|
||||
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
|
||||
private DungeonState.Room standing(CommandSender sender,Player player){var result=controller.standingRoom(player).orElse(null);if(result==null)sender.sendMessage("Stand inside exactly one room of the dungeon being edited.");return result;}
|
||||
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null){refused("owner_refused");player.sendMessage("Start editing one dungeon first.");}return result;}
|
||||
private DungeonState.Room standing(CommandSender sender,Player player){var result=controller.standingRoom(player).orElse(null);if(result==null){refused("owner_refused");sender.sendMessage("Stand inside exactly one room of the dungeon being edited.");}return result;}
|
||||
private void saved(CommandSender sender,Throwable failure,String success){respond(sender,()->sender.sendMessage(failure==null?success:"Dungeon edit failed; desired state is unchanged."));}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
@@ -69,5 +120,5 @@ public final class DungeonCommands {
|
||||
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static List<String> modeValues(){return Arrays.stream(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode.values()).map(games.dmg.fantasystick.adventure.AdventureQuestState.ParticipationMode::value).toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|reward|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
private void usage(CommandSender sender){refused("invalid_arguments");sender.sendMessage("Usage: /fstickadmin dungeon <create [--mode <mode>] <name...>|mode <dungeon-uuid> [<mode>]|list|remove|edit|entrance|reward|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
}
|
||||
@@ -54,7 +54,10 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private record PendingKill(UUID entity,UUID dungeon,UUID room,UUID generation,UUID slot,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target,defeatedBoss;games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation,deathObservation;games.dmg.fantasystick.events.CharacterBossEventBridge.Facts characterDeathObservation;Map<String,String> defeatAudit=Map.of();long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private record OwnedEffect(UUID dungeon,UUID definition,UUID player,PotionEffectType type,PotionEffect applied,PotionEffect prior,Integer priorTick,Integer appliedTick,games.dmg.fantasystick.events.DungeonAuditEventBridge.RuntimeFacts observation){}
|
||||
private record PlayerRoomEffect(UUID dungeon,DungeonState.Room room,Player player,DungeonState.Effect definition,PotionEffectType type){}
|
||||
private record RoomPotion(LivingEntity target,PotionEffectType type,List<EntityPotionEffectEvent> events){}
|
||||
private final ThreadLocal<Deque<RoomPotion>> roomPotions=ThreadLocal.withInitial(ArrayDeque::new);
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;private final games.dmg.fantasystick.presentation.ParticleDelivery particles;private FishingController fishing;private games.dmg.fantasystick.events.DungeonEventBridge auditEvents;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Map<UUID,PendingKill> pendingKills=new ConcurrentHashMap<>();private final Map<UUID,UUID> pendingBosses=new ConcurrentHashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
@@ -107,7 +110,7 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private CompletionStage<Void> cleanupOnMain(UUID dungeon){var result=new CompletableFuture<Void>();if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return result;}try{nextTick.accept(()->{if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return;}cleanupDungeon(dungeon);synchronized(this){var editor=dungeonEditors.remove(dungeon);if(editor!=null)playerEdits.remove(editor);}result.complete(null);});}catch(RuntimeException failure){result.completeExceptionally(failure);}return result;}
|
||||
public void definitionChanged(UUID dungeon){synchronized(this){var editor=dungeonEditors.get(dungeon);if(editor==null)return;var edit=playerEdits.get(editor);if(edit==null)return;for(var room:dungeons.find(dungeon).map(DungeonState.Dungeon::rooms).orElse(List.of()))edit.colors().computeIfAbsent(room.id(),ignored->randomColor());}}
|
||||
private void tick(){if(closed)return;if(++ticks%10==0)showBorders();runtime();}
|
||||
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key)){var observation=facts(dungeon,null,catalog.revision(),null,Map.of("reason","offered_runtime_initialized"));var source=dungeons.run(dungeonId,catalog.revision());source.whenComplete((ignored,failure)->{if(failure==null&&DungeonDirectory.publication(source).isPresent())observe("dungeon.run.started",observation,Map.of("run_status","persisted_undefeated"));schedule(()->startingRuns.remove(key));});}continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key,"no_eligible_player_in_room");continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->{var created=new Attempt(dungeonId,room.id(),catalog.revision());var trigger=players.getFirst();created.observation=facts(dungeon,room,catalog.revision(),trigger,questData(eligible(trigger,dungeonId)));observe("dungeon.room.activated",created.observation,Map.of("attempt_capture_quality","native_transient_not_persisted","reason","eligible_room_entry"));return created;});contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
|
||||
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var desiredEffects=desiredPlayerEffects(activeDungeonIds,catalog.revision());var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key)){var observation=facts(dungeon,null,catalog.revision(),null,Map.of("reason","offered_runtime_initialized"));var source=dungeons.run(dungeonId,catalog.revision());source.whenComplete((ignored,failure)->{if(failure==null&&DungeonDirectory.publication(source).isPresent())observe("dungeon.run.started",observation,Map.of("run_status","persisted_undefeated"));schedule(()->startingRuns.remove(key));});}continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key,"no_eligible_player_in_room");continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects,desiredEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->{var created=new Attempt(dungeonId,room.id(),catalog.revision());var trigger=players.getFirst();created.observation=facts(dungeon,room,catalog.revision(),trigger,questData(eligible(trigger,dungeonId)));observe("dungeon.room.activated",created.observation,Map.of("attempt_capture_quality","native_transient_not_persisted","reason","eligible_room_entry"));return created;});contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return party==null?new Context(record.id(),null,Set.of(record.id())):new Context(record.id(),party.id(),Set.copyOf(party.characters()));}
|
||||
private AdventureQuestState.Quest eligibleQuest(Player player){var context=context(player);if(context==null)return null;var quest=adventures.activeFor(context.character(),context.party()).orElse(null);return quest!=null&&quest.snapshot().contains(context.character())?quest:null;}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID dungeon){var quest=eligibleQuest(player);return quest!=null&&quest.site().equals(dungeon)?quest:null;}
|
||||
@@ -162,10 +165,204 @@ public final class DungeonController implements Listener,AutoCloseable,games.dmg
|
||||
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&¢er.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
|
||||
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
|
||||
private final Set<String> observedEffectKeys=new HashSet<>();
|
||||
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&¤t.getAmplifier()>amplifier)continue;var owned=ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current,effectFacts(dungeon,room,player,effect)));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);if(ownerEvents!=null)try{var applied=effect(player,type);if(applied!=null&&applied.getAmplifier()==amplifier&&observedEffectKeys.add(key))observe("effect.applied",owned.observation(),Map.of("application_status","native_layer_confirmed","reason","eligible_room_effect"));}catch(RuntimeException unavailable){/* This optional confirmation cannot interrupt native application or room runtime. */}}}}
|
||||
private Map<String,PlayerRoomEffect> desiredPlayerEffects(Set<UUID> activeDungeons,UUID generation){
|
||||
var desired=new LinkedHashMap<String,PlayerRoomEffect>();
|
||||
for(var dungeonId:activeDungeons){
|
||||
if(defeatingDungeons.contains(dungeonId))continue;
|
||||
var dungeon=dungeons.find(dungeonId).orElse(null);var run=dungeons.runView(dungeonId,generation).orElse(null);
|
||||
if(dungeon==null||run==null||run.defeated())continue;
|
||||
for(var room:dungeon.rooms())for(var player:eligibleInside(dungeon,room).toList())
|
||||
for(var definition:room.effects())if(definition.target()!=DungeonState.EffectTarget.MOBS){
|
||||
var type=effectType(definition.effect());if(type==null)continue;
|
||||
String key=player.getUniqueId()+":"+type.getKey();var previous=desired.get(key);
|
||||
// Native dominance: strongest level wins; the last equal-level definition supplies flags.
|
||||
if(previous==null||definition.level()>=previous.definition().level())
|
||||
desired.put(key,new PlayerRoomEffect(dungeonId,room,player,definition,type));
|
||||
}
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active,Map<String,PlayerRoomEffect> desired){
|
||||
for(var entry:desired.entrySet()){
|
||||
var selected=entry.getValue();
|
||||
if(!selected.dungeon().equals(dungeon)||!selected.room().id().equals(room.id())||!players.contains(selected.player()))continue;
|
||||
var definition=selected.definition();var type=selected.type();var player=selected.player();
|
||||
String key=entry.getKey();active.add(key);
|
||||
var current=effect(player,type);var previous=ownedEffects.get(key);
|
||||
if(previous!=null&&!sameOwnedLayer(current,previous,player)){
|
||||
releaseRoomOwnership(key,previous,"unrelated_current_layer_preserved");previous=null;
|
||||
}
|
||||
int amplifier=definition.level()-1;
|
||||
if(previous!=null&&!previous.definition().equals(definition.id())&¤t.getAmplifier()>amplifier){
|
||||
restoreOwned(key,previous);
|
||||
if(ownedEffects.containsKey(key))continue; // Cancelled native removal retains its owned layer.
|
||||
previous=null;current=effect(player,type);
|
||||
}
|
||||
if(current!=null&¤t.getAmplifier()>amplifier)continue;
|
||||
Integer priorTick=previous==null?nativeAge(player):previous.priorTick();
|
||||
var requested=new PotionEffect(type,30,amplifier,false,definition.particles());
|
||||
var call=new RoomPotion(player,type,new ArrayList<>());
|
||||
boolean returned;
|
||||
roomPotions.get().push(call);
|
||||
try{returned=player.addPotionEffect(requested,true);}finally{finishRoomPotion(call);}
|
||||
// Actual native return/final cancellation decision, never an equal-amplifier prior.
|
||||
if(!returned||call.events().stream().anyMatch(event->event.isCancelled()
|
||||
||event.getNewEffect()==null||!requested.equals(event.getNewEffect())
|
||||
||event.getAction()==EntityPotionEffectEvent.Action.CHANGED&&!event.isOverride()))continue;
|
||||
PotionEffect signature=roomSignature(requested,current);boolean verified=false;
|
||||
try{
|
||||
var applied=effect(player,type);
|
||||
if(!confirmedRoomLayer(applied,requested,current))continue;
|
||||
signature=applied;verified=true;
|
||||
}catch(RuntimeException unavailable){
|
||||
// A successful native call is still authoritative. Optional post-call metadata
|
||||
// cannot undo it; cleanup requires its exact signature before any mutation.
|
||||
}
|
||||
boolean changed=previous!=null&&!previous.definition().equals(definition.id());
|
||||
if(changed&&observedEffectKeys.remove(key))observe("effect.ended",previous.observation(),
|
||||
Map.of("reason","owned_room_layer_released","cleanup_status","replaced_by_composed_room_layer"));
|
||||
var owned=new OwnedEffect(dungeon,definition.id(),player.getUniqueId(),type,signature,
|
||||
previous==null?current:previous.prior(),priorTick,nativeAge(player),
|
||||
previous==null||changed?effectFacts(dungeon,room,player,definition):previous.observation());
|
||||
ownedEffects.put(key,owned);
|
||||
if(verified&&ownerEvents!=null&&observedEffectKeys.add(key))
|
||||
observe("effect.applied",owned.observation(),Map.of("application_status","native_layer_confirmed","reason","eligible_room_effect"));
|
||||
}
|
||||
}
|
||||
private static Integer nativeAge(Player player){try{return player.getTicksLived();}catch(RuntimeException unavailable){return null;}}
|
||||
private static PotionEffect remainingPrior(PotionEffect prior,long elapsed){
|
||||
if(prior==null)return null;
|
||||
var hidden=remainingPrior(prior.getHiddenPotionEffect(),elapsed);
|
||||
if(!prior.isInfinite()&&prior.getDuration()<=elapsed)return hidden;
|
||||
int duration=prior.isInfinite()?PotionEffect.INFINITE_DURATION:(int)(prior.getDuration()-elapsed);
|
||||
return new PotionEffect(prior.getType(),duration,prior.getAmplifier(),prior.isAmbient(),prior.hasParticles(),prior.hasIcon(),hidden);
|
||||
}
|
||||
private void cleanupOwnedEffects(Set<String> active){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(!active.contains(entry.getKey()))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void cleanupOwnedDungeon(UUID dungeon){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(entry.getValue().dungeon().equals(dungeon))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);boolean observed=observedEffectKeys.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null){if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","player_unavailable"));return;}var current=effect(player,owned.type());if(current!=null&¤t.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","native_restore_returned","prior_layer_status",owned.prior()==null?"absent":"restored_request"));}else if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released","cleanup_status","unrelated_current_layer_preserved"));}
|
||||
private void restoreOwned(String key,OwnedEffect owned){
|
||||
var player=plugin.getServer().getPlayer(owned.player());
|
||||
if(player==null){releaseRoomOwnership(key,owned,"player_unavailable");return;}
|
||||
var current=effect(player,owned.type());
|
||||
if(!sameOwnedLayer(current,owned,player)){
|
||||
releaseRoomOwnership(key,owned,"unrelated_current_layer_preserved");return;
|
||||
}
|
||||
var call=new RoomPotion(player,owned.type(),new ArrayList<>());
|
||||
roomPotions.get().push(call);
|
||||
try{player.removePotionEffect(owned.type());}finally{finishRoomPotion(call);}
|
||||
var remaining=effect(player,owned.type());
|
||||
if(sameOwnedLayer(remaining,owned,player)){
|
||||
// A cancelled native removal cannot justify restoring a prior or asserting an end.
|
||||
// Cancellation retains the lease; only confirmed removal may emit the supported effect.ended.
|
||||
return;
|
||||
}
|
||||
if(remaining!=null){releaseRoomOwnership(key,owned,"unrelated_current_layer_preserved");return;}
|
||||
String priorStatus=owned.prior()==null?"absent":"expired";
|
||||
var now=nativeAge(player);PotionEffect prior=null;
|
||||
if(owned.prior()!=null){
|
||||
if(now!=null&&owned.priorTick()!=null)
|
||||
prior=remainingPrior(owned.prior(),Integer.toUnsignedLong(now-owned.priorTick()));
|
||||
else priorStatus="remaining_lifetime_unavailable"; // Never invent a remaining native budget.
|
||||
}
|
||||
if(prior!=null){
|
||||
boolean accepted=restorePriorChain(player,prior);
|
||||
priorStatus=accepted&&sameRoomLayer(effect(player,owned.type()),prior)?"restored_request":"restore_unconfirmed";
|
||||
}
|
||||
ownedEffects.remove(key);boolean observed=observedEffectKeys.remove(key);
|
||||
if(observed)observe("effect.ended",owned.observation(),Map.of("reason","owned_room_layer_released",
|
||||
"cleanup_status","native_restore_returned","prior_layer_status",priorStatus));
|
||||
}
|
||||
private boolean restorePriorChain(Player player,PotionEffect prior){
|
||||
var layers=new ArrayList<PotionEffect>();
|
||||
for(var layer=prior;layer!=null;layer=layer.getHiddenPotionEffect())layers.add(layer);
|
||||
// Bukkit input conversion drops hidden nodes: let actual native merges rebuild deepest first.
|
||||
for(int index=layers.size()-1;index>=0;index--){
|
||||
var layer=layers.get(index);
|
||||
var requested=new PotionEffect(layer.getType(),layer.getDuration(),layer.getAmplifier(),layer.isAmbient(),layer.hasParticles(),layer.hasIcon());
|
||||
if(index<layers.size()-1){
|
||||
var lower=layers.get(index+1);
|
||||
if(requested.isInfinite()||!lower.isInfinite()&&requested.getDuration()>=lower.getDuration()){
|
||||
// A longer/infinite stronger addition would discard lower state. Hide it with a
|
||||
// short visible-only seed first, then extend without resetting any lower budget.
|
||||
int seedDuration=lower.isInfinite()?1:Math.min(1,lower.getDuration()-1);
|
||||
if(!restorePriorLayer(player,requested.withDuration(seedDuration)))return false;
|
||||
}
|
||||
}
|
||||
if(!restorePriorLayer(player,requested))return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private boolean restorePriorLayer(Player player,PotionEffect requested){
|
||||
var call=new RoomPotion(player,requested.getType(),new ArrayList<>());boolean accepted;
|
||||
roomPotions.get().push(call);
|
||||
try{accepted=player.addPotionEffect(requested,true);}finally{finishRoomPotion(call);}
|
||||
return accepted&&call.events().stream().noneMatch(event->event.isCancelled()||event.getNewEffect()==null
|
||||
||!requested.equals(event.getNewEffect())
|
||||
||event.getAction()==EntityPotionEffectEvent.Action.CHANGED&&!event.isOverride());
|
||||
}
|
||||
private void finishRoomPotion(RoomPotion call){
|
||||
var stack=roomPotions.get();stack.remove(call);if(stack.isEmpty())roomPotions.remove();
|
||||
}
|
||||
private static PotionEffect roomSignature(PotionEffect requested,PotionEffect prior){
|
||||
int duration=requested.getDuration();var hidden=prior==null?null:prior.getHiddenPotionEffect();
|
||||
if(prior!=null){
|
||||
if(prior.getAmplifier()==requested.getAmplifier())
|
||||
duration=prior.isInfinite()?PotionEffect.INFINITE_DURATION:Math.max(prior.getDuration(),duration);
|
||||
else if(requested.getAmplifier()>prior.getAmplifier()&&!requested.isInfinite()
|
||||
&&(prior.isInfinite()||requested.getDuration()<prior.getDuration()))hidden=prior;
|
||||
}
|
||||
// Only the hidden state dictated by the captured prior/native merge is admissible.
|
||||
return new PotionEffect(requested.getType(),duration,requested.getAmplifier(),requested.isAmbient(),requested.hasParticles(),requested.hasIcon(),hidden);
|
||||
}
|
||||
private static boolean confirmedRoomLayer(PotionEffect applied,PotionEffect requested,PotionEffect prior){
|
||||
return sameRoomLayer(applied,roomSignature(requested,prior));
|
||||
}
|
||||
private static boolean sameOwnedLayer(PotionEffect current,OwnedEffect owned,Player player){
|
||||
var signature=owned.applied();
|
||||
if(signature.getHiddenPotionEffect()==null)return sameRoomLayer(current,signature);
|
||||
var now=nativeAge(player);
|
||||
if(now==null||owned.appliedTick()==null)return false;
|
||||
long elapsed=Integer.toUnsignedLong(now-owned.appliedTick());
|
||||
if(!signature.isInfinite()&&signature.getDuration()<=elapsed)return false; // A promoted prior is never our visible layer.
|
||||
return sameRoomLayer(current,decayedNativeSignature(signature,elapsed));
|
||||
}
|
||||
private static PotionEffect decayedNativeSignature(PotionEffect signature,long elapsed){
|
||||
if(signature==null)return null;
|
||||
int duration=signature.isInfinite()?PotionEffect.INFINITE_DURATION:(int)Math.max(0,signature.getDuration()-elapsed);
|
||||
// Native hidden nodes remain structurally present at zero until the visible layer downgrades.
|
||||
return new PotionEffect(signature.getType(),duration,signature.getAmplifier(),signature.isAmbient(),
|
||||
signature.hasParticles(),signature.hasIcon(),decayedNativeSignature(signature.getHiddenPotionEffect(),elapsed));
|
||||
}
|
||||
private static boolean sameRoomLayer(PotionEffect current,PotionEffect signature){
|
||||
if(current==null)return false;
|
||||
boolean duration=signature.isInfinite()?current.isInfinite()
|
||||
:current.getDuration()>0&¤t.getDuration()<=signature.getDuration();
|
||||
// withDuration drops Paper's hidden chain; explicitly retain every matching field/layer.
|
||||
return duration&&new PotionEffect(signature.getType(),current.getDuration(),signature.getAmplifier(),
|
||||
signature.isAmbient(),signature.hasParticles(),signature.hasIcon(),signature.getHiddenPotionEffect()).equals(current);
|
||||
}
|
||||
private void releaseRoomOwnership(String key,OwnedEffect owned,String status){
|
||||
ownedEffects.remove(key);
|
||||
if(observedEffectKeys.remove(key))observe("effect.ended",owned.observation(),
|
||||
Map.of("reason","owned_room_layer_released","cleanup_status",status));
|
||||
}
|
||||
@EventHandler(priority=EventPriority.MONITOR)
|
||||
public void roomPotionChanged(EntityPotionEffectEvent event){
|
||||
var call=roomPotions.get().peek();
|
||||
if(call!=null&&call.target()==event.getEntity()&&call.type().equals(event.getModifiedType())){
|
||||
call.events().add(event);return;
|
||||
}
|
||||
if(call==null)roomPotions.remove();
|
||||
if(event.isCancelled()||!(event.getEntity() instanceof Player player)
|
||||
||event.getAction()==EntityPotionEffectEvent.Action.CHANGED&&!event.isOverride()
|
||||
||event.getAction()==EntityPotionEffectEvent.Action.ADDED)return;
|
||||
// Accepted unscoped native changes revoke the lease even if every visible field is equal.
|
||||
for(var entry:new ArrayList<>(ownedEffects.entrySet())){
|
||||
var owned=entry.getValue();
|
||||
if(owned.player().equals(player.getUniqueId())&&owned.type().equals(event.getModifiedType()))
|
||||
releaseRoomOwnership(entry.getKey(),owned,"unrelated_current_layer_preserved");
|
||||
}
|
||||
}
|
||||
|
||||
private static PotionEffect effect(LivingEntity entity,PotionEffectType type){return entity.getActivePotionEffects().stream().filter(value->value.getType().equals(type)).findFirst().orElse(null);}
|
||||
private static PotionEffectType effectType(String key){var value=NamespacedKey.fromString(key);return value==null?null:PotionEffectType.getByKey(value);}
|
||||
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
|
||||
@@ -33,14 +33,22 @@ public final class AbilityConfigEventBridge {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String abilityId, String key, String value) {
|
||||
emit("config.read", actor, abilityId, key, null, value);
|
||||
read(UUID.randomUUID(), actor, abilityId, key, value);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String abilityId, String key, String value) {
|
||||
emit(operation, "config.read", actor, abilityId, key, null, value);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String abilityId, String key, String before, String after) {
|
||||
emit("config.changed", actor, abilityId, key, before, after);
|
||||
changed(UUID.randomUUID(), actor, abilityId, key, before, after);
|
||||
}
|
||||
|
||||
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
|
||||
public void changed(UUID operation, Actor actor, String abilityId, String key, String before, String after) {
|
||||
emit(operation, "config.changed", actor, abilityId, key, before, after);
|
||||
}
|
||||
|
||||
private void emit(UUID operation, String type, Actor actor, String abilityId, String key, String before, String after) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", actor.kind());
|
||||
@@ -50,12 +58,11 @@ public final class AbilityConfigEventBridge {
|
||||
data.put("after", after);
|
||||
data.put("application", "future_casts_only");
|
||||
data.put("outcome", "acknowledged");
|
||||
var action = UUID.randomUUID();
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + operation + ":" + type)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
var context = actor.playerId() == null ? null
|
||||
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
|
||||
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
|
||||
sink.accept(new EventInput(id, operation, clock.instant(), type, context, data));
|
||||
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** One bounded read outcome per captured administrator action; never serializes book/record contents. */
|
||||
public final class ActiveInspectionEventBridge {
|
||||
public static final class Request {
|
||||
private final AdminActionEventBridge.Action action;
|
||||
private final UUID target;
|
||||
private final int offset;
|
||||
private final String entryPoint;
|
||||
private final AtomicBoolean resolved = new AtomicBoolean();
|
||||
private Request(AdminActionEventBridge.Action action, UUID target, int offset, String entryPoint) {
|
||||
this.action = action;
|
||||
this.target = action.authorized() ? target : null;
|
||||
this.offset = offset;
|
||||
this.entryPoint = entryPoint;
|
||||
}
|
||||
public UUID eventId() {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:active-inspection:" + action.id())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
public ActiveInspectionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
public Request request(AdminActionEventBridge.Action action, UUID target, int offset, String entryPoint) {
|
||||
if (action == null) return null;
|
||||
if (offset < 0 || !(entryPoint.equals("command") || entryPoint.equals("book_navigation")))
|
||||
throw new IllegalArgumentException("Invalid inspection metadata");
|
||||
return new Request(action, target, offset, entryPoint);
|
||||
}
|
||||
|
||||
/** Protected resolved character metadata is supplied only after native open and session revalidation. */
|
||||
public void resolved(Request request, String outcome, String reason, CharacterRecord record,
|
||||
EquipmentSessions.Active active, int pages, int volumes) {
|
||||
if (request == null || !request.resolved.compareAndSet(false, true)) return;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
|
||||
data.put("route", "character.view");
|
||||
data.put("actor_kind", request.action.actorKind());
|
||||
data.put("authorization", denied ? "denied" : "authorized");
|
||||
data.put("entry_point", request.entryPoint);
|
||||
data.put("requested_offset", request.offset);
|
||||
data.put("requested_volume", request.offset / 30 + 1);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
if (!denied && request.target != null) data.put("target_player_id", request.target);
|
||||
if (!denied && outcome.equals("completed") && record != null && active != null) {
|
||||
data.put("target_character_id", record.id());
|
||||
data.put("target_session_id", active.session());
|
||||
data.put("target_revision", record.revision().toString());
|
||||
data.put("presented_pages", pages);
|
||||
data.put("presented_volumes", volumes);
|
||||
}
|
||||
try {
|
||||
sink.accept(new EventInput(request.eventId(), request.action.id(), clock.instant(),
|
||||
"character.active_inspection_resolved", request.action.context(), data));
|
||||
} catch (RuntimeException ignored) { /* Audit delivery cannot authorize, fail or replay inspection. */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured command-entry audit. Outcomes are supplied by the authoritative owner, never parsed from feedback. */
|
||||
public final class AdminActionEventBridge implements AutoCloseable {
|
||||
private static final Set<String> ROUTES = Set.of("adventure", "adventure-site", "quest-giver",
|
||||
"character", "dungeon", "shop", "rare-shop", "training-dummy", "party-registrar",
|
||||
"config", "give", "revive", "experience", "inspect", "recover");
|
||||
private static final Set<String> REASONS = Set.of("invalid_arguments", "unauthorized", "owner_outcome_pending",
|
||||
"native_entry_cancelled_at_observation", "native_entry_unauthorized_at_observation",
|
||||
"sender_unsupported", "stale", "not_found", "no_change", "confirmation_required",
|
||||
"permission_changed", "operation_failed", "operation_uncertain", "acknowledged",
|
||||
"read_completed", "owner_refused", "owner_pending");
|
||||
private static final Set<String> FACTS = Set.of("domain_operation_id", "domain_event_id", "subject_id",
|
||||
"target_id", "before", "after", "amount", "key", "setting", "revision_before",
|
||||
"revision_after", "confirmation", "requested", "applied");
|
||||
public enum Outcome { COMPLETED, REFUSED, FAILED, PENDING, UNCERTAIN }
|
||||
|
||||
/** Contains only captured immutable facts; never a sender, live session, command or argument array. */
|
||||
public static final class Action {
|
||||
private final UUID id;
|
||||
private final String route, actorKind, authorization, entryPoint;
|
||||
private final EventInput.Context context;
|
||||
private final Map<String, Object> location;
|
||||
private final Map<String, Object> arguments;
|
||||
private final boolean invalidShape;
|
||||
private boolean terminal, pending;
|
||||
private final Map<String, Object> teleportData;
|
||||
|
||||
private Action(UUID id, String route, String actorKind, String authorization,
|
||||
EventInput.Context context, Map<String, Object> location,
|
||||
Map<String, Object> arguments, boolean invalidShape, String entryPoint, Map<String, Object> teleportData) {
|
||||
this.id = id; this.route = route; this.actorKind = actorKind; this.authorization = authorization;
|
||||
this.context = context; this.location = location; this.arguments = arguments;
|
||||
this.invalidShape = invalidShape;
|
||||
this.entryPoint = entryPoint;
|
||||
this.teleportData = teleportData;
|
||||
}
|
||||
public UUID id() { return id; }
|
||||
public String route() { return route; }
|
||||
public EventInput.Context context() { return context; }
|
||||
public String actorKind() { return actorKind; }
|
||||
public Map<String, Object> commandLocation() { return Map.copyOf(location); }
|
||||
public boolean authorized() { return authorization.equals("authorized"); }
|
||||
}
|
||||
|
||||
/** Restores the previous same-thread scope even on exceptions or nested command dispatch. */
|
||||
public final class Invocation implements AutoCloseable {
|
||||
private final Action action;
|
||||
private final Action previous;
|
||||
private boolean closed;
|
||||
private Invocation(Action action) {
|
||||
this.action = action;
|
||||
previous = active.get();
|
||||
active.set(action);
|
||||
}
|
||||
public Action action() { return action; }
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
synchronized (action) {
|
||||
if (!action.terminal && !action.pending) {
|
||||
if (!action.authorized()) result(action, Outcome.REFUSED, "unauthorized", Map.of());
|
||||
else if (action.invalidShape) result(action, Outcome.REFUSED, "invalid_arguments", Map.of());
|
||||
else result(action, Outcome.PENDING, "owner_outcome_pending", Map.of());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (previous == null) active.remove(); else active.set(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final ThreadLocal<Action> active = new ThreadLocal<>();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean closed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final Set<String> configurationKeys = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
public void allowConfigurationKeys(java.util.Collection<String> keys) {
|
||||
// Supplied by the existing installed typed command catalog, never discovered through I/O/completion.
|
||||
configurationKeys.addAll(keys);
|
||||
}
|
||||
// Diagnostic ownership only; async callers retain their own captured Action. Eviction cannot refuse gameplay.
|
||||
private final Map<UUID, Action> recent = new LinkedHashMap<>(128, .75f, true) {
|
||||
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Action> eldest) { return size() > 1024; }
|
||||
};
|
||||
|
||||
public AdminActionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, EquipmentSessions sessions,
|
||||
Consumer<Throwable> failures) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-action-event-intents", Set.of("admin.action", "admin.teleport_resolved"),
|
||||
Objects.requireNonNull(sink), Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public Action current() { return active.get(); }
|
||||
|
||||
public Invocation open(CommandSender sender, Command command, String label, String[] args) {
|
||||
Objects.requireNonNull(sender);
|
||||
Objects.requireNonNull(command);
|
||||
// The caller is the registered executor; neither label spelling nor argument text is an audit identity.
|
||||
var action = capture(sender, args);
|
||||
emit(action, "request", null, null, Map.of());
|
||||
emit(action, "authorization", null, null, Map.of());
|
||||
return new Invocation(action);
|
||||
}
|
||||
|
||||
/** Only for a final native entry observation where the plugin executor will not run. */
|
||||
public Action nativeEntryRefused(CommandSender sender, String[] args, String reason) {
|
||||
var action = capture(sender, args);
|
||||
emit(action, "request", null, null, Map.of());
|
||||
emit(action, "authorization", null, null, Map.of());
|
||||
result(action, Outcome.REFUSED, reason, Map.of());
|
||||
return action;
|
||||
}
|
||||
|
||||
/** An actual custom-click inspection attempt, not synthetic command dispatch or new permission authority. */
|
||||
public Invocation openInspectionNavigation(Player administrator) {
|
||||
return openInspectionNavigation(administrator, "view");
|
||||
}
|
||||
|
||||
public Invocation openSavedInspectionNavigation(Player administrator) {
|
||||
return openInspectionNavigation(administrator, "show");
|
||||
}
|
||||
|
||||
private Invocation openInspectionNavigation(Player administrator, String route) {
|
||||
var action = capture(administrator, new String[]{"character", route}, "book_navigation");
|
||||
emit(action, "request", null, null, Map.of());
|
||||
emit(action, "authorization", null, null, Map.of());
|
||||
return new Invocation(action);
|
||||
}
|
||||
|
||||
private Action capture(CommandSender sender, String[] args) {
|
||||
return capture(sender, args, "command");
|
||||
}
|
||||
|
||||
private Action capture(CommandSender sender, String[] args, String entryPoint) {
|
||||
boolean permitted = sender.hasPermission("fantasystick.admin");
|
||||
String route = args != null && args.length > 0 && ROUTES.contains(args[0]) ? args[0] : "unknown";
|
||||
var player = sender instanceof Player value ? value : null;
|
||||
String kind = player != null ? "player" : sender instanceof BlockCommandSender ? "command_block" : "console";
|
||||
EventInput.Context context = null;
|
||||
Map<String, Object> place = Map.of();
|
||||
if (player != null) {
|
||||
var id = player.getUniqueId();
|
||||
UUID character = null, session = null;
|
||||
String name = null;
|
||||
try {
|
||||
var current = sessions.active(id).orElse(null);
|
||||
var record = current == null ? null : sessions.currentRecord(current).orElse(null);
|
||||
if (record != null) { character = record.id(); name = record.name(); session = current.session(); }
|
||||
} catch (RuntimeException unavailable) { /* Never turn missing session evidence into guessed identity. */ }
|
||||
EventInput.Location physical = null;
|
||||
try { physical = location(player.getLocation()); } catch (RuntimeException unavailable) { /* Optional native port. */ }
|
||||
context = new EventInput.Context(id, player.getName(), character, name, session, physical);
|
||||
} else if (sender instanceof BlockCommandSender block) {
|
||||
try { place = locationData(block.getBlock().getLocation()); }
|
||||
catch (RuntimeException unavailable) { /* Missing native location is not a player location. */ }
|
||||
}
|
||||
boolean valid = recognizedShape(route, args);
|
||||
Map<String, Object> safeArgs = Map.of();
|
||||
if (permitted && valid && (route.equals("inspect") || route.equals("recover"))) {
|
||||
safeArgs = Map.of("player_id", UUID.fromString(args[1]).toString());
|
||||
} else if (permitted && valid && route.equals("character") && args[1].equals("coins")) {
|
||||
safeArgs = Map.of("character_id", UUID.fromString(args[2]).toString(),
|
||||
"delta", new java.math.BigInteger(args[3]).toString());
|
||||
} else if (permitted && valid && route.equals("config")
|
||||
&& configurationKeys.contains(args[1])) {
|
||||
var typed = new LinkedHashMap<String, Object>();
|
||||
typed.put("key", args[1]);
|
||||
if (args.length == 3) {
|
||||
var value = safeSettingValue(args[1], args[2]);
|
||||
if (value != null) typed.put("value", value);
|
||||
else typed.put("value_redacted", true);
|
||||
}
|
||||
safeArgs = Map.copyOf(typed);
|
||||
}
|
||||
Map<String, Object> teleportData = null;
|
||||
if ((route.equals("adventure-site") || route.equals("dungeon")) && args.length >= 2 && args[1].equals("teleport")) {
|
||||
teleportData = new LinkedHashMap<>();
|
||||
teleportData.put("stage", "validation");
|
||||
teleportData.put("native_outcome", "not_requested");
|
||||
if (player != null) {
|
||||
try { teleportData.put("from_location", teleportLocation(player.getLocation())); }
|
||||
catch (RuntimeException unavailable) { teleportData.put("from_location_status", "unavailable"); }
|
||||
} else teleportData.put("from_location_status", "not_applicable");
|
||||
}
|
||||
var action = new Action(UUID.randomUUID(), route, kind, permitted ? "authorized" : "denied",
|
||||
context, place, safeArgs, !valid, entryPoint, teleportData);
|
||||
synchronized (recent) { recent.put(action.id, action); }
|
||||
return action;
|
||||
}
|
||||
|
||||
private boolean recognizedShape(String route, String[] args) {
|
||||
if (route.equals("unknown") || args == null) return false;
|
||||
int length = args.length;
|
||||
if (route.equals("inspect") || route.equals("recover")) {
|
||||
if (length != 2) return false;
|
||||
try { return UUID.fromString(args[1]).toString().equalsIgnoreCase(args[1]); }
|
||||
catch (IllegalArgumentException invalid) { return false; }
|
||||
}
|
||||
if (route.equals("give") || route.equals("revive")) return length == 2;
|
||||
if (route.equals("config")) return length == 2 || length == 3;
|
||||
if (route.equals("experience")) return length >= 4 && args[1].equals("give");
|
||||
if (route.equals("adventure")) return (length == 2 || length == 3)
|
||||
&& args[1].equals("reroll") && (length == 2 || args[2].equals("confirm"));
|
||||
if (length < 2) return false;
|
||||
if (route.equals("character") && args[1].equals("coins")) {
|
||||
if (length != 4 || !args[3].matches("[+-]?[0-9]+")) return false;
|
||||
try { return UUID.fromString(args[2]).toString().equalsIgnoreCase(args[2])
|
||||
&& new java.math.BigInteger(args[3]).signum() != 0; }
|
||||
catch (IllegalArgumentException invalid) { return false; }
|
||||
}
|
||||
return switch (route) {
|
||||
case "character" -> Set.of("find", "show", "recover", "view").contains(args[1]);
|
||||
case "adventure-site" -> Set.of("list", "place", "fishing", "mode", "remove", "teleport").contains(args[1]);
|
||||
case "quest-giver", "training-dummy", "party-registrar" -> Set.of("list", "place", "remove").contains(args[1]);
|
||||
case "shop", "rare-shop" -> Set.of("list", "place", "remove", "restock").contains(args[1]);
|
||||
case "dungeon" -> Set.of("create", "list", "mode", "remove", "edit", "entrance", "room",
|
||||
"reward", "boss", "teleport", "enable", "disable", "open", "close").contains(args[1]);
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
public void result(Action action, Outcome outcome, String safeReason, Map<String, Object> typedFacts) {
|
||||
if (action == null || outcome == null || closed.get()) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal || outcome == Outcome.PENDING && action.pending) return;
|
||||
var reason = REASONS.contains(safeReason) ? safeReason : "owner_refused";
|
||||
var facts = action.authorized() && !action.route.equals("unknown") ? safeFacts(typedFacts) : Map.<String, Object>of();
|
||||
emit(action, "result", outcome.name().toLowerCase(java.util.Locale.ROOT), reason, facts);
|
||||
if (outcome == Outcome.PENDING) action.pending = true;
|
||||
else {
|
||||
emitTeleport(action, outcome, reason);
|
||||
action.terminal = true;
|
||||
synchronized (recent) { recent.remove(action.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable registration facts come only from the authorized teleport owner. */
|
||||
public void teleportTarget(Action action, games.dmg.fantasystick.adventure.AdventureAdminTeleport.Target target) {
|
||||
if (action == null || !action.authorized() || action.teleportData == null) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal) return;
|
||||
action.teleportData.put("registration_kind", action.route.equals("dungeon") ? "dungeon" : "adventure_site");
|
||||
action.teleportData.put("registration_id", target.id().toString());
|
||||
action.teleportData.put("registration_name", target.name());
|
||||
action.teleportData.put("registration_type", switch (target.type()) {
|
||||
case "monster camp" -> "camp";
|
||||
case "dungeon entrance" -> "dungeon";
|
||||
case "fishing site" -> "fishing";
|
||||
case "champion" -> "champion";
|
||||
default -> "unknown";
|
||||
});
|
||||
action.teleportData.put("facing_policy", target.preserveFacing() ? "preserve_player_facing" : "configured_entrance_facing");
|
||||
var destination = new LinkedHashMap<String, Object>();
|
||||
destination.put("world_id", target.world().toString());
|
||||
destination.put("x", target.x()); destination.put("y", target.y()); destination.put("z", target.z());
|
||||
if (!target.preserveFacing()) {
|
||||
destination.put("yaw", (double) target.yaw()); destination.put("pitch", (double) target.pitch());
|
||||
} else if (action.teleportData.get("from_location") instanceof Map<?, ?> origin) {
|
||||
if (origin.get("yaw") != null) destination.put("yaw", origin.get("yaw"));
|
||||
if (origin.get("pitch") != null) destination.put("pitch", origin.get("pitch"));
|
||||
} else destination.put("facing_status", "unavailable");
|
||||
action.teleportData.put("intended_to_location", Map.copyOf(destination));
|
||||
action.teleportData.put("to_location", Map.copyOf(destination));
|
||||
action.teleportData.put("to_location_status", "intended");
|
||||
}
|
||||
}
|
||||
|
||||
/** Called at actual async boundaries; never reads a native sender/session on a worker. */
|
||||
public void teleportObservation(Action action, String stage, String reason, String nativeOutcome, Map<String, Object> destination) {
|
||||
if (action == null || action.teleportData == null) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal) return;
|
||||
action.teleportData.put("stage", stage);
|
||||
action.teleportData.put("reason", reason);
|
||||
action.teleportData.put("native_outcome", nativeOutcome);
|
||||
if (action.authorized() && !destination.isEmpty()) {
|
||||
action.teleportData.put("to_location", destination);
|
||||
action.teleportData.put("to_location_status", nativeOutcome.equals("completed")
|
||||
? "native_event".equals(destination.get("quality")) ? "native_completed" : "requested_only"
|
||||
: "attempted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> teleportLocation(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld());
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("world_id", world.getUID().toString());
|
||||
data.put("x", location.getX()); data.put("y", location.getY()); data.put("z", location.getZ());
|
||||
data.put("yaw", (double) location.getYaw()); data.put("pitch", (double) location.getPitch());
|
||||
try { data.put("world_name", Objects.requireNonNull(world.getName())); }
|
||||
catch (RuntimeException unavailable) { data.put("world_name_status", "unavailable"); }
|
||||
return Map.copyOf(data);
|
||||
}
|
||||
|
||||
private void emitTeleport(Action action, Outcome outcome, String fallbackReason) {
|
||||
if (action.teleportData == null) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<>(action.teleportData);
|
||||
data.put("route", action.route);
|
||||
data.put("entry_point", "command");
|
||||
data.put("actor_kind", action.actorKind);
|
||||
data.put("authorization", action.authorization);
|
||||
data.put("outcome", outcome.name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.putIfAbsent("reason", fallbackReason);
|
||||
if (outcome == Outcome.UNCERTAIN) data.put("reason", "operation_uncertain");
|
||||
if (action.context == null || action.context.location() == null)
|
||||
data.put("location_status", action.actorKind.equals("console") ? "not_applicable" : "unavailable");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:admin-teleport:" + action.id).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, action.id, clock.instant(), "admin.teleport_resolved", action.context, data));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot authorize, undo or repeat a native teleport. */ }
|
||||
}
|
||||
|
||||
private Map<String, Object> safeFacts(Map<String, Object> input) {
|
||||
if (input == null || input.isEmpty()) return Map.of();
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
input.forEach((key, value) -> {
|
||||
if (!FACTS.contains(key)) return;
|
||||
if (value instanceof UUID id) result.put(key, id.toString());
|
||||
else if (value instanceof Boolean || value instanceof Integer) result.put(key, value);
|
||||
else if (value instanceof Long number && number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE)
|
||||
result.put(key, number.intValue());
|
||||
else if (value instanceof String text && text.length() <= 2048) {
|
||||
if (key.endsWith("_id")) {
|
||||
try { result.put(key, UUID.fromString(text).toString()); } catch (IllegalArgumentException ignored) { }
|
||||
} else if (key.equals("key") || key.equals("setting")) {
|
||||
if (configurationKeys.contains(text)) result.put(key, text);
|
||||
} else if (Set.of("true", "false", "on", "off", "confirm").contains(text)) result.put(key, text);
|
||||
else {
|
||||
boolean secretSetting = input.get("key") instanceof String setting
|
||||
&& (setting.contains("url") || setting.contains("sha1") || setting.contains("hash")
|
||||
|| setting.contains("password") || setting.contains("token"));
|
||||
var number = secretSetting ? null : safeNumber(text);
|
||||
if (number != null) result.put(key, number);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static String safeSettingValue(String key, String value) {
|
||||
if (key.contains("url") || key.contains("sha1") || key.contains("hash")
|
||||
|| key.contains("password") || key.contains("token")) return null;
|
||||
if (value.equals("true") || value.equals("false")) return value;
|
||||
return safeNumber(value);
|
||||
}
|
||||
|
||||
/** An intentional native confirmation, never a render, inert slot or a new gameplay authorization. */
|
||||
public void confirmation(Action action, boolean permitted, String entryPoint, UUID causation) {
|
||||
if (action == null || closed.get()) return;
|
||||
if (!Set.of("inventory_confirmation", "command_confirmation").contains(entryPoint)) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal) return;
|
||||
var facts = new LinkedHashMap<String, Object>();
|
||||
facts.put("confirmation", true);
|
||||
facts.put("entry_point", entryPoint);
|
||||
facts.put("authorization", permitted ? "authorized" : "denied");
|
||||
if (causation != null) facts.put("causation_id", causation.toString());
|
||||
emit(action, "confirmation", null, null, facts);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeNumber(String value) {
|
||||
// Bounded typed decimal only: never free-form text, an URL, secret or nonfinite native value.
|
||||
if (value.length() > 2048 || !value.matches("-?[0-9]+(?:\\.[0-9]+)?")) return null;
|
||||
try { return new java.math.BigDecimal(value).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { return null; }
|
||||
}
|
||||
|
||||
private void emit(Action action, String stage, String outcome, String reason, Map<String, Object> facts) {
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("stage", stage);
|
||||
data.put("route", action.route);
|
||||
data.put("entry_point", action.entryPoint);
|
||||
data.put("actor_kind", action.actorKind);
|
||||
data.put("authorization", action.authorization);
|
||||
if (action.context == null) data.put("location_status", action.actorKind.equals("command_block")
|
||||
? action.location.isEmpty() ? "unavailable" : "captured" : "not_applicable");
|
||||
else if (action.context.location() == null) data.put("location_status", "unavailable");
|
||||
if (!action.location.isEmpty()) data.put("location", action.location);
|
||||
if (!action.arguments.isEmpty() && !"denied".equals(facts.get("authorization"))) data.put("arguments", action.arguments);
|
||||
if (outcome != null) data.put("outcome", outcome);
|
||||
if (reason != null) data.put("reason", reason);
|
||||
data.putAll(facts);
|
||||
outbox.accepted(new EventInput(UUID.randomUUID(), action.id, clock.instant(), "admin.action", action.context, data));
|
||||
} catch (RuntimeException ignored) { /* Never let an audit failure authorize or reject a command. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location place) {
|
||||
try {
|
||||
if (place == null || place.getWorld() == null) return null;
|
||||
return new EventInput.Location(place.getWorld().getUID(), place.getWorld().getName(),
|
||||
place.getX(), place.getY(), place.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
private Map<String, Object> locationData(Location place) {
|
||||
var captured = location(place);
|
||||
if (captured == null) return Map.of();
|
||||
return Map.of("world_id", captured.worldId().toString(), "world_name", captured.worldName(),
|
||||
"x", captured.x(), "y", captured.y(), "z", captured.z(),
|
||||
"captured_at", captured.capturedAt().toString(), "quality", captured.quality());
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() {
|
||||
if (closed.get()) return;
|
||||
active.remove();
|
||||
java.util.List<Action> pending;
|
||||
synchronized (recent) { pending = java.util.List.copyOf(recent.values()); }
|
||||
// Audit-only uncertainty at the owner lifetime boundary; never cancel/replay the domain operation.
|
||||
for (var action : pending) result(action, Outcome.UNCERTAIN, "operation_uncertain", Map.of());
|
||||
closed.set(true);
|
||||
synchronized (recent) { recent.clear(); }
|
||||
outbox.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Independent feature outbox: only acknowledged administrator balance adjustments. */
|
||||
public final class AdminCoinsEventBridge implements AutoCloseable {
|
||||
public record Intent(UUID operation, UUID action, Instant time, String actorKind, EventInput.Context actor) { }
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final FeatureEventOutbox outbox;
|
||||
public AdminCoinsEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
|
||||
this.clock = clock; this.sessions = sessions;
|
||||
outbox = new FeatureEventOutbox(data, "admin-coins-event-intents", Set.of("character.coins_adjusted"), sink, failures);
|
||||
}
|
||||
public Intent capture(CommandSender sender, UUID action) {
|
||||
EventInput.Context actor = null;
|
||||
if (sender instanceof Player player) {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
EventInput.Location location = null;
|
||||
try {
|
||||
var place = player.getLocation();
|
||||
if (place != null && place.getWorld() != null) location = new EventInput.Location(place.getWorld().getUID(),
|
||||
place.getWorld().getName(), place.getX(), place.getY(), place.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { /* Optional actor location is never mutation authority. */ }
|
||||
actor = new EventInput.Context(player.getUniqueId(), player.getName(), record == null ? null : record.id(),
|
||||
record == null ? null : record.name(), active == null ? null : active.session(), location);
|
||||
}
|
||||
return new Intent(UUID.randomUUID(), action, clock.instant(), sender instanceof Player ? "player" : "console", actor);
|
||||
}
|
||||
public void adjusted(Intent intent, CharacterRecord before, CharacterRecord after, BigInteger delta,
|
||||
String targetState, String sync) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("operation_id", intent.operation().toString());
|
||||
if (intent.action() != null) values.put("admin_action_id", intent.action().toString());
|
||||
values.put("actor_kind", intent.actorKind());
|
||||
values.put("target_character_id", after.id().toString()); values.put("target_character_name", after.name());
|
||||
values.put("delta", delta.toString());
|
||||
values.put("before_coins", before.coins().toString()); values.put("after_coins", after.coins().toString());
|
||||
values.put("before_revision", before.revision().toString()); values.put("after_revision", after.revision().toString());
|
||||
values.put("target_state", targetState); values.put("session_sync", sync);
|
||||
var location = intent.actor() == null ? null : intent.actor().location();
|
||||
values.put("location_status", intent.actor() == null ? "not_applicable" : location == null ? "unavailable" : "captured");
|
||||
if (location != null) values.put("location", Map.of("world_id", location.worldId().toString(),
|
||||
"world_name", location.worldName(), "x", location.x(), "y", location.y(), "z", location.z(),
|
||||
"captured_at", location.capturedAt().toString(), "quality", location.quality()));
|
||||
outbox.accepted(new EventInput(eventId(intent.operation()), intent.operation(), intent.time(),
|
||||
"character.coins_adjusted", intent.actor(), values));
|
||||
}
|
||||
public static UUID eventId(UUID operation) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:admin-coins:" + operation).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.purpurmc.purpur.event.ExecuteCommandEvent;
|
||||
|
||||
/** Final native command-map observation, before PluginCommand's metadata permission check. */
|
||||
public final class AdminCommandEntryObserver implements Listener {
|
||||
private final PluginCommand root;
|
||||
private final AdminActionEventBridge audit;
|
||||
private final ActiveInspectionEventBridge inspections;
|
||||
private final SavedCharacterEventBridge saved;
|
||||
|
||||
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit) {
|
||||
this(root, audit, null);
|
||||
}
|
||||
|
||||
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit, ActiveInspectionEventBridge inspections) {
|
||||
this(root, audit, inspections, null);
|
||||
}
|
||||
|
||||
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit,
|
||||
ActiveInspectionEventBridge inspections, SavedCharacterEventBridge saved) {
|
||||
this.saved = saved;
|
||||
this.inspections = inspections;
|
||||
this.root = Objects.requireNonNull(root);
|
||||
this.audit = Objects.requireNonNull(audit);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void observe(ExecuteCommandEvent event) {
|
||||
// Object identity matters: another plugin's command/alias is never Fantasy Stick administration.
|
||||
if (event.getCommand() != root) return;
|
||||
try {
|
||||
var sender = event.getSender();
|
||||
boolean allowed = sender.hasPermission("fantasystick.admin");
|
||||
if (!allowed || event.isCancelled()) {
|
||||
// Describes only the state at this observation, not a claim about later monitor listeners.
|
||||
var action = audit.nativeEntryRefused(sender, event.getArgs(), allowed
|
||||
? "native_entry_cancelled_at_observation" : "native_entry_unauthorized_at_observation");
|
||||
var args = event.getArgs();
|
||||
if (inspections != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view"))
|
||||
inspections.resolved(inspections.request(action, null, 0, "command"), "refused",
|
||||
allowed ? "native_entry_cancelled" : "unauthorized", null, null, 0, 0);
|
||||
if (saved != null && args.length >= 2 && args[0].equals("character")) {
|
||||
var reason = allowed ? "native_entry_cancelled" : "unauthorized";
|
||||
if (args[1].equals("find")) saved.searchResolved(saved.search(action, ""), "refused", reason,
|
||||
java.util.List.of(), 0, false);
|
||||
if (args[1].equals("show")) saved.inspectionResolved(saved.inspection(action, null, 0, "command"),
|
||||
"refused", reason, null, false, 0, 0, 0, java.util.List.of());
|
||||
}
|
||||
}
|
||||
// Otherwise the executor owns the request; observing here too would duplicate the action.
|
||||
} catch (RuntimeException unavailable) { /* Audit may not change native dispatch or authority. */ }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,12 @@ public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
EventInput.Location targetLocation,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active targetSession) { }
|
||||
|
||||
public record Target(String name, EventInput.Location location,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active session) { }
|
||||
public Target snapshot(Player target) {
|
||||
return new Target(target.getName(), safeLocation(target), sessions.active(target.getUniqueId()).orElse(null));
|
||||
}
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
@@ -63,9 +69,14 @@ public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, Player target) {
|
||||
revived(selection, reservation, dead, restored, target.getName());
|
||||
}
|
||||
|
||||
public void revived(Selection selection, RevivalStore.Reservation reservation,
|
||||
CharacterRecord dead, CharacterRecord restored, String targetName) {
|
||||
if (selection == null || selection.actor() == null) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
var data = revivalData(reservation, restored, targetName);
|
||||
data.put("revision_before", dead.revision().toString());
|
||||
data.put("death_time", dead.diedAt().toString());
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0, Duration.between(dead.diedAt(), clock.instant()).getSeconds())));
|
||||
@@ -82,9 +93,13 @@ public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
|
||||
/** Reconciliation has the reservation's exact admin UUID, but never invents its old location or name. */
|
||||
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
recovered(reservation, restored, snapshot(target));
|
||||
}
|
||||
|
||||
public void recovered(RevivalStore.Reservation reservation, CharacterRecord restored, Target target) {
|
||||
if (!reservation.administrative()) return;
|
||||
try {
|
||||
var data = revivalData(reservation, restored, target);
|
||||
var data = revivalData(reservation, restored, target.name());
|
||||
data.put("actor_kind", "system");
|
||||
data.put("admin_player_id", reservation.casterCharacter().toString());
|
||||
data.put("admin_name_status", "unavailable");
|
||||
@@ -94,18 +109,18 @@ public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
data.put("death_age_seconds", Long.toString(Math.max(0,
|
||||
Duration.between(restored.diedAt(), reservation.createdAt()).getSeconds())));
|
||||
data.put("recovery_reconciliation", true);
|
||||
location(data, "target_location", safeLocation(target));
|
||||
targetSession(data, sessions.active(target.getUniqueId()).orElse(null));
|
||||
location(data, "target_location", target.location());
|
||||
targetSession(data, target.session());
|
||||
durable("character.revived", reservation.id(), null, data);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, Player target) {
|
||||
private Map<String, Object> revivalData(RevivalStore.Reservation reservation, CharacterRecord restored, String targetName) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("revival_kind", "admin_tail");
|
||||
data.put("revival_operation_id", reservation.id().toString());
|
||||
data.put("target_player_id", reservation.targetPlayer().toString());
|
||||
data.put("target_player_name", target.getName());
|
||||
data.put("target_player_name", targetName);
|
||||
data.put("character_id", restored.id().toString());
|
||||
data.put("character_name", restored.name());
|
||||
data.put("death_id", reservation.death().toString());
|
||||
@@ -124,21 +139,29 @@ public final class AdminRevivalEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
read(UUID.randomUUID(), actor, effective);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
emit(UUID.randomUUID(), "config.read", actor.context(), data);
|
||||
emit(operation, "config.read", actor.context(), data);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
changed(UUID.randomUUID(), actor, before, saved);
|
||||
}
|
||||
|
||||
public void changed(UUID operation, Actor actor, String before, AdminReviveSettings.Update saved) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor);
|
||||
data.put("before", before);
|
||||
data.put("after", saved.value());
|
||||
data.put("effective", saved.value());
|
||||
data.put("outcome", "acknowledged");
|
||||
durable("config.changed", UUID.randomUUID(), actor.context(), data);
|
||||
durable("config.changed", operation, actor.context(), data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> config(Actor actor) {
|
||||
|
||||
@@ -140,14 +140,20 @@ public final class ArmorEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String value) {
|
||||
configuration("config.read", actor, key, null, value);
|
||||
configuration("config.read", UUID.randomUUID(), actor, key, null, value);
|
||||
}
|
||||
public void configChanged(Actor actor, String key, String before, String after) {
|
||||
configuration("config.changed", actor, key, before, after);
|
||||
configuration("config.changed", UUID.randomUUID(), actor, key, before, after);
|
||||
}
|
||||
private void configuration(String type, Actor actor, String key, String before, String after) {
|
||||
public void configRead(UUID operation, Actor actor, String key, String value) {
|
||||
configuration("config.read", operation, actor, key, null, value);
|
||||
}
|
||||
public void configChanged(UUID operation, Actor actor, String key, String before, String after) {
|
||||
configuration("config.changed", operation, actor, key, before, after);
|
||||
}
|
||||
private void configuration(String type, UUID operation, Actor actor, String key, String before, String after) {
|
||||
try {
|
||||
var operation = UUID.randomUUID(); var values = new LinkedHashMap<String, Object>();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
actorData(values, actor); values.put("key", key); values.put("after", after);
|
||||
if (before != null) values.put("before", before);
|
||||
values.put("application", "next_armor_restock"); values.put("application_scope", "next_catalog");
|
||||
|
||||
@@ -67,6 +67,7 @@ public final class CampEventBridge implements AutoCloseable {
|
||||
try{
|
||||
var data=siteData(site,arena);
|
||||
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
|
||||
data.put("application","future_acceptances");
|
||||
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
|
||||
accepted("adventure.site.registered",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Cannot undo acknowledged registration. */}
|
||||
@@ -103,7 +104,7 @@ public final class CampEventBridge implements AutoCloseable {
|
||||
var data=siteData(acknowledged,arena);
|
||||
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
|
||||
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
|
||||
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("application","future_acceptances");data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
|
||||
accepted("adventure.site.mode_changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Never alter mode publication. */}
|
||||
@@ -161,9 +162,13 @@ public final class CampEventBridge implements AutoCloseable {
|
||||
default->throw new IllegalArgumentException("Not a camp/catalog setting");};}
|
||||
private static LinkedHashMap<String,Object> settingData(String key,String defaults){var data=new LinkedHashMap<String,Object>();
|
||||
data.put("key",key);data.put("default",defaults);data.put("feature","camp_catalog");data.put("application_scope",settingScope(key));return data;}
|
||||
public void configRead(Actor actor,String key,String effective,String defaults){
|
||||
public void configRead(Actor actor,String key,String effective,String defaults) {
|
||||
configRead(UUID.randomUUID(), actor, key, effective, defaults);
|
||||
}
|
||||
|
||||
public void configRead(UUID operation, Actor actor,String key,String effective,String defaults) {
|
||||
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
|
||||
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
accepted("config.read",operation,actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
}
|
||||
public void configChanged(Actor actor,UUID operation,String key,String before,String after,String defaults){
|
||||
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class ChampionEventBridge implements AutoCloseable {
|
||||
if(site.type()!=AdventureQuestState.SiteType.CHAMPION)return;
|
||||
var data=CampEventBridge.siteData(site,arena);
|
||||
data.put("mode_before","not_applicable");data.put("mode_after",site.participationMode().value());
|
||||
data.put("application","future_acceptances");
|
||||
data.put("catalog_impact","unchanged_by_registration");data.put("active_quest_impact","unchanged");
|
||||
accepted("adventure.site.registered",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Acknowledged authority is independent of audit availability. */}
|
||||
@@ -38,7 +39,7 @@ public final class ChampionEventBridge implements AutoCloseable {
|
||||
var data=CampEventBridge.siteData(acknowledged,arena);
|
||||
data.put("mode_before",requestedBefore.participationMode().value());data.put("mode_after",acknowledged.participationMode().value());
|
||||
if(requestedBefore.registrationRevision()!=null)data.put("requested_site_revision",requestedBefore.registrationRevision().toString());
|
||||
data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("application","future_acceptances");data.put("application_scope","future_acceptances");data.put("active_quest_impact","captured_authority_unchanged");
|
||||
data.put("catalog_impact","same_offer_future_acceptances_use_new_mode");
|
||||
accepted("adventure.site.mode_changed",operation,actor,data);
|
||||
}catch(RuntimeException ignored){/* Mode publication never depends on audit. */}
|
||||
@@ -64,9 +65,13 @@ public final class ChampionEventBridge implements AutoCloseable {
|
||||
data.put("application_scope",key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_HEALTH)
|
||||
||key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.CHAMPION_DAMAGE)?"future_attempts":"future_acceptances");return data;
|
||||
}
|
||||
public void configRead(CampEventBridge.Actor actor,String key,String effective,String defaults){
|
||||
public void configRead(CampEventBridge.Actor actor,String key,String effective,String defaults) {
|
||||
configRead(UUID.randomUUID(), actor, key, effective, defaults);
|
||||
}
|
||||
|
||||
public void configRead(UUID operation, CampEventBridge.Actor actor,String key,String effective,String defaults) {
|
||||
try{if(!ownsSetting(key))return;var data=settingData(key,defaults);data.put("effective",effective);data.put("outcome","acknowledged_read");
|
||||
accepted("config.read",UUID.randomUUID(),actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
accepted("config.read",operation,actor,data);}catch(RuntimeException ignored){/* Reporting cannot depend on audit. */}
|
||||
}
|
||||
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaults){
|
||||
try{if(!ownsSetting(key)||before.equals(after))return;var data=settingData(key,defaults);data.put("before",before);data.put("after",after);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observation only: frozen recovery evidence never grants native delivery or character authority. */
|
||||
public final class CharacterRecoveryEventBridge implements AutoCloseable {
|
||||
public record Capture(UUID operation, UUID command, UUID confirmation, EventInput.Context administrator,
|
||||
UUID recipient, String recipientName, EventInput.Location recipientLocation,
|
||||
int destination, Instant confirmedAt) { }
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public CharacterRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.clock = clock;
|
||||
this.failures = failures;
|
||||
outbox = new FeatureEventOutbox(data, "character-recovery-event-intents", java.util.Set.of(
|
||||
"character.recovery_committed", "character.recovery_uncertain", "character.recovery_completed"), sink, failures);
|
||||
}
|
||||
|
||||
/** Called on the native thread at confirmation, not from the file worker or on reconnect. */
|
||||
public Capture capture(UUID operation, AdminActionEventBridge.Action action, UUID confirmation,
|
||||
Player administrator, Player recipient, int destination) {
|
||||
var context = action == null ? new EventInput.Context(administrator.getUniqueId(), administrator.getName(),
|
||||
null, null, null, location(administrator)) : action.context();
|
||||
return new Capture(operation, action == null ? null : action.id(), confirmation, context,
|
||||
recipient.getUniqueId(), recipient.getName(), location(recipient), destination, clock.instant());
|
||||
}
|
||||
|
||||
public static EventInput committed(Capture capture, CharacterRecord before, CharacterRecord after) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", "player");
|
||||
data.put("operation_id", capture.operation().toString());
|
||||
if (capture.command() != null) data.put("command_action_id", capture.command().toString());
|
||||
data.put("confirmation_action_id", capture.confirmation().toString());
|
||||
data.put("admin_player_id", capture.administrator().playerId().toString());
|
||||
data.put("admin_player_name", capture.administrator().playerName());
|
||||
data.put("recipient_player_id", capture.recipient().toString());
|
||||
data.put("recipient_player_name", capture.recipientName());
|
||||
data.put("recovered_character_id", before.id().toString());
|
||||
data.put("recovered_character_name", before.name());
|
||||
data.put("mode", before.dead() ? "no_tail_revival" : "living_reissue");
|
||||
data.put("revision_before", before.revision().toString());
|
||||
data.put("revision_after", after.revision().toString());
|
||||
data.put("generation_before", before.sheetGeneration().toString());
|
||||
data.put("generation_after", after.sheetGeneration().toString());
|
||||
data.put("alive_before", !before.dead()); data.put("alive_after", !after.dead());
|
||||
data.put("health_before", before.health().toPlainString()); data.put("health_after", after.health().toPlainString());
|
||||
if (before.dead()) { data.put("death_id", before.deathId().toString()); data.put("death_time", before.diedAt().toString()); }
|
||||
data.put("destination_slot", capture.destination());
|
||||
if (capture.recipientLocation() == null) data.put("recipient_location_status", "unavailable");
|
||||
else data.put("recipient_location", place(capture.recipientLocation()));
|
||||
if (capture.administrator().location() == null) data.put("location_status", "unavailable");
|
||||
data.put("outcome", "pending_delivery");
|
||||
return event(capture.operation(), "character.recovery_committed", capture.confirmedAt(), capture.administrator(), data);
|
||||
}
|
||||
|
||||
public EventInput completed(EventInput committed) {
|
||||
var data = new LinkedHashMap<>(committed.data());
|
||||
data.put("outcome", "acknowledged"); data.put("native_delivery", "acknowledged");
|
||||
data.put("final_revision", data.get("revision_after")); data.put("final_generation", data.get("generation_after"));
|
||||
data.put("revoked_generation", data.get("generation_before")); data.put("recovery_reconciliation", false);
|
||||
return event(committed.actionId(), "character.recovery_completed", clock.instant(), committed.context(), data);
|
||||
}
|
||||
|
||||
public EventInput uncertain(EventInput committed, String stage, String reason, boolean nativeWriteMayHaveOccurred) {
|
||||
var data = new LinkedHashMap<>(committed.data());
|
||||
data.put("outcome", "uncertain"); data.put("uncertain_stage", stage); data.put("reason", reason);
|
||||
data.put("native_write_may_have_occurred", nativeWriteMayHaveOccurred);
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:character-recovery:" + committed.actionId()
|
||||
+ ":character.recovery_uncertain:" + stage).getBytes(StandardCharsets.UTF_8));
|
||||
return new EventInput(id, committed.actionId(), clock.instant(), "character.recovery_uncertain", committed.context(), data);
|
||||
}
|
||||
|
||||
private static EventInput event(UUID operation, String type, Instant time, EventInput.Context context, Map<String, Object> data) {
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:character-recovery:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
return new EventInput(id, operation, time, type, context, data);
|
||||
}
|
||||
private EventInput.Location location(Player player) {
|
||||
try {
|
||||
var place = player.getLocation(); var world = place.getWorld();
|
||||
if (world == null) return null;
|
||||
return new EventInput.Location(world.getUID(), world.getName(), place.getX(), place.getY(), place.getZ(), clock.instant(), "exact");
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
private static Map<String, Object> place(EventInput.Location value) {
|
||||
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
|
||||
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString(), "quality", value.quality());
|
||||
}
|
||||
|
||||
/** Bounded owner receipt codec, with explicit RFC3339/UUID parsing rather than reflective runtime serialization. */
|
||||
public static String encode(EventInput event) {
|
||||
var fields = new LinkedHashMap<String, Object>();
|
||||
fields.put("id", event.id().toString()); fields.put("action", event.actionId().toString());
|
||||
fields.put("time", event.time().toString()); fields.put("type", event.type()); fields.put("data", event.data());
|
||||
var context = event.context();
|
||||
if (context != null) {
|
||||
var actor = new LinkedHashMap<String, Object>();
|
||||
actor.put("player", context.playerId().toString()); actor.put("name", context.playerName());
|
||||
if (context.characterId() != null) {
|
||||
actor.put("character", context.characterId().toString()); actor.put("character_name", context.characterName());
|
||||
actor.put("session", context.sessionId().toString());
|
||||
}
|
||||
if (context.location() != null) actor.put("location", place(context.location()));
|
||||
fields.put("context", actor);
|
||||
}
|
||||
return new Gson().toJson(fields);
|
||||
}
|
||||
public static EventInput decode(String text) {
|
||||
if (text.length() > 16_384) throw new IllegalArgumentException("Recovery event too large");
|
||||
var json = JsonParser.parseString(text).getAsJsonObject();
|
||||
EventInput.Context context = null;
|
||||
if (json.has("context")) {
|
||||
var actor = json.getAsJsonObject("context"); EventInput.Location location = null;
|
||||
if (actor.has("location")) {
|
||||
var place = actor.getAsJsonObject("location");
|
||||
location = new EventInput.Location(UUID.fromString(place.get("world_id").getAsString()), place.get("world_name").getAsString(),
|
||||
place.get("x").getAsDouble(), place.get("y").getAsDouble(), place.get("z").getAsDouble(),
|
||||
Instant.parse(place.get("captured_at").getAsString()), place.get("quality").getAsString());
|
||||
}
|
||||
context = new EventInput.Context(UUID.fromString(actor.get("player").getAsString()), actor.get("name").getAsString(),
|
||||
actor.has("character") ? UUID.fromString(actor.get("character").getAsString()) : null,
|
||||
actor.has("character_name") ? actor.get("character_name").getAsString() : null,
|
||||
actor.has("session") ? UUID.fromString(actor.get("session").getAsString()) : null, location);
|
||||
}
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
json.getAsJsonObject("data").entrySet().forEach(entry -> data.put(entry.getKey(), value(entry.getValue(), 0)));
|
||||
return new EventInput(UUID.fromString(json.get("id").getAsString()), UUID.fromString(json.get("action").getAsString()),
|
||||
Instant.parse(json.get("time").getAsString()), json.get("type").getAsString(), context, data);
|
||||
}
|
||||
private static Object value(JsonElement element, int depth) {
|
||||
if (depth > 8) throw new IllegalArgumentException("Recovery event nesting too deep");
|
||||
if (element.isJsonNull()) return null;
|
||||
if (element.isJsonObject()) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
element.getAsJsonObject().entrySet().forEach(entry -> map.put(entry.getKey(), value(entry.getValue(), depth + 1)));
|
||||
return map;
|
||||
}
|
||||
if (element.isJsonArray()) return element.getAsJsonArray().asList().stream().map(item -> value(item, depth + 1)).toList();
|
||||
var primitive = element.getAsJsonPrimitive();
|
||||
if (primitive.isBoolean()) return primitive.getAsBoolean();
|
||||
if (primitive.isNumber()) {
|
||||
String number = primitive.getAsString();
|
||||
if (number.matches("-?[0-9]+")) return Integer.valueOf(number);
|
||||
double finite = primitive.getAsDouble();
|
||||
if (!Double.isFinite(finite)) throw new IllegalArgumentException("Non-finite recovery event value");
|
||||
return finite;
|
||||
}
|
||||
return primitive.getAsString();
|
||||
}
|
||||
public void publish(EventInput event) {
|
||||
try { outbox.accepted(event); }
|
||||
catch (RuntimeException failure) { diagnostic(failure); }
|
||||
}
|
||||
public void diagnostic(Throwable failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignored) { /* Audit failure cannot authorize or undo recovery. */ }
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -27,9 +27,11 @@ public final class CoinEventBridge implements AutoCloseable {
|
||||
private void publish(EventInput event){if(outbox!=null)outbox.accepted(event);else sink.accept(event);}
|
||||
public void initialized(CharacterRecord record,String source,CoinRules rules){var operation=stable(record.id(),"coins-initialized");var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("character_id",record.id().toString());data.put("character_name",record.name());data.put("source",source);data.put("configured_minimum",Long.toString(rules.startingMin()));data.put("configured_maximum",Long.toString(rules.startingMax()));data.put("balance",record.coins().toString());data.put("revision",record.revision().toString());data.put("outcome","acknowledged");emit(operation,"character.coins_initialized",data);}
|
||||
public void lost(CharacterRecord before,CharacterRecord after,UUID death,Instant diedAt){var data=new LinkedHashMap<String,Object>();data.put("operation_id",death.toString());data.put("death_id",death.toString());data.put("character_id",after.id().toString());data.put("character_name",after.name());data.put("reason","player_attributed_final_death");data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("death_bound_escrow",after.pvpCoinEscrow().toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("final_death_at",diedAt.toString());data.put("outcome","acknowledged");emit(death,"character.coins_lost",data);}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
|
||||
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{publish(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){configRead(UUID.randomUUID(),actorKind, player, name, key, value);}
|
||||
public void configRead(UUID operation,String actorKind,UUID player,String name,String key,String value){config(operation,"config.read",actorKind, player, name, key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){configChanged(UUID.randomUUID(),actorKind, player, name, key, before, after);}
|
||||
public void configChanged(UUID operation,String actorKind,UUID player,String name,String key,String before,String after){config(operation,"config.changed",actorKind, player, name, key, before, after);}
|
||||
private void config(UUID operation,String type,String actorKind,UUID player,String name,String key,String before,String after){var action=operation;var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",key.startsWith("starting-")?"future_character_initialization":"future_mob_defeats");data.put("outcome","acknowledged");try{publish(new EventInput(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
private void emit(UUID action,String type,Map<String,Object> data){try{publish(new EventInput(id(action,type),action,clock.instant(),type,null,Map.copyOf(data)));}catch(RuntimeException ignored){}}
|
||||
private static UUID stable(UUID character,String kind){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+character+":"+kind).getBytes(StandardCharsets.UTF_8));}
|
||||
private static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:coin:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
|
||||
@@ -22,7 +22,15 @@ public final class CombatRewardEventBridge implements AutoCloseable {
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
|
||||
Map<String,Object> managedEvidence){
|
||||
Map<String,Object> managedEvidence,java.util.List<UUID> beneficiaries){
|
||||
public Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
String rewardUnitId,int rewardUnitCount,BigInteger experiencePool,BigInteger coinPool,
|
||||
Map<String,Object> managedEvidence){
|
||||
this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt,
|
||||
directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,managedEvidence,java.util.List.of());
|
||||
}
|
||||
public Capture(UUID targetId,String targetType,String experienceClass,String coinClass,
|
||||
CombatRewardRules.KillPosition killPosition,ManagedEncounterOrigin managedOrigin,
|
||||
String stage,Instant defeatedAt,java.util.List<UUID> directContributors,
|
||||
@@ -30,7 +38,7 @@ public final class CombatRewardEventBridge implements AutoCloseable {
|
||||
this(targetId,targetType,experienceClass,coinClass,killPosition,managedOrigin,stage,defeatedAt,
|
||||
directContributors,rewardUnitId,rewardUnitCount,experiencePool,coinPool,Map.of());
|
||||
}
|
||||
public Capture{managedEvidence=Map.copyOf(managedEvidence);Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
|
||||
public Capture{beneficiaries=java.util.List.copyOf(beneficiaries);managedEvidence=Map.copyOf(managedEvidence);Objects.requireNonNull(targetId);Objects.requireNonNull(targetType);Objects.requireNonNull(experienceClass);Objects.requireNonNull(coinClass);Objects.requireNonNull(stage);Objects.requireNonNull(defeatedAt);directContributors=java.util.List.copyOf(directContributors);Objects.requireNonNull(rewardUnitId);Objects.requireNonNull(experiencePool);Objects.requireNonNull(coinPool);if(rewardUnitCount<1||experiencePool.signum()<0||coinPool.signum()<0)throw new IllegalArgumentException("Invalid combat reward capture");}
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
@@ -47,7 +55,7 @@ public final class CombatRewardEventBridge implements AutoCloseable {
|
||||
CombatRewardRules.KillPosition restricted,CombatRewardRules rules,Capture capture,
|
||||
CharacterRecord before,CharacterStorage.CombatAward award,EquipmentSessions.Active active,
|
||||
Player player,String nativeSync,String feedback){
|
||||
if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","passive_or_fantasy_character");}data.put("loss_reason",loss(award.status()));var context=context(active,player,after.name());data.put("location_capture_quality",context==null||context.location()==null?"unavailable":"exact");try{var event=new EventInput(id(operation),operation,clock.instant(),"combat.reward_resolved",context,Map.copyOf(data));if(outbox!=null)outbox.accepted(event);else sink.accept(event);}catch(RuntimeException ignored){}
|
||||
if(award==null||award.record().isEmpty()||before==null||award.status()==CharacterStorage.CombatAwardStatus.DUPLICATE)return;var after=award.record().orElseThrow();var data=new LinkedHashMap<String,Object>();data.put("operation_id",operation.toString());data.put("reward_receipt",operation.toString());data.put("stage",capture.stage());data.put("recipient_character_id",character.toString());data.put("target_id",capture.targetId().toString());data.put("target_type",capture.targetType());data.put("target_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT));data.put("reward_class",capture.experienceClass());data.put("classifier_version","native_entity_type_v1");data.put("classifier_evidence",Map.of("native_entity_type",capture.targetType().toUpperCase(java.util.Locale.ROOT),"selected_experience_class",capture.experienceClass(),"capture_stage",capture.stage()));if("hard".equals(capture.experienceClass()))data.put("hard_mob_xp",capture.experiencePool().toString());data.put("experience_class",capture.experienceClass());data.put("coin_class",capture.coinClass());data.put("defeated_at",capture.defeatedAt().toString());data.put("direct_contributor_character_ids",capture.directContributors().stream().map(UUID::toString).sorted().toList());data.put("reward_unit_id",capture.rewardUnitId());data.put("reward_unit_count",capture.rewardUnitCount());data.put("beneficiary_capture_quality",capture.beneficiaries().isEmpty()?"unavailable":"exact");if(!capture.beneficiaries().isEmpty())data.put("reward_unit_beneficiary_character_ids",capture.beneficiaries().stream().map(UUID::toString).sorted().toList());data.put("experience_pool",capture.experiencePool().toString());data.put("coin_pool",capture.coinPool().toString());if(capture.killPosition()!=null)data.put("kill_location",position(capture.killPosition()));data.put("allocated_experience",allocatedExperience.toString());data.put("allocated_coins",allocatedCoins.toString());var appliedExperience=after.experience().subtract(before.experience()).max(BigInteger.ZERO);var appliedCoins=after.coins().subtract(before.coins()).max(BigInteger.ZERO);data.put("applied_experience",appliedExperience.toString());data.put("applied_coins",appliedCoins.toString());data.put("suppressed_experience",allocatedExperience.subtract(appliedExperience.min(allocatedExperience)).toString());data.put("suppressed_coins",allocatedCoins.subtract(appliedCoins.min(allocatedCoins)).toString());data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());data.put("before_level",before.level());data.put("after_level",after.level());data.put("before_experience",before.experience().toString());data.put("after_experience",after.experience().toString());data.put("before_points",before.unspentPoints().toString());data.put("after_points",after.unspentPoints().toString());data.put("balance_before",before.coins().toString());data.put("balance_after",after.coins().toString());data.put("storage_status",award.status().name().toLowerCase(java.util.Locale.ROOT));data.put("levels_gained",award.levels());data.put("native_sync",nativeSync);data.put("feedback",feedback);if(capture.managedOrigin()!=null){data.put("resolution","managed_origin_exempt");data.put("managed_origin_kind",capture.managedOrigin().kind().name().toLowerCase(java.util.Locale.ROOT));data.put("managed_origin_source",capture.managedOrigin().source().toString());data.put("spacing_applicability","authenticated_managed_origin_exempt");if(capture.managedOrigin().kind()==ManagedEncounterOrigin.Kind.DUNGEON&&capture.managedOrigin().source().toString().equals(capture.managedEvidence().get("managed_source_id")))data.putAll(capture.managedEvidence());}else if(restricted!=null){boolean blocked=award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED;data.put("resolution",blocked?"ordinary_spacing_blocked":"ordinary_spacing_eligible");data.put("spacing_applicability","ordinary_restricted_kill");data.put("spacing_minimum",rules.restrictedSpacing().toPlainString());var prior=before.restrictedKills().get(restricted.world());if(prior!=null)data.put("ordinary_history_before",position(prior));var current=after.restrictedKills().get(restricted.world());if(current!=null)data.put("ordinary_history_after",position(current));spacingEvidence(data,prior,restricted,current,rules,blocked);}else{data.put("resolution","spacing_not_applicable");data.put("spacing_applicability","fantasy_character");}var experienceLoss=allocatedExperience.compareTo(appliedExperience)>0?(award.status()==CharacterStorage.CombatAwardStatus.SPACING_BLOCKED?"restricted_kill_spacing":"maximum_level"):"none";var coinLoss=allocatedCoins.compareTo(appliedCoins)>0?loss(award.status()):"none";data.put("experience_loss_reason",experienceLoss);data.put("coin_loss_reason",coinLoss);data.put("loss_reason",!experienceLoss.equals("none")?experienceLoss:loss(award.status()));var context=context(active,player,after.name());data.put("location_capturLine truncated
|
||||
}
|
||||
private EventInput.Context context(EquipmentSessions.Active active,Player player,String characterName){
|
||||
if(active==null||player==null)return null;
|
||||
@@ -60,6 +68,20 @@ public final class CombatRewardEventBridge implements AutoCloseable {
|
||||
return new EventInput.Location(value.getWorld().getUID(),value.getWorld().getName(),value.getX(),value.getY(),value.getZ(),clock.instant(),"exact");
|
||||
}catch(RuntimeException unavailable){return null;}
|
||||
}
|
||||
private static void spacingEvidence(Map<String,Object> data,CombatRewardRules.KillPosition prior,
|
||||
CombatRewardRules.KillPosition captured,CombatRewardRules.KillPosition saved,
|
||||
CombatRewardRules rules,boolean blocked){
|
||||
var thresholdSquared=rules.restrictedSpacing().multiply(rules.restrictedSpacing());
|
||||
data.put("spacing_first_kill",prior==null);
|
||||
data.put("spacing_eligible",!blocked);
|
||||
data.put("spacing_threshold_squared",thresholdSquared.toPlainString());
|
||||
data.put("ordinary_history_updated",captured.equals(saved));
|
||||
if(prior==null){data.put("spacing_result","first_kill");return;}
|
||||
var dx=captured.x().subtract(prior.x());var dy=captured.y().subtract(prior.y());var dz=captured.z().subtract(prior.z());
|
||||
var distanceSquared=dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
|
||||
data.put("spacing_distance_squared",distanceSquared.toPlainString());
|
||||
data.put("spacing_result",blocked?"too_close":distanceSquared.compareTo(thresholdSquared)==0?"exact_threshold":"beyond_threshold");
|
||||
}
|
||||
private static Map<String,Object> position(CombatRewardRules.KillPosition value){return Map.of("world_id",value.world().toString(),"x",value.x().toPlainString(),"y",value.y().toPlainString(),"z",value.z().toPlainString());}
|
||||
private static String loss(CharacterStorage.CombatAwardStatus status){return switch(status){case SPACING_BLOCKED->"restricted_kill_spacing";case AT_CAP->"maximum_level";case UNCHANGED->"zero_experience_and_coins";case DUPLICATE->"duplicate_receipt";case UNAVAILABLE->"recipient_unavailable";case STALE->"stale_retry";case APPLIED->"none";};}
|
||||
private static UUID id(UUID operation){return UUID.nameUUIDFromBytes(("fantasystick:combat-reward:"+operation).getBytes(StandardCharsets.UTF_8));}
|
||||
|
||||
@@ -200,18 +200,27 @@ public final class DownedEventBridge {
|
||||
}
|
||||
|
||||
public void configRead(String actorKind, UUID player, String playerName, String key, String value) {
|
||||
config("config.read", actorKind, player, playerName, key, null, value);
|
||||
configRead(UUID.randomUUID(), actorKind, player, playerName, key, value);
|
||||
}
|
||||
|
||||
public void configRead(UUID operation, String actorKind, UUID player, String playerName, String key, String value) {
|
||||
config(operation, "config.read", actorKind, player, playerName, key, null, value);
|
||||
}
|
||||
|
||||
public void configChanged(String actorKind, UUID player, String playerName, String key,
|
||||
String before, String after) {
|
||||
config("config.changed", actorKind, player, playerName, key, before, after);
|
||||
configChanged(UUID.randomUUID(), actorKind, player, playerName, key, before, after);
|
||||
}
|
||||
|
||||
private void config(String type, String actorKind, UUID player, String playerName, String key,
|
||||
public void configChanged(UUID operation, String actorKind, UUID player, String playerName, String key,
|
||||
String before, String after) {
|
||||
config(operation, "config.changed", actorKind, player, playerName, key, before, after);
|
||||
}
|
||||
|
||||
private void config(UUID operation, String type, String actorKind, UUID player, String playerName, String key,
|
||||
String before, String after) {
|
||||
var now = clock.instant();
|
||||
var action = UUID.randomUUID();
|
||||
var action = operation;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", Objects.requireNonNull(actorKind));
|
||||
if (player != null) data.put("actor_player_id", player.toString());
|
||||
|
||||
@@ -28,13 +28,13 @@ public final class DungeonAuditEventBridge implements AutoCloseable {
|
||||
public void configChanged(CampEventBridge.Actor actor,UUID operation,String key,String before,String after,String defaultValue){if(!key.equals(games.dmg.fantasystick.persistence.AdventureQuestSettings.DUNGEON_ROOM_LINK_DISTANCE)||before.equals(after))return;accepted("config.changed",operation,actor,Map.of("feature","dungeon","key",key,"before",before,"after",after,"default",defaultValue,"application_scope","future_definitions_and_offers","active_work_status","unchanged"));}
|
||||
public void created(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon value){
|
||||
var data=definition(value);data.put("after_revision",value.revision().toString());data.put("after_mode",value.participationMode().value());
|
||||
data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create");
|
||||
data.put("application","future_acceptances");data.put("after_enabled",value.enabled());data.put("before_definition_status","absent");data.put("reason","administrator_create");
|
||||
accepted("dungeon.created",operation,actor,data);
|
||||
}
|
||||
public void modeChanged(UUID operation,CampEventBridge.Actor actor,DungeonState.Dungeon before,DungeonState.Dungeon after){
|
||||
if(before.participationMode()==after.participationMode())return;
|
||||
var data=definition(after);data.put("before_revision",before.revision().toString());data.put("after_revision",after.revision().toString());
|
||||
data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application_scope","future_acceptances");
|
||||
data.put("before_mode",before.participationMode().value());data.put("after_mode",after.participationMode().value());data.put("application","future_acceptances");data.put("application_scope","future_acceptances");
|
||||
accepted("dungeon.mode_changed",operation,actor,data);
|
||||
}
|
||||
/** Project only original acknowledged definition changes; reward and character-boss owners stay independent. */
|
||||
@@ -78,6 +78,7 @@ public final class DungeonAuditEventBridge implements AutoCloseable {
|
||||
private void questObserved(String type,UUID operation,CampEventBridge.Actor actor,games.dmg.fantasystick.adventure.AdventureQuestState.Quest quest,games.dmg.fantasystick.adventure.AdventureQuestState.Site site,DungeonState.Dungeon definition,String reason){
|
||||
var data=definition==null?new LinkedHashMap<String,Object>():definition(definition);data.put("dungeon_id",quest.site().toString());data.put("site_capture_quality",site==null?"unavailable_legacy_source_identity_only":"original_acknowledged_site");if(definition==null){if(site!=null)data.put("dungeon_name",site.displayName());data.put("definition_revision_status","unavailable_not_in_captured_site");}data.put("definition_capture_quality",definition==null?"unavailable":"original_request_snapshot");
|
||||
data.put("quest_id",quest.id().toString());data.put("catalog_id",quest.catalog().toString());data.put("generation_id",quest.catalog().toString());data.put("unit_id",quest.unit().toString());data.put("unit_kind",quest.unitType().name().toLowerCase(java.util.Locale.ROOT));data.put("accepted_mode",quest.participationMode().value());data.put("accepted_roster",quest.snapshot().stream().sorted().map(UUID::toString).toList());data.put("expires_at",quest.expiresAt().toString());data.put("reason",reason);
|
||||
data.put("reward_xp_pool",quest.rewardXp().toString());data.put("reward_coin_pool",quest.rewardCoins().toString());
|
||||
var qualified=UUID.nameUUIDFromBytes((operation+":"+quest.id()).getBytes(StandardCharsets.UTF_8));accepted(type,qualified,actor,data);
|
||||
}
|
||||
public void questExpired(games.dmg.fantasystick.adventure.AdventureQuestDirectory.QuestExpiry expired){questObserved("adventure.quest.expired",expired.operation(),expired.actor(),expired.quest(),expired.site(),null,expired.reason());}
|
||||
@@ -108,9 +109,11 @@ public final class DungeonAuditEventBridge implements AutoCloseable {
|
||||
else{data.put("actor_kind","unavailable");data.put("location_status","unavailable");}
|
||||
// Match the unchanged writer envelope before journaling, retaining original-actor provenance separately.
|
||||
if(actor==null||actor.context()==null){var original=data.get("actor_kind");if(!"console".equals(original)&&!"command_block".equals(original)){data.put("original_actor_kind",java.util.Objects.toString(original,"unavailable"));data.put("actor_kind","system");}}
|
||||
var id=UUID.nameUUIDFromBytes(("fantasystick:dungeon-audit:"+operation+":"+type).getBytes(StandardCharsets.UTF_8));
|
||||
var id=eventId(operation,type);
|
||||
outbox.accepted(new EventInput(id,operation,clock.instant(),type,actor==null?null:actor.context(),data));
|
||||
}
|
||||
/** Stable original owner envelope identity; no log read or new publication. */
|
||||
public static UUID eventId(UUID operation,String type){return UUID.nameUUIDFromBytes(("fantasystick:dungeon-audit:"+operation+":"+type).getBytes(StandardCharsets.UTF_8));}
|
||||
public void replay(){outbox.replay();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
@Override public void close(){outbox.close();}
|
||||
|
||||
@@ -40,6 +40,14 @@ public final class EventCaptureListener implements Listener {
|
||||
private final Clock clock;
|
||||
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
|
||||
private java.util.function.Predicate<PlayerTeleportEvent> ownedTeleport = ignored -> false;
|
||||
public void excludeOwnedTeleport(java.util.function.Predicate<PlayerTeleportEvent> owner) {
|
||||
ownedTeleport = ownedTeleport.or(Objects.requireNonNull(owner));
|
||||
}
|
||||
private boolean isOwnedTeleport(PlayerTeleportEvent event) {
|
||||
try { return ownedTeleport.test(event); }
|
||||
catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
|
||||
/** Feature owners replace the generic incoming view with one richer application record. */
|
||||
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
|
||||
@@ -133,7 +141,7 @@ public final class EventCaptureListener implements Listener {
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerSwapHandItemsEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){if(isOwnedTeleport(event))return;var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerTeleportEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleEnterEvent event){if(event.getEntered() instanceof Player player)capture(event,player,"vehicle_enter",entity(event.getVehicle(),"VehicleEnterEvent"),event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleEnterEvent event){resolveCaptured(event,event);}
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.util.Iterator;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
@@ -18,7 +20,10 @@ import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
@@ -26,14 +31,88 @@ import java.util.function.Function;
|
||||
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
|
||||
final class FeatureEventOutbox implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
// Shared internal safety envelope: queued snapshots AND unsettled sink callbacks across all owners.
|
||||
private static final int MAX_HANDOFFS = 1024;
|
||||
private static final Semaphore HANDOFFS = new Semaphore(MAX_HANDOFFS);
|
||||
// One small control record per live feature owner, never per rejected observation or pending file.
|
||||
private static final Semaphore OWNER_SLOTS = new Semaphore(MAX_HANDOFFS);
|
||||
private static final Set<FeatureEventOutbox> OWNERS = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
private static final Object MAINTENANCE_LIFETIME = new Object();
|
||||
private static java.util.concurrent.ScheduledExecutorService REPLAYS;
|
||||
private static java.util.concurrent.ScheduledExecutorService DIAGNOSTICS;
|
||||
|
||||
private static void attach(FeatureEventOutbox owner) {
|
||||
synchronized (MAINTENANCE_LIFETIME) {
|
||||
OWNERS.add(owner);
|
||||
if (REPLAYS == null) {
|
||||
REPLAYS = maintenance("fantasy-feature-replay");
|
||||
DIAGNOSTICS = maintenance("fantasy-feature-diagnostics");
|
||||
REPLAYS.scheduleWithFixedDelay(() -> OWNERS.forEach(FeatureEventOutbox::continueReplay), 100, 100, TimeUnit.MILLISECONDS);
|
||||
DIAGNOSTICS.scheduleWithFixedDelay(() -> OWNERS.forEach(FeatureEventOutbox::diagnose), 100, 100, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void detach(FeatureEventOutbox owner) {
|
||||
synchronized (MAINTENANCE_LIFETIME) {
|
||||
OWNERS.remove(owner);
|
||||
if (OWNERS.isEmpty()) {
|
||||
REPLAYS.shutdown(); DIAGNOSTICS.shutdownNow();
|
||||
REPLAYS = null; DIAGNOSTICS = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.concurrent.ScheduledExecutorService maintenance(String name) {
|
||||
return java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
var thread = new Thread(task, name); thread.setDaemon(true); return thread;
|
||||
});
|
||||
}
|
||||
|
||||
static final class HandoffRejection extends RejectedExecutionException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final long rejected, deferred;
|
||||
HandoffRejection(long rejected, long deferred) {
|
||||
super("Feature audit handoff: " + rejected + " observations rejected before durable acceptance; "
|
||||
+ deferred + " replay passes deferred with durable intents retained");
|
||||
this.rejected = rejected; this.deferred = deferred;
|
||||
}
|
||||
long rejectedCount() { return rejected; }
|
||||
long deferredCount() { return deferred; }
|
||||
}
|
||||
private final Set<String> types;
|
||||
private final Path directory;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final java.util.concurrent.atomic.AtomicBoolean workerFinished = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final ExecutorService worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
|
||||
new ArrayBlockingQueue<>(MAX_HANDOFFS), task -> new Thread(task, "fantasy-feature-event-intents"),
|
||||
new ThreadPoolExecutor.AbortPolicy()) {
|
||||
@Override protected void terminated() {
|
||||
try { closeReplayCursor(); }
|
||||
finally { workerFinished.set(true); retire(); }
|
||||
}
|
||||
};
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
private final Set<UUID> inFlight = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
// 0 detached, 1 attached, 2 retiring, 3 registering: no caller waits for maintenance or I/O.
|
||||
private final java.util.concurrent.atomic.AtomicInteger registration = new java.util.concurrent.atomic.AtomicInteger();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean closed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean replayQueued = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean diagnosing = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicBoolean closingDiagnosis = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final java.util.concurrent.atomic.AtomicLong rejected = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final java.util.concurrent.atomic.AtomicLong deferred = new java.util.concurrent.atomic.AtomicLong();
|
||||
private final Object diagnosticFence = new Object();
|
||||
// Terminal after the bounded closing attempt; late rejections cannot resurrect this owner.
|
||||
private volatile boolean diagnosticsStopped;
|
||||
// Guarded by diagnosticFence: interrupt only this owner's active aggregate callback.
|
||||
private Thread diagnosticThread;
|
||||
private volatile long lastDiagnostic;
|
||||
private volatile boolean replayRequested;
|
||||
// The directory cursor is confined to the intent worker; it retains no materialized backlog.
|
||||
private DirectoryStream<Path> replayFiles;
|
||||
private Iterator<Path> replayPaths;
|
||||
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
@@ -43,27 +122,138 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
this.types = Set.copyOf(types);
|
||||
this.sink = sink;
|
||||
this.failures = failures;
|
||||
if (!register()) throw new RejectedExecutionException("Feature audit owner capacity exhausted");
|
||||
}
|
||||
|
||||
private boolean register() {
|
||||
if (diagnosticsStopped) return false;
|
||||
if (!registration.compareAndSet(0, 3)) return true;
|
||||
if (!OWNER_SLOTS.tryAcquire()) { registration.set(0); return false; }
|
||||
attach(this); registration.set(1);
|
||||
// Closing may finish while a producer is attaching; retire that raced registration too.
|
||||
if (diagnosticsStopped) retire();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void retire() {
|
||||
if (!workerFinished.get() || (!diagnosticsStopped
|
||||
&& (rejected.get() != 0 || deferred.get() != 0 || diagnosing.get()))) return;
|
||||
if (!registration.compareAndSet(1, 2)) return;
|
||||
detach(this); OWNER_SLOTS.release(); registration.set(0);
|
||||
// A producer seeing state 2 cannot reattach until removal finishes; its counts remain owned here.
|
||||
if (!diagnosticsStopped && (rejected.get() != 0 || deferred.get() != 0)) register();
|
||||
}
|
||||
|
||||
private void rejectObservation() { rejected.incrementAndGet(); register(); }
|
||||
|
||||
private void diagnose() {
|
||||
synchronized (diagnosticFence) {
|
||||
if (diagnosticsStopped || !diagnosing.compareAndSet(false, true)) return;
|
||||
diagnosticThread = Thread.currentThread();
|
||||
}
|
||||
try {
|
||||
long lost, delayed;
|
||||
synchronized (diagnosticFence) {
|
||||
// Admission/dequeue shares the stop fence: a late count cannot be withdrawn
|
||||
// by a diagnostic pass selected just before terminal shutdown.
|
||||
if (diagnosticsStopped) return;
|
||||
long now = System.nanoTime();
|
||||
if (!closingDiagnosis.getAndSet(false) && lastDiagnostic != 0
|
||||
&& now - lastDiagnostic < TimeUnit.SECONDS.toNanos(1)) return;
|
||||
lost = rejected.getAndSet(0); delayed = deferred.getAndSet(0);
|
||||
if (lost != 0 || delayed != 0) lastDiagnostic = now;
|
||||
}
|
||||
if (lost != 0 || delayed != 0) report(new HandoffRejection(lost, delayed));
|
||||
} finally {
|
||||
synchronized (diagnosticFence) {
|
||||
diagnosticThread = null;
|
||||
diagnosing.set(false);
|
||||
// Do not leak our cooperative cancellation into another owner's callback.
|
||||
if (diagnosticsStopped) Thread.interrupted();
|
||||
diagnosticFence.notifyAll();
|
||||
}
|
||||
retire();
|
||||
}
|
||||
}
|
||||
|
||||
private void stopDiagnostics() {
|
||||
synchronized (diagnosticFence) {
|
||||
diagnosticsStopped = true;
|
||||
if (diagnosticThread != null) diagnosticThread.interrupt();
|
||||
}
|
||||
// A non-cooperative handler can still retain its own executing stack/classloader.
|
||||
// Detachment is bookkeeping retirement, never proof that diagnostics were delivered.
|
||||
retire();
|
||||
}
|
||||
|
||||
void accepted(EventInput event) {
|
||||
if (!admit()) return;
|
||||
try { worker.execute(() -> {
|
||||
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
EventInput saved;
|
||||
try { saved = write(event); }
|
||||
catch (IOException | RuntimeException failure) { HANDOFFS.release(); report(failure); return; }
|
||||
publish(saved); // The admission remains owned until delivery settles, not merely until dequeue.
|
||||
}); } catch (RejectedExecutionException failure) { HANDOFFS.release(); rejectObservation(); }
|
||||
}
|
||||
|
||||
private boolean admit() {
|
||||
if (closed.get() || worker.isShutdown() || !HANDOFFS.tryAcquire()) {
|
||||
rejectObservation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void replay() {
|
||||
if (worker.isShutdown()) return;
|
||||
try { worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (closed.get()) return;
|
||||
replayRequested = true;
|
||||
continueReplay();
|
||||
}
|
||||
|
||||
private void continueReplay() {
|
||||
if (!replayRequested || closed.get() || !replayQueued.compareAndSet(false, true)) return;
|
||||
// The command and its next handoff both need a slot. Never wait or do caller-thread I/O.
|
||||
if (HANDOFFS.availablePermits() < 2 || !HANDOFFS.tryAcquire()) {
|
||||
replayQueued.set(false);
|
||||
if (deferred.compareAndSet(0, 1)) register();
|
||||
return;
|
||||
}
|
||||
try { worker.execute(this::replayBatch); }
|
||||
catch (RejectedExecutionException failure) {
|
||||
HANDOFFS.release(); replayQueued.set(false); deferred.compareAndSet(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void replayBatch() {
|
||||
try {
|
||||
if (replayFiles == null) {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { replayRequested = false; return; }
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
replayFiles = Files.newDirectoryStream(directory, "*.pending");
|
||||
replayPaths = replayFiles.iterator();
|
||||
}
|
||||
while (replayPaths.hasNext()) {
|
||||
if (!HANDOFFS.tryAcquire()) {
|
||||
deferred.compareAndSet(0, 1);
|
||||
return; // Resume this same bounded cursor after any owner's acknowledgment releases capacity.
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
EventInput saved;
|
||||
try { saved = read(replayPaths.next()); }
|
||||
catch (IOException | RuntimeException failure) { HANDOFFS.release(); report(failure); continue; }
|
||||
publish(saved);
|
||||
}
|
||||
replayRequested = false;
|
||||
} catch (IOException | RuntimeException failure) { replayRequested = false; report(failure); }
|
||||
finally {
|
||||
if (!replayRequested || closed.get()) closeReplayCursor();
|
||||
replayQueued.set(false); HANDOFFS.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeReplayCursor() {
|
||||
if (replayFiles == null) return;
|
||||
try { replayFiles.close(); } catch (IOException failure) { report(failure); }
|
||||
finally { replayFiles = null; replayPaths = null; }
|
||||
}
|
||||
|
||||
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
|
||||
@@ -240,16 +430,17 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
if (!inFlight.add(event.id())) return;
|
||||
if (!inFlight.add(event.id())) { HANDOFFS.release(); return; }
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
try {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
} finally { inFlight.remove(event.id()); }
|
||||
} finally { inFlight.remove(event.id()); HANDOFFS.release(); }
|
||||
}); } catch (RuntimeException failure) {
|
||||
inFlight.remove(event.id());
|
||||
HANDOFFS.release();
|
||||
report(failure);
|
||||
}
|
||||
}
|
||||
@@ -263,15 +454,30 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
closingDiagnosis.set(true);
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
|
||||
synchronized (diagnosticFence) {
|
||||
while (rejected.get() != 0 || deferred.get() != 0 || diagnosing.get()) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) break; // Best-effort diagnostics stop at this lifetime's budget.
|
||||
TimeUnit.NANOSECONDS.timedWait(diagnosticFence, remaining);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
} finally { stopDiagnostics(); drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Observations not submitted to an aggregate callback, including late calls after diagnostic retirement.
|
||||
* This is neither a durable-event count nor acknowledgment of earlier callback delivery. */
|
||||
long pendingRejectedObservations() { return rejected.get(); }
|
||||
|
||||
/** Bounded closing attempt, not an acknowledgment of diagnostic delivery or handler termination. */
|
||||
CompletionStage<Void> drained() { return drained; }
|
||||
}
|
||||
@@ -53,6 +53,78 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public record TurnInAction(UUID id, java.time.Instant time, Actor actor, Map<String,Object> data) { }
|
||||
|
||||
/** Actual user input only; automatic menu refreshes must not call this boundary. */
|
||||
public TurnInAction turnIn(Player player, AdventureQuestState.Giver giver, String kind) {
|
||||
try {
|
||||
var captured = actor(player);
|
||||
if (captured == null) return null;
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("action_kind", "quest_turn_in_" + kind);
|
||||
data.put("entry_point", kind.equals("view") ? "Quest Giver turn-in entry" : "InventoryClickEvent");
|
||||
data.put("actor_kind", captured.kind());
|
||||
data.put("giver_status", giver == null ? "menu_origin_unverified" : "entered_via_registered_giver");
|
||||
if (giver != null) {
|
||||
data.put("giver_id", giver.id().toString());
|
||||
data.put("giver_location", Map.of("world_id", giver.world().toString(),
|
||||
"x", giver.x(), "y", giver.y(), "z", giver.z()));
|
||||
}
|
||||
var action = new TurnInAction(UUID.randomUUID(), clock.instant(), captured, Map.copyOf(data));
|
||||
var observation = new LinkedHashMap<String,Object>(data);
|
||||
observation.put("outcome", "observed");
|
||||
transientEvent("action.observed", action.id(), captured, observation);
|
||||
return action;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
public void turnInResolved(TurnInAction action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var data = new LinkedHashMap<String,Object>(action.data());
|
||||
data.put("outcome", outcome);
|
||||
if (reason != null) data.put("reason", reason);
|
||||
data.put("operation_id",action.id().toString());
|
||||
// An input may be pending and later refused/failed: separate facts, one shared action identity.
|
||||
try {sink.apply(new EventInput(UUID.randomUUID(),action.id(),clock.instant(),"action.resolved",action.actor().context(),data));}
|
||||
catch(RuntimeException ignored){/* Audit cannot change the source operation. */}
|
||||
}
|
||||
|
||||
public void legacyClaimed(AdventureQuestState.RewardClaim claim, Actor actor,
|
||||
AdventureQuestState.Giver giver, games.dmg.fantasystick.persistence.CharacterRecord before,
|
||||
games.dmg.fantasystick.persistence.CharacterStorage.CombatAward award,
|
||||
List<AdventureQuestState.RewardAllocation> allocations, TurnInAction request) {
|
||||
var saved = award.record().orElseThrow();
|
||||
var data = new LinkedHashMap<String,Object>();
|
||||
data.put("claim_kind", "legacy_adventure");
|
||||
data.put("claim_id", claim.id().toString()); data.put("beneficiary_id", claim.character().toString());
|
||||
data.put("requested_xp", claim.xp().toString()); data.put("requested_coins", claim.coins().toString());
|
||||
data.put("award_status", award.status().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("revision_before", before.revision().toString()); data.put("revision_after", saved.revision().toString());
|
||||
data.put("experience_after", saved.experience().toString()); data.put("coins_after", saved.coins().toString());
|
||||
data.put("level_after", Integer.toString(saved.level()));
|
||||
if (award.status() == games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.DUPLICATE)
|
||||
data.put("applied_status", "previously_applied_receipt");
|
||||
else {
|
||||
var xp = saved.experience().subtract(before.experience()).max(java.math.BigInteger.ZERO);
|
||||
var coins = saved.coins().subtract(before.coins()).max(java.math.BigInteger.ZERO);
|
||||
data.put("applied_xp", xp.toString()); data.put("lost_xp", claim.xp().subtract(xp).max(java.math.BigInteger.ZERO).toString());
|
||||
data.put("applied_coins", coins.toString()); data.put("lost_coins", claim.coins().subtract(coins).max(java.math.BigInteger.ZERO).toString());
|
||||
data.put("loss_reason", xp.compareTo(claim.xp()) < 0 ? "maximum_level_clamp" : "none");
|
||||
}
|
||||
data.put("giver_status", giver == null ? "menu_origin_unverified" : "entered_via_registered_giver");
|
||||
if (giver != null) {
|
||||
data.put("giver_id", giver.id().toString());
|
||||
data.put("giver_location", Map.of("world_id", giver.world().toString(), "x", giver.x(), "y", giver.y(), "z", giver.z()));
|
||||
}
|
||||
if (request != null) data.put("causation_id", request.id().toString());
|
||||
data.put("sources", allocations.stream().sorted(java.util.Comparator.comparing(AdventureQuestState.RewardAllocation::id))
|
||||
.map(value -> Map.of("allocation_id", value.id().toString(), "source_receipt_id", value.source().toString(),
|
||||
"source_kind", "legacy_adventure", "quest_id", value.quest().toString(),
|
||||
"catalog_id", value.catalog().toString(), "site_id", value.site().toString(),
|
||||
"allocated_xp", value.xp().toString(), "allocated_coins", value.coins().toString())).toList());
|
||||
durable("adventure.reward_claimed", claim.id(), actor == null ? new Actor("system", null) : actor, data);
|
||||
}
|
||||
|
||||
public void site(String type, UUID operation, Actor actor, AdventureQuestState.Site site,
|
||||
Map<String, Object> changed) {
|
||||
if (actor == null || site == null || site.fishing() == null) return;
|
||||
@@ -95,10 +167,14 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String effective, String defaultValue) {
|
||||
configRead(UUID.randomUUID(), actor, key, effective, defaultValue);
|
||||
}
|
||||
|
||||
public void configRead(UUID operation, Actor actor, String key, String effective, String defaultValue) {
|
||||
if (actor == null) return;
|
||||
var data = config(actor, key, defaultValue);
|
||||
data.put("effective", effective); data.put("outcome", "acknowledged_read");
|
||||
transientEvent("config.read", UUID.randomUUID(), actor, data);
|
||||
transientEvent("config.read", operation, actor, data);
|
||||
}
|
||||
|
||||
public void configChanged(Actor actor, UUID operation, String key, String before,
|
||||
@@ -128,6 +204,32 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
} catch (RuntimeException unavailable) { return new Actor("system", null); }
|
||||
}
|
||||
|
||||
/** Small immutable actor proof inside the owning submission, never a live worker-thread lookup. */
|
||||
public static String actorSnapshot(Actor actor) {
|
||||
if(actor==null||actor.context()==null)return "{}";
|
||||
var c=actor.context();var value=new com.google.gson.JsonObject();
|
||||
value.addProperty("player_id",c.playerId().toString());value.addProperty("player_name",c.playerName());
|
||||
if(c.characterId()!=null){value.addProperty("character_id",c.characterId().toString());value.addProperty("character_name",c.characterName());value.addProperty("session_id",c.sessionId().toString());}
|
||||
if(c.location()!=null){var l=c.location();var location=new com.google.gson.JsonObject();
|
||||
location.addProperty("world_id",l.worldId().toString());location.addProperty("world_name",l.worldName());
|
||||
location.addProperty("x",l.x());location.addProperty("y",l.y());location.addProperty("z",l.z());
|
||||
location.addProperty("captured_at",l.capturedAt().toString());location.addProperty("quality",l.quality());value.add("location",location);}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
public Actor actorFromSubmission(FishingState.Submission receipt) {
|
||||
try {
|
||||
var audit=receipt.audit();
|
||||
if(audit.containsKey("actor_snapshot")){
|
||||
var c=com.google.gson.JsonParser.parseString(audit.get("actor_snapshot")).getAsJsonObject();
|
||||
EventInput.Location location=null;
|
||||
if(c.has("location")){var l=c.getAsJsonObject("location");location=new EventInput.Location(UUID.fromString(l.get("world_id").getAsString()),l.get("world_name").getAsString(),l.get("x").getAsDouble(),l.get("y").getAsDouble(),l.get("z").getAsDouble(),java.time.Instant.parse(l.get("captured_at").getAsString()),l.get("quality").getAsString());}
|
||||
return new Actor("player",new EventInput.Context(UUID.fromString(c.get("player_id").getAsString()),c.get("player_name").getAsString(),UUID.fromString(c.get("character_id").getAsString()),c.get("character_name").getAsString(),UUID.fromString(c.get("session_id").getAsString()),location));
|
||||
}
|
||||
return new Actor("player",new EventInput.Context(UUID.fromString(audit.get("player_id")),audit.get("player_name"),receipt.submitter(),audit.get("character_name"),UUID.fromString(audit.get("submitter_session_id")),null));
|
||||
} catch(RuntimeException unavailable){return new Actor("system",null);}
|
||||
}
|
||||
|
||||
/** Reconstruct a combat-trophy outcome solely from the saved receipt, including after a prepared retry. */
|
||||
public void trophyEarned(FishingState.Trophy receipt) {
|
||||
if ((receipt.type() != FishingState.TrophyType.CHAMPION
|
||||
@@ -216,6 +318,10 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void durable(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
durableAt(type,operation,actor,data,clock.instant());
|
||||
}
|
||||
|
||||
public void durableAt(String type, UUID operation, Actor actor, Map<String, Object> data, java.time.Instant occurredAt) {
|
||||
if (actor == null) return;
|
||||
try {
|
||||
String primary = data.get("beneficiaries") instanceof List<?> ? "beneficiaries"
|
||||
@@ -226,7 +332,7 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
if (primary == null) { primary = secondary; secondary = null; }
|
||||
if ((primary == null || ((List<?>)data.get(primary)).size() <= 20)
|
||||
&& (secondary == null || ((List<?>)data.get(secondary)).size() <= 20)) {
|
||||
outbox.accepted(input(type, operation, actor, data));
|
||||
outbox.accepted(input(type, operation, actor, data, -1, occurredAt));
|
||||
return;
|
||||
}
|
||||
var first = (List<?>)data.get(primary);
|
||||
@@ -249,7 +355,7 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
part.put("collection_part_index", collectionPart);
|
||||
part.put("part_index", index);
|
||||
part.put("part_count", total);
|
||||
outbox.accepted(input(type, operation, actor, part, index));
|
||||
outbox.accepted(input(type, operation, actor, part, index, occurredAt));
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Audit availability cannot change acknowledged fishing. */ }
|
||||
}
|
||||
@@ -261,15 +367,15 @@ public final class FishingEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data) {
|
||||
return input(type, operation, actor, data, -1);
|
||||
return input(type, operation, actor, data, -1, clock.instant());
|
||||
}
|
||||
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part) {
|
||||
private EventInput input(String type, UUID operation, Actor actor, Map<String, Object> data, int part, java.time.Instant occurredAt) {
|
||||
var values = new LinkedHashMap<String, Object>(data);
|
||||
values.put("operation_id", operation.toString());
|
||||
var identity = "fantasystick:fishing:" + operation + ':' + type + (part < 0 ? "" : ":part:" + part);
|
||||
return new EventInput(UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)), operation,
|
||||
clock.instant(), type, actor.context(), values);
|
||||
occurredAt, type, actor.context(), values);
|
||||
}
|
||||
|
||||
private EventInput.Location safeLocation(Player player) {
|
||||
|
||||
@@ -45,7 +45,11 @@ public final class HardMobXpEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String value) {
|
||||
record("config.read", actor, UUID.randomUUID(), null, value);
|
||||
read(UUID.randomUUID(), actor, value);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String value) {
|
||||
record("config.read", actor, operation, null, value);
|
||||
}
|
||||
|
||||
public void changed(Actor actor, UUID operation, String before, String value) {
|
||||
|
||||
@@ -60,6 +60,7 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
private final java.nio.channels.FileLock writerLock;
|
||||
private long sequence;
|
||||
private Path segment;
|
||||
private Path damagedSegment;
|
||||
private LocalDate segmentDate;
|
||||
private long segmentBytes;
|
||||
private int segmentNumber;
|
||||
@@ -203,24 +204,40 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
}
|
||||
boolean recovered = false;
|
||||
for (var path : files) {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
int prefix=0,cursor=0;while(cursor<bytes.length){int end=cursor;while(end<bytes.length&&bytes[end]!='\n')end++;if(end==bytes.length)break;var line=java.util.Arrays.copyOfRange(bytes,cursor,end+1);if(!validJsonObjectLine(line))break;prefix=end+1;cursor=end+1;}if(prefix==bytes.length)continue;
|
||||
recovered = true;
|
||||
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
|
||||
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
|
||||
while (tail.hasRemaining()) channel.write(tail);
|
||||
channel.force(true);
|
||||
}
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.truncate(prefix);
|
||||
channel.force(true);
|
||||
}
|
||||
recovered |= recoverInterruptedTail(path);
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/** Quarantine must be forced before truncation; failure leaves pending work and repair state intact. */
|
||||
private boolean recoverInterruptedTail(Path path) throws IOException {
|
||||
if (Files.isSymbolicLink(path)) throw new IOException("Symbolic link in Fantasy Stick event segments");
|
||||
var bytes = Files.readAllBytes(path);
|
||||
int prefix = 0;
|
||||
int cursor = 0;
|
||||
while (cursor < bytes.length) {
|
||||
int end = cursor;
|
||||
while (end < bytes.length && bytes[end] != '\n') end++;
|
||||
if (end == bytes.length) break;
|
||||
var line = java.util.Arrays.copyOfRange(bytes, cursor, end + 1);
|
||||
if (!validJsonObjectLine(line)) break;
|
||||
prefix = end + 1;
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (prefix == bytes.length) return false;
|
||||
var quarantine = path.resolveSibling(path.getFileName() + ".corrupt-" + UUID.randomUUID());
|
||||
try (var channel = FileChannel.open(quarantine, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
var tail = ByteBuffer.wrap(bytes, prefix, bytes.length - prefix);
|
||||
while (tail.hasRemaining()) channel.write(tail);
|
||||
channel.force(true);
|
||||
}
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.WRITE)) {
|
||||
channel.truncate(prefix);
|
||||
channel.force(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int recoverPending() throws IOException {
|
||||
List<Path> files;
|
||||
try (var stream = Files.list(pending)) {
|
||||
@@ -247,6 +264,10 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void append(byte[] line) throws IOException {
|
||||
if (damagedSegment != null) {
|
||||
recoverInterruptedTail(damagedSegment);
|
||||
damagedSegment = null;
|
||||
}
|
||||
var date = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
if (segment == null || !date.equals(segmentDate)
|
||||
|| segmentBytes > 0 && segmentBytes + line.length > maximumSegmentBytes) {
|
||||
@@ -256,6 +277,14 @@ public final class LocalEventLog implements AutoCloseable {
|
||||
var buffer = ByteBuffer.wrap(line);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
} catch (IOException failure) {
|
||||
// Even open/force/close failures can leave uncertain bytes. Never reuse this
|
||||
// segment or acknowledge pending work until its tail has been repaired.
|
||||
damagedSegment = segment;
|
||||
segment = null;
|
||||
segmentDate = null;
|
||||
segmentBytes = 0;
|
||||
throw failure;
|
||||
}
|
||||
segmentBytes += line.length;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,14 @@ public final class PartyCapacityEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
read(UUID.randomUUID(), actor, effective);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var values = common(actor);
|
||||
values.put("effective", effective);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change party capacity. */ }
|
||||
|
||||
@@ -33,14 +33,16 @@ public final class PartyEventBridge {
|
||||
public void registrarPlaced(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.placed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registered_and_reconciled");}
|
||||
public void registrarRemoved(String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded){registrar("party_registrar.removed",actorKind,actorPlayer,actorName,registrar,entity,loaded,"registration_removed");}
|
||||
public void finalDefeat(UUID character,CharacterParty before,CharacterPartyDirectory.Defeat outcome){if(before==null||outcome==CharacterPartyDirectory.Defeat.UNCHANGED)return;var action=stable(before.id(),character,before.revision(),"final-death");var data=partyData(before,null);data.put("reason","final_death");data.put("subject_character_id",character.toString());data.put("popup_delivery",outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"active_former_member_delivery_queued":"not_applicable");data.put("outcome","acknowledged");data.put("operation_id",action.toString());emit(id(action,outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left"),action,clock.instant(),outcome==CharacterPartyDirectory.Defeat.PARTY_DISBANDED?"party.disbanded":"party.left",null,data);}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){config("config.read",actorKind,player,name,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){config("config.changed",actorKind,player,name,key,before,after);}
|
||||
public void configRead(String actorKind,UUID player,String name,String key,String value){configRead(UUID.randomUUID(),actorKind, player, name, key, value);}
|
||||
public void configRead(UUID operation,String actorKind,UUID player,String name,String key,String value){config(operation,"config.read",actorKind, player, name, key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String name,String key,String before,String after){configChanged(UUID.randomUUID(),actorKind, player, name, key, before, after);}
|
||||
public void configChanged(UUID operation,String actorKind,UUID player,String name,String key,String before,String after){config(operation,"config.changed",actorKind, player, name, key, before, after);}
|
||||
|
||||
private void emitAction(String type,Action action,String outcome,String reason,CharacterParty after,CharacterPartyDirectory.Offer invitation){var data=partyData(action.before(),after);data.put("action_kind","party_"+action.kind());data.put("operation_id",action.id().toString());data.put("registrar_id",action.registrar().id().toString());data.put("registrar_location",registrarLocation(action.registrar()));data.put("actor_character_id",action.context().characterId().toString());data.put("actor_player_id",action.context().playerId().toString());data.put("actor_session_id",action.context().sessionId().toString());if(action.target()!=null)data.put("target_character_id",action.target().toString());if(action.targetContext()!=null){data.put("target_player_id",action.targetContext().playerId().toString());data.put("target_session_id",action.targetContext().sessionId().toString());if(action.targetContext().playerName()!=null)data.put("target_player_name",action.targetContext().playerName());if(action.targetContext().location()!=null)data.put("target_location",locationData(action.targetContext().location()));}if(action.invitation()!=null)data.put("invitation_id",action.invitation().toString());else if(invitation!=null)data.put("invitation_id",invitation.id().toString());if(action.charge().signum()>0){data.put("charged_coins",action.charge().toString());data.put("charge_receipt",action.id().toString());}data.put("reason",action.reason());data.put("popup_delivery",type.equals("party.disbanded")?"active_former_member_delivery_queued":"not_applicable");data.put("outcome",outcome);if(reason!=null)data.put(type.startsWith("party.")?"mutation_boundary":"reason",reason);emit(id(action.id(),type),action.id(),clock.instant(),type,action.context(),data);}
|
||||
private static String type(String kind,CharacterParty before,CharacterParty after){return switch(kind){case "create"->"party.created";case "invite"->"party.invitation_created";case "cancel"->"party.invitation_cancelled";case "decline"->"party.invitation_declined";case "accept"->"party.joined";case "kick"->"party.member_kicked";case "transfer"->"party.leadership_transferred";case "disband","leader_leave"->"party.disbanded";case "leave"->before!=null&&before.leader().equals(before.members().stream().filter(id->after==null||!after.members().contains(id)).findFirst().orElse(null))?"party.disbanded":"party.left";default->"party.mutated";};}
|
||||
private static LinkedHashMap<String,Object> partyData(CharacterParty before,CharacterParty after){var data=new LinkedHashMap<String,Object>();var party=after==null?before:after;if(party!=null){data.put("party_id",party.id().toString());data.put("party_display_name",party.displayName());data.put("party_canonical_name",party.canonicalName());}if(before!=null){data.put("before_revision",before.revision().toString());data.put("before_leader_character_id",before.leader().toString());data.put("before_member_character_ids",before.members().stream().map(UUID::toString).sorted().toList());}if(after!=null){data.put("after_revision",after.revision().toString());data.put("after_leader_character_id",after.leader().toString());data.put("after_member_character_ids",after.members().stream().map(UUID::toString).sorted().toList());}return data;}
|
||||
private void registrar(String type,String actorKind,UUID actorPlayer,String actorName,PartyRegistrar registrar,UUID entity,boolean loaded,String outcome){var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(actorPlayer!=null)data.put("actor_player_id",actorPlayer.toString());if(actorName!=null)data.put("actor_player_name",actorName);data.put("registrar_id",registrar.id().toString());data.put("registrar_name",registrar.name());data.put("registrar_revision","0");data.put("operation_id",registrar.id().toString());data.put("registered_location",registrarLocation(registrar));if(entity!=null)data.put("entity_id",entity.toString());data.put("entity_status",loaded?"loaded":"unloaded");data.put("outcome",outcome);emit(id(registrar.id(),type),registrar.id(),clock.instant(),type,actorPlayer==null?null:new EventInput.Context(actorPlayer,actorName,null,null,null,null),data);}
|
||||
private void config(String type,String actorKind,UUID player,String name,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
|
||||
private void config(UUID operation,String type,String actorKind,UUID player,String name,String key,String before,String after){var action=operation;var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(name!=null)data.put("actor_player_name",name);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_party_transactions");data.put("outcome","acknowledged");emit(id(action,type),action,clock.instant(),type,player==null?null:new EventInput.Context(player,name,null,null,null,null),data);}
|
||||
private static Map<String,Object> locationData(EventInput.Location value){return Map.of("world_id",value.worldId().toString(),"world_name",value.worldName(),"x",value.x(),"y",value.y(),"z",value.z(),"captured_at",value.capturedAt().toString(),"quality",value.quality());}
|
||||
private static Map<String,Object> registrarLocation(PartyRegistrar value){return Map.of("world_id",value.world().toString(),"x",Double.toString(value.x()),"y",Double.toString(value.y()),"z",Double.toString(value.z()),"yaw",Float.toString(value.yaw()),"pitch",Float.toString(value.pitch()),"quality","registered_exact");}
|
||||
public EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
|
||||
@@ -27,15 +27,19 @@ public final class PassiveEventBridge {
|
||||
public void refused(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn,String reason){var action=captureAction(player,active,before,definition,learn);emit("action.observed",action,"observed",null,null);emit("action.resolved",action,"refused",reason,null);}
|
||||
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null);}
|
||||
public void resolved(Action action,PassiveEngine.Mutation mutation){if(mutation!=null&&mutation.status()==games.dmg.fantasystick.persistence.CharacterStorage.PassiveMutationStatus.APPLIED&&mutation.record()!=null){emit("action.resolved",action,"accepted","acknowledged",mutation.record());emit(action.learn()?"passive.learned":"passive.unlearned",action,"accepted","acknowledged",mutation.record());}else emit("action.resolved",action,"refused",mutation==null?"unavailable":mutation.status().name().toLowerCase(java.util.Locale.ROOT),mutation==null?null:mutation.record());}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){config("config.read",actorKind,player,playerName,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){config("config.changed",actorKind,player,playerName,key,before,after);}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){configRead(UUID.randomUUID(),actorKind,player,playerName,key,value);}
|
||||
public void configRead(UUID operation,String actorKind,UUID player,String playerName,String key,String value){config(operation,"config.read",actorKind,player,playerName,key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){configChanged(UUID.randomUUID(),actorKind,player,playerName,key,before,after);}
|
||||
public void configChanged(UUID operation,String actorKind,UUID player,String playerName,String key,String before,String after){config(operation,"config.changed",actorKind,player,playerName,key,before,after);}
|
||||
|
||||
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);data.putAll(action.actorMetadata());if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){config("config.read",actorKind,player,playerName,key,null,value,application);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){config("config.changed",actorKind,player,playerName,key,before,after,application);}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",application);data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){configRead(UUID.randomUUID(),actorKind,player,playerName,key,value,application);}
|
||||
public void configRead(UUID operation,String actorKind,UUID player,String playerName,String key,String value,String application){config(operation,"config.read",actorKind,player,playerName,key,null,value,application);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){configChanged(UUID.randomUUID(),actorKind,player,playerName,key,before,after,application);}
|
||||
public void configChanged(UUID operation,String actorKind,UUID player,String playerName,String key,String before,String after,String application){config(operation,"config.changed",actorKind,player,playerName,key,before,after,application);}
|
||||
private void config(UUID operation,String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(operation,type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
|
||||
private void config(UUID operation,String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){var action=operation;var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",application);data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
|
||||
}
|
||||
private Action captureAction(org.bukkit.entity.Player player,EquipmentSessions.Active active,CharacterRecord before,PassiveDefinition definition,boolean learn){
|
||||
if(!games.dmg.fantasystick.passive.StickKnockback.ID.equals(definition.id()))return new Action(UUID.randomUUID(),context(player,active,before.name()),definition.id(),learn,definition,before);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Immutable real road-effect observations; export/replay has no gameplay authority. */
|
||||
public final class PathSpeedEventBridge implements AutoCloseable {
|
||||
public record Application(UUID id, EventInput.Context context, Instant startedAt, int leaseTicks) { }
|
||||
private final Clock clock;
|
||||
private final FeatureEventOutbox outbox;
|
||||
public PathSpeedEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path directory, Consumer<Throwable> failures) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
outbox = new FeatureEventOutbox(directory, "path-speed-event-intents",
|
||||
java.util.Set.of("effect.applied", "effect.ended"), sink, failures);
|
||||
}
|
||||
public Application applied(Player player, EquipmentSessions.Active active, String name, int ticks) {
|
||||
try {
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), name,
|
||||
active.session(), location(player));
|
||||
var application = new Application(UUID.randomUUID(), context, clock.instant(), ticks);
|
||||
emit("effect.applied", application, context, "eligible_dirt_path_support", "owned_layer_applied");
|
||||
return application;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
public void ended(Application application, Player player, String reason, String disposition) {
|
||||
if (application == null) return;
|
||||
try {
|
||||
var before = application.context();
|
||||
var context = new EventInput.Context(before.playerId(), before.playerName(), before.characterId(),
|
||||
before.characterName(), before.sessionId(), location(player));
|
||||
emit("effect.ended", application, context, reason, disposition);
|
||||
} catch (RuntimeException unavailable) { /* Never retry or authorize native effects from logging. */ }
|
||||
}
|
||||
private void emit(String type, Application application, EventInput.Context context, String reason, String disposition) {
|
||||
try {
|
||||
var values = new java.util.LinkedHashMap<String, Object>();
|
||||
values.put("feature_id", "dirt_path_speed"); values.put("application_id", application.id().toString());
|
||||
values.put("effect_id", application.id().toString()); values.put("owner_character_id", context.characterId().toString());
|
||||
values.put("owner_session_id", context.sessionId().toString()); values.put("target_player_id", context.playerId().toString());
|
||||
values.put("effect_type", "minecraft:speed"); values.put("amplifier", 0); values.put("lease_ticks", application.leaseTicks());
|
||||
values.put("started_at", application.startedAt().toString()); values.put("reason", reason);
|
||||
values.put("native_disposition", disposition); values.put("native_publication", "acknowledged");
|
||||
values.put("location_capture_quality", context.location() == null ? "unavailable" : "exact");
|
||||
UUID id = UUID.nameUUIDFromBytes(("fantasystick:path-speed:" + application.id() + ":" + type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, application.id(), clock.instant(), type, context, Map.copyOf(values)));
|
||||
} catch (RuntimeException unavailable) { /* Native outcome stands even if audit admission fails. */ }
|
||||
}
|
||||
private EventInput.Location location(Player player) {
|
||||
var location = player.getLocation();
|
||||
if (location == null || location.getWorld() == null) return null;
|
||||
return new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Original per-character/quest preference facts; persistence and presentation remain their own authority. */
|
||||
public final class QuestTrackingEventBridge implements AutoCloseable {
|
||||
public record Request(UUID action,Instant time,CampEventBridge.Actor actor,AdventureQuestState.Quest quest,
|
||||
UUID character,Boolean requested) { }
|
||||
public record Acceptance(UUID action,Instant time,CampEventBridge.Actor initiator,Map<UUID,CampEventBridge.Actor> owners) {
|
||||
public Acceptance { owners=Map.copyOf(owners); }
|
||||
}
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Function<CommandSender,CampEventBridge.Actor> capture;
|
||||
public QuestTrackingEventBridge(Function<EventInput,CompletionStage<LocalEventLog.Delivery>> sink,Path data,
|
||||
Function<CommandSender,CampEventBridge.Actor> capture,Consumer<Throwable> failures){
|
||||
this.capture=Objects.requireNonNull(capture);
|
||||
outbox=new FeatureEventOutbox(data,"quest-tracking-event-intents",Set.of("preference.read","preference.changed","action.observed","action.resolved"),sink,failures);
|
||||
}
|
||||
public Request request(Player player,Boolean requested){return new Request(UUID.randomUUID(),Instant.now(),actor(player),null,null,requested);}
|
||||
public Request bind(Request request,AdventureQuestState.Quest quest,UUID character){
|
||||
return new Request(request.action(),request.time(),request.actor(),quest,character,request.requested());
|
||||
}
|
||||
public CampEventBridge.Actor actor(CommandSender sender){
|
||||
try{var value=capture.apply(sender);if(value!=null)return value;}catch(RuntimeException ignored){}
|
||||
EventInput.Context context=null;
|
||||
if(sender instanceof Player player)try{context=new EventInput.Context(player.getUniqueId(),player.getName(),null,null,null,null);}catch(RuntimeException ignored){}
|
||||
return new CampEventBridge.Actor(context,Map.of("actor_kind",sender instanceof Player?"player":"console","actor_capture_quality","optional_capture_unavailable"));
|
||||
}
|
||||
/** All native holder facts are captured before the acceptance worker; absent holders stay absent. */
|
||||
public Acceptance acceptance(UUID operation,Player initiator,Set<UUID> members,Collection<? extends Player> online){
|
||||
var original=actor(initiator);var owners=new LinkedHashMap<UUID,CampEventBridge.Actor>();
|
||||
if(original.context()!=null&&original.context().characterId()!=null&&members.contains(original.context().characterId()))owners.put(original.context().characterId(),original);
|
||||
for(var player:online)try{var value=actor(player);var context=value.context();if(context!=null&&context.characterId()!=null&&members.contains(context.characterId()))owners.putIfAbsent(context.characterId(),value);}catch(RuntimeException ignored){}
|
||||
return new Acceptance(operation,Instant.now(),original,owners);
|
||||
}
|
||||
public void accepted(Acceptance acceptance,AdventureQuestState.Quest quest){
|
||||
for(var character:quest.snapshot().stream().sorted().toList()){
|
||||
var owner=acceptance.owners().get(character);
|
||||
var action=UUID.nameUUIDFromBytes((acceptance.action()+":"+character).getBytes(StandardCharsets.UTF_8));
|
||||
var request=new Request(action,acceptance.time(),owner,quest,character,true);
|
||||
var values=common(request);values.put("origin","quest_acceptance");values.put("previous_status","absent");
|
||||
values.put("saved_value",true);values.put("outcome","acknowledged");values.put("source_acknowledgement","durable_quest_acceptance");
|
||||
values.put("origin_acceptance_action_id",acceptance.action().toString());values.put("actor_role","preference_owner");
|
||||
values.put("preference_owner_actor_status",owner==null?"unavailable_at_acceptance":"captured_original_holder");
|
||||
var initiator=acceptance.initiator().context();
|
||||
if(initiator!=null){values.put("originating_account_id",initiator.playerId().toString());if(initiator.characterId()!=null){values.put("originating_character_id",initiator.characterId().toString());values.put("originating_session_id",initiator.sessionId().toString());}}
|
||||
putActionLocation(values,acceptance.initiator());
|
||||
emit("preference.changed",request,values);
|
||||
}
|
||||
}
|
||||
public void observed(Request request){var values=common(request);values.put("outcome","observed");emit("action.observed",request,values);}
|
||||
public void refused(Request request,String reason){observed(request);resolved(request,"refused",reason,null);}
|
||||
public void read(Request request,boolean value,boolean persisted){
|
||||
var values=common(request);values.put("previous_status",persisted?"persisted":"legacy_default");values.put("previous_value",value);
|
||||
values.put("saved_value",value);values.put("outcome","acknowledged_read");emit("preference.read",request,values);
|
||||
resolved(request,"acknowledged_read",null,null);
|
||||
}
|
||||
public void changed(Request request,AdventureQuestDirectory.TrackingPublication publication,Throwable failure){
|
||||
if(failure!=null){
|
||||
var values=common(request);var proof=failure instanceof AdventureQuestDirectory.TrackingFailure observed?observed.before():null;
|
||||
if(proof!=null)publication(values,proof);else values.put("previous_status","unavailable_before_source_decision");
|
||||
values.put("outcome","failed");values.put("reason","source_operation_failed");values.put("saved_value_status","not_acknowledged");
|
||||
emit("action.resolved",request,values);return;
|
||||
}
|
||||
if(publication.status()==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){resolved(request,"stale","quest_no_longer_eligible",publication);return;}
|
||||
if(publication.status()==AdventureQuestDirectory.TrackingStatus.UNCHANGED){resolved(request,"unchanged",null,publication);return;}
|
||||
var values=common(request);publication(values,publication);values.put("outcome","acknowledged");
|
||||
values.put("source_acknowledgement","durable_quest_tracking_save");emit("preference.changed",request,values);
|
||||
resolved(request,"acknowledged",null,publication);
|
||||
}
|
||||
private void resolved(Request request,String outcome,String reason,AdventureQuestDirectory.TrackingPublication publication){
|
||||
var values=common(request);if(publication!=null)publication(values,publication);values.put("outcome",outcome);if(reason!=null)values.put("reason",reason);emit("action.resolved",request,values);
|
||||
}
|
||||
private static void publication(Map<String,Object> values,AdventureQuestDirectory.TrackingPublication publication){
|
||||
values.put("previous_status",publication.previous()==null?"not_applicable_no_eligible_quest":publication.persistedBefore()?"persisted":"legacy_default");
|
||||
if(publication.previous()!=null)values.put("previous_value",publication.previous());
|
||||
if(publication.saved()!=null)values.put("saved_value",publication.saved());
|
||||
}
|
||||
private static Map<String,Object> common(Request request){
|
||||
var values=new LinkedHashMap<String,Object>();var actor=request.actor();
|
||||
if(actor!=null)values.putAll(actor.captured());
|
||||
if(actor==null||actor.context()==null){
|
||||
var original=values.getOrDefault("actor_kind","unavailable");values.put("original_actor_kind",original);
|
||||
values.put("actor_kind","console".equals(original)||"command_block".equals(original)?original:"system");
|
||||
values.put("actor_identity_status","unavailable_original_context");
|
||||
}
|
||||
values.put("preference_key","quest-tracking");values.put("scope","character_quest");values.put("origin","command");
|
||||
values.put("operation_id",request.action().toString());values.put("source_revision_status","no_persisted_tracking_revision");
|
||||
if(request.requested()!=null)values.put("requested_value",request.requested());else values.put("requested_value_status","not_applicable_query");
|
||||
if(request.character()!=null)values.put("preference_owner_character_id",request.character().toString());
|
||||
if(request.quest()!=null){var quest=request.quest();values.put("quest_id",quest.id().toString());values.put("site_id",quest.site().toString());values.put("catalog_id",quest.catalog().toString());values.put("deadline",quest.expiresAt().toString());}
|
||||
putActionLocation(values,actor);return values;
|
||||
}
|
||||
private static void putActionLocation(Map<String,Object> values,CampEventBridge.Actor actor){
|
||||
var context=actor==null?null:actor.context();var at=context==null?null:context.location();
|
||||
if(at==null){
|
||||
var partial=actor==null?null:actor.captured().get("actor_location");
|
||||
if(partial instanceof Map<?,?>){values.put("action_location",partial);values.put("action_location_status","partial_original_capture");}
|
||||
else values.put("action_location_status","unavailable");
|
||||
return;
|
||||
}
|
||||
values.put("action_location_status","native");
|
||||
values.put("action_location",Map.of("world_id",at.worldId().toString(),"world_name",at.worldName(),"x",at.x(),"y",at.y(),"z",at.z(),"captured_at",at.capturedAt().toString(),"quality",at.quality()));
|
||||
}
|
||||
private void emit(String type,Request request,Map<String,Object> values){
|
||||
try{var id=UUID.nameUUIDFromBytes(("fantasystick:quest-tracking:"+request.action()+":"+type).getBytes(StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id,request.action(),request.time(),type,request.actor()==null?null:request.actor().context(),values));
|
||||
}catch(RuntimeException ignored){/* Audit cannot authorize or interrupt preference publication. */}
|
||||
}
|
||||
public void replay(){outbox.replay();}
|
||||
@Override public void close(){outbox.close();}
|
||||
public CompletionStage<Void> drained(){return outbox.drained();}
|
||||
}
|
||||
@@ -74,19 +74,17 @@ public final class RareEventBridge implements AutoCloseable {
|
||||
emit(stable(operation,product==RareProduct.PHOENIX_DOWN?"phoenix-purchased":"rare-purchased"),operation,
|
||||
clock.instant(),"rare_item.purchased",context,data);
|
||||
}
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){
|
||||
config("config.read",actorKind,player,playerName,key,null,value);
|
||||
}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
config("config.changed",actorKind,player,playerName,key,before,after);
|
||||
}
|
||||
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
public void configRead(String actorKind,UUID player,String playerName,String key,String value){configRead(UUID.randomUUID(),actorKind, player, playerName, key, value);}
|
||||
public void configRead(UUID operation,String actorKind,UUID player,String playerName,String key,String value){config(operation,"config.read",actorKind, player, playerName, key,null,value);}
|
||||
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after){configChanged(UUID.randomUUID(),actorKind, player, playerName, key, before, after);}
|
||||
public void configChanged(UUID operation,String actorKind,UUID player,String playerName,String key,String before,String after){config(operation,"config.changed",actorKind, player, playerName, key, before, after);}
|
||||
private void config(UUID operation,String type,String actorKind,UUID player,String playerName,String key,String before,String after){
|
||||
var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);
|
||||
if(player!=null)data.put("actor_player_id",player.toString());
|
||||
if(playerName!=null)data.put("actor_player_name",playerName);
|
||||
data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);
|
||||
data.put("application",key.endsWith("-healing-percent")?"future_potion_uses":"next_rare_restock");
|
||||
data.put("outcome","acknowledged");var operation=UUID.randomUUID();
|
||||
data.put("outcome","acknowledged");
|
||||
var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);
|
||||
emit(stable(operation,type),operation,clock.instant(),type,context,data);
|
||||
}
|
||||
|
||||
@@ -65,11 +65,14 @@ public final class RecoveryEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void configRead(Actor actor, String key, String effective) {
|
||||
configRead(UUID.randomUUID(), actor, key, effective);
|
||||
}
|
||||
|
||||
public void configRead(UUID operation, Actor actor, String key, String effective) {
|
||||
if (actor == null) return;
|
||||
var values = config(actor, key);
|
||||
values.put("effective", effective);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.accept(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Audit reads cannot change recovery. */ }
|
||||
|
||||
@@ -21,15 +21,23 @@ public final class ReviveEventBridge {
|
||||
Instant castAt,Instant cooldownUntil,double range){
|
||||
public Cast{Objects.requireNonNull(actionId);Objects.requireNonNull(caster);Objects.requireNonNull(casterCharacter);Objects.requireNonNull(casterCharacterName);Objects.requireNonNull(targetPlayer);Objects.requireNonNull(targetPlayerName);Objects.requireNonNull(targetLocation);Objects.requireNonNull(castAt);Objects.requireNonNull(cooldownUntil);}
|
||||
}
|
||||
public record Carrier(UUID id, String name, EventInput.Location location, Map<String,Object> locationData) { }
|
||||
public Carrier snapshot(Player carrier) {
|
||||
var location = carrier.getLocation();
|
||||
return new Carrier(carrier.getUniqueId(), carrier.getName(), location(location),
|
||||
locationData(location, clock.instant()));
|
||||
}
|
||||
private final Consumer<EventInput> sink;private final Clock clock;
|
||||
public ReviveEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
|
||||
public Cast cast(AbilityExecution execution,Player target){var input=execution.input();var caster=execution.character();var now=clock.instant();var action=new Cast(UUID.randomUUID(),context(input.player(),input.session(),caster.name()),caster.id(),caster.name(),target.getUniqueId(),target.getName(),locationData(target.getLocation(),now),now,caster.cooldowns().get("revive"),input.player().getLocation().distance(target.getLocation()));emitAbility("ability.cast",action,"accepted","cooldown_committed",null);return action;}
|
||||
public void resolved(Cast cast,String outcome,String stage){emitAbility("ability.resolved",cast,outcome,stage,null);}
|
||||
public void failed(Cast cast,String reason){emitAbility("ability.resolved",cast,"failed","tail_restoration",reason);}
|
||||
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,carrier,false);}
|
||||
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);}
|
||||
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier){revived(cast,reservation,dead,restored,snapshot(carrier),false);}
|
||||
public void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Carrier carrier){revived(cast,reservation,dead,restored,carrier,false);}
|
||||
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Player carrier){recovered(reservation,restored,snapshot(carrier));}
|
||||
public void recovered(RevivalStore.Reservation reservation,CharacterRecord restored,Carrier carrier){if(reservation.administrative())return;revived(null,reservation,null,restored,carrier,true);}
|
||||
|
||||
private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Player carrier,boolean recovery){var data=new LinkedHashMap<String,Object>();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.getUniqueId().toString());data.put("carrier_player_name",carrier.getName());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",locationData(carrier.getLocation(),clock.instant()));var context=new EventInput.Context(carrier.getUniqueId(),carrier.getName(),null,null,null,location(carrier.getLocation()));emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));}
|
||||
private void revived(Cast cast,RevivalStore.Reservation reservation,CharacterRecord dead,CharacterRecord restored,Carrier carrier,boolean recovery){var data=new LinkedHashMap<String,Object>();data.put("revival_kind","learned_tail");data.put("selected_branch","tail");data.put("reservation_id",reservation.id().toString());data.put("death_id",reservation.death().toString());data.put("tail_id",reservation.death().toString());data.put("caster_character_id",reservation.casterCharacter().toString());if(cast!=null){data.put("caster_player_id",cast.caster().playerId().toString());data.put("caster_player_name",cast.caster().playerName());data.put("cast_action_id",cast.actionId().toString());}data.put("carrier_player_id",carrier.id().toString());data.put("carrier_player_name",carrier.name());data.put("source_slot",reservation.sourceSlot());data.put("destination_slot",reservation.sourceAmount()==1?reservation.sourceSlot():reservation.destinationSlot());data.put("source_quantity_before",Integer.toString(reservation.sourceAmount()));data.put("source_quantity_after",Integer.toString(reservation.sourceAmount()-1));data.put("consumed_quantity","1");data.put("restored_character_id",restored.id().toString());data.put("restored_character_name",restored.name());data.put("restored_revision",restored.revision().toString());data.put("restored_health",restored.health().toPlainString());data.put("maximum_health",restored.maximumHealth().toPlainString());data.put("no_auto_entry",true);data.put("recovery_reconciliation",recovery);data.put("outcome","acknowledged");data.put("carrier_location",carrier.locationData());var context=new EventInput.Context(carrier.id(),carrier.name(),null,null,null,carrier.location());emit(id(reservation.id(),"character-revived"),reservation.id(),reservation.createdAt(),"character.revived",context,data);if(cast!=null)emitAbility("ability.resolved",cast,"restored","tail_restoration_acknowledged",null,Map.of("reservation_id",reservation.id().toString(),"death_id",reservation.death().toString(),"restored_character_id",restored.id().toString()));}
|
||||
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason){emitAbility(type,cast,outcome,stage,reason,Map.of());}
|
||||
private void emitAbility(String type,Cast cast,String outcome,String stage,String reason,Map<String,Object> extra){var data=new LinkedHashMap<String,Object>();data.put("ability_id","revive");data.put("selected_branch","tail");data.put("stage",stage);data.put("outcome",outcome);data.put("cooldown_until",cast.cooldownUntil());data.put("caster_character_id",cast.casterCharacter().toString());data.put("target_player_id",cast.targetPlayer().toString());data.put("target_player_name",cast.targetPlayerName());data.put("target_location",cast.targetLocation());data.put("final_range",Double.toString(cast.range()));data.put("line_of_sight",true);if(reason!=null)data.put("reason",reason);data.putAll(extra);emit(id(cast.actionId(),type),cast.actionId(),cast.castAt(),type,cast.caster(),data);}
|
||||
private EventInput.Context context(Player player,games.dmg.fantasystick.session.EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
|
||||
|
||||
@@ -50,12 +50,15 @@ public final class RoleplayAvailabilityEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, boolean disabled) {
|
||||
read(UUID.randomUUID(), actor, disabled);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, boolean disabled) {
|
||||
if (actor == null) return;
|
||||
var values = common(actor);
|
||||
values.put("effective", disabled);
|
||||
values.put("available", !disabled);
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var operation = UUID.randomUUID();
|
||||
try { sink.apply(new EventInput(id(operation, "config.read"), operation, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* An observation cannot change availability. */ }
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Bounded saved-read outcomes only; no character records, books, native objects or mutation authority. */
|
||||
public final class SavedCharacterEventBridge {
|
||||
public static final int SEARCH_PART_SIZE = 8;
|
||||
public record Summary(UUID character, String name, int level, boolean alive, boolean active) { }
|
||||
public static final class Search {
|
||||
private final AdminActionEventBridge.Action action;
|
||||
private final String query;
|
||||
private final boolean truncated;
|
||||
private final AtomicBoolean resolved = new AtomicBoolean();
|
||||
private Search(AdminActionEventBridge.Action action, String query) {
|
||||
this.action = action;
|
||||
var points = query.codePoints().toArray();
|
||||
truncated = points.length > 256;
|
||||
var text = new StringBuilder();
|
||||
for (int i = 0; i < Math.min(points.length, 256); i++)
|
||||
text.appendCodePoint(Character.isISOControl(points[i]) ? ' ' : points[i]);
|
||||
this.query = text.toString();
|
||||
}
|
||||
public UUID eventId() { return id(action.id(), "search", 0); }
|
||||
}
|
||||
public static final class Inspection {
|
||||
private final AdminActionEventBridge.Action action;
|
||||
private final UUID character;
|
||||
private final int offset;
|
||||
private final String entryPoint;
|
||||
private final AtomicBoolean resolved = new AtomicBoolean();
|
||||
private Inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) {
|
||||
this.action = action; this.character = character; this.offset = offset; this.entryPoint = entryPoint;
|
||||
}
|
||||
public UUID eventId() { return id(action.id(), "inspection", 0); }
|
||||
}
|
||||
public Inspection inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) {
|
||||
if (action == null) return null;
|
||||
if (offset < 0 || !(entryPoint.equals("command") || entryPoint.equals("book_navigation")))
|
||||
throw new IllegalArgumentException("Invalid saved inspection metadata");
|
||||
return new Inspection(action, action.authorized() ? character : null, offset, entryPoint);
|
||||
}
|
||||
public void inspectionResolved(Inspection request, String outcome, String reason,
|
||||
games.dmg.fantasystick.persistence.CharacterRecord record, boolean active,
|
||||
int pages, int volumes, int presentedOffset, List<String> sections) {
|
||||
if (request == null || !request.resolved.compareAndSet(false, true)) return;
|
||||
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("route", "character.show");
|
||||
values.put("actor_kind", request.action.actorKind());
|
||||
values.put("authorization", denied ? "denied" : "authorized");
|
||||
values.put("entry_point", request.entryPoint);
|
||||
values.put("requested_offset", request.offset);
|
||||
values.put("requested_volume", request.offset / 30 + 1);
|
||||
values.put("outcome", outcome); values.put("reason", reason);
|
||||
location(values, request.action);
|
||||
if (!denied && !reason.equals("sender_unsupported") && request.character != null)
|
||||
values.put("requested_character_id", request.character);
|
||||
if (!denied && outcome.equals("opened") && record != null && record.id().equals(request.character)) {
|
||||
values.put("subject_character_id", record.id()); values.put("subject_character_name", record.name());
|
||||
values.put("saved_revision", record.revision().toString());
|
||||
values.put("sheet_generation", record.sheetGeneration().toString());
|
||||
values.put("alive", !record.dead()); values.put("active", active);
|
||||
values.put("presented_pages", pages); values.put("presented_volumes", volumes);
|
||||
values.put("presented_offset", presentedOffset); values.put("exposed_sections", List.copyOf(sections));
|
||||
}
|
||||
emit(new EventInput(request.eventId(), request.action.id(), clock.instant(),
|
||||
"character.saved_inspection_resolved", request.action.context(), values));
|
||||
}
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
public SavedCharacterEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
public Search search(AdminActionEventBridge.Action action, String query) {
|
||||
return action == null ? null : new Search(action, Objects.requireNonNull(query));
|
||||
}
|
||||
public void searchResolved(Search request, String outcome, String reason,
|
||||
List<Summary> results, int unreadable, boolean complete) {
|
||||
if (request == null || !request.resolved.compareAndSet(false, true)) return;
|
||||
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
|
||||
boolean exposed = !denied && (outcome.equals("completed") || outcome.equals("incomplete") || outcome.equals("no_match"));
|
||||
var visible = exposed ? List.copyOf(results) : List.<Summary>of();
|
||||
int pages = (visible.size() + SEARCH_PART_SIZE - 1) / SEARCH_PART_SIZE;
|
||||
int parts = Math.max(1, pages);
|
||||
var time = clock.instant();
|
||||
for (int part = 0; part < parts; part++) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("route", "character.find");
|
||||
location(values, request.action);
|
||||
values.put("actor_kind", request.action.actorKind());
|
||||
values.put("authorization", denied ? "denied" : "authorized");
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
values.put("resolution_id", request.eventId());
|
||||
values.put("part_index", part + 1);
|
||||
values.put("part_count", parts);
|
||||
if (!denied && !reason.equals("sender_unsupported")) {
|
||||
values.put("query", request.query);
|
||||
values.put("query_truncated", request.truncated);
|
||||
}
|
||||
if (exposed) {
|
||||
values.put("match_count", visible.size());
|
||||
values.put("page_count", pages);
|
||||
values.put("unreadable_count", unreadable);
|
||||
values.put("catalog_complete", complete);
|
||||
values.put("results", visible.subList(part * SEARCH_PART_SIZE,
|
||||
Math.min(visible.size(), (part + 1) * SEARCH_PART_SIZE)).stream().map(summary -> {
|
||||
var value = new LinkedHashMap<String, Object>();
|
||||
value.put("character_id", summary.character()); value.put("name", summary.name());
|
||||
value.put("level", Integer.toString(summary.level()));
|
||||
value.put("alive", summary.alive()); value.put("active", summary.active());
|
||||
return value;
|
||||
}).toList());
|
||||
}
|
||||
emit(new EventInput(id(request.action.id(), "search", part), request.action.id(), time,
|
||||
"character.saved_search_resolved", request.action.context(), values));
|
||||
}
|
||||
}
|
||||
private static void location(LinkedHashMap<String, Object> values, AdminActionEventBridge.Action action) {
|
||||
if (action.context() != null) {
|
||||
if (action.context().location() == null) values.put("location_status", "unavailable");
|
||||
} else if (!action.commandLocation().isEmpty()) {
|
||||
values.put("location", action.commandLocation());
|
||||
values.put("location_status", "captured");
|
||||
} else values.put("location_status", action.actorKind().equals("command_block") ? "unavailable" : "not_applicable");
|
||||
}
|
||||
private void emit(EventInput input) {
|
||||
try { sink.accept(input); }
|
||||
catch (RuntimeException unavailable) { /* Logging cannot change or replay a read. */ }
|
||||
}
|
||||
private static UUID id(UUID action, String kind, int part) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:saved-" + kind + ":" + action + ":" + part)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,15 @@ public final class SheetResourcePackEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String key, String value) {
|
||||
read(UUID.randomUUID(), actor, key, value);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String key, String value) {
|
||||
if (actor == null) return;
|
||||
var values = config(actor, key);
|
||||
values.put("effective", safeValue(key, value));
|
||||
values.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
var action = operation;
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* A read cannot change a delivery. */ }
|
||||
|
||||
@@ -50,11 +50,15 @@ public final class SwitchConfigEventBridge implements AutoCloseable {
|
||||
}
|
||||
|
||||
public void read(Actor actor, String effective) {
|
||||
read(UUID.randomUUID(), actor, effective);
|
||||
}
|
||||
|
||||
public void read(UUID operation, Actor actor, String effective) {
|
||||
if (actor == null) return;
|
||||
var data = common(actor);
|
||||
data.put("effective", effective);
|
||||
data.put("outcome", "acknowledged_read");
|
||||
var action = UUID.randomUUID();
|
||||
var action = operation;
|
||||
try { sink.apply(new EventInput(id(action, "config.read"), action, clock.instant(),
|
||||
"config.read", actor.context(), data)); }
|
||||
catch (RuntimeException ignored) { /* Observation cannot change the policy. */ }
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class CharacterSheets {
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final NamespacedKey GENERATION = new NamespacedKey("fantasystick", "sheet_generation");
|
||||
private static final NamespacedKey REVIVAL = new NamespacedKey("fantasystick", "tail_revival_operation");
|
||||
private static final NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
|
||||
@@ -58,6 +59,25 @@ public final class CharacterSheets {
|
||||
public boolean hasRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())return false;return operation.toString().equals(item.getItemMeta().getPersistentDataContainer().get(RECOVERY,PersistentDataType.STRING));}
|
||||
public void clearRecovery(ItemStack item){if(item==null||!item.hasItemMeta())return;var meta=item.getItemMeta();meta.getPersistentDataContainer().remove(RECOVERY);item.setItemMeta(meta);}
|
||||
|
||||
public void markRevival(ItemStack item, UUID operation) {
|
||||
if (identity(item).filter(value -> !value.legacy()).isEmpty())
|
||||
throw new IllegalArgumentException("Not an explicitly generated character sheet");
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(REVIVAL, PersistentDataType.STRING, operation.toString());
|
||||
item.setItemMeta(meta);
|
||||
}
|
||||
|
||||
public boolean hasRevival(ItemStack item, UUID operation) {
|
||||
return identify(item).isPresent() && operation.toString().equals(
|
||||
item.getItemMeta().getPersistentDataContainer().get(REVIVAL, PersistentDataType.STRING));
|
||||
}
|
||||
|
||||
public void clearRevival(ItemStack item) {
|
||||
if (item == null || !item.hasItemMeta()) return;
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().remove(REVIVAL); item.setItemMeta(meta);
|
||||
}
|
||||
|
||||
/** Adds only the cosmetic model association to an already authentic sheet. */
|
||||
public boolean applyModel(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return false;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Feature-scoped native custody proof shared by the existing learned and admin Tail routes. */
|
||||
public final class TailRevivalDelivery {
|
||||
private static final CharacterSheets SHEETS = new CharacterSheets();
|
||||
private static final TailBooks TAILS = new TailBooks();
|
||||
private TailRevivalDelivery() { }
|
||||
|
||||
public static boolean currentRecipient(Player target, java.util.function.Function<java.util.UUID, Player> players) {
|
||||
return target.isOnline() && players.apply(target.getUniqueId()) == target;
|
||||
}
|
||||
|
||||
public static boolean destinationAvailable(Player target, RevivalStore.Reservation reservation) {
|
||||
if (reservation.sourceAmount() == 1) return reservation.destinationSlot() == -1;
|
||||
var item = target.getInventory().getItem(reservation.destinationSlot());
|
||||
return item == null || item.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean matchesRecord(CharacterRecord record, RevivalStore.Reservation reservation) {
|
||||
return reservation.sheetGeneration() != null && record.id().equals(reservation.character())
|
||||
&& record.sheetGeneration().equals(reservation.sheetGeneration())
|
||||
&& reservation.death().equals(record.deathId());
|
||||
}
|
||||
|
||||
public static ItemStack provisional(CharacterRecord dead, RevivalStore.Reservation reservation) {
|
||||
if (!dead.dead() || !matchesRecord(dead, reservation)
|
||||
|| reservation.phase() != RevivalStore.DeliveryPhase.DELIVERY_STARTED)
|
||||
throw new IllegalArgumentException("Unbound Tail revival");
|
||||
var living = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(),
|
||||
dead.experience(), dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(),
|
||||
dead.passives(), dead.cooldowns(), dead.restrictedKills(), false, dead.deathId(), dead.diedAt(),
|
||||
// Death-only escrow remains in the saved dead record until restore commits; a living
|
||||
// reference projection must satisfy the same zero-escrow invariant as saved restoration.
|
||||
dead.coins(), dead.coinsInitialized(), dead.rewardReceipts(), java.math.BigInteger.ZERO, dead.gear(),
|
||||
dead.sheetGeneration());
|
||||
var sheet = SHEETS.create(living); SHEETS.markRevival(sheet, reservation.id()); return sheet;
|
||||
}
|
||||
|
||||
public static boolean proves(ItemStack item, RevivalStore.Reservation reservation) {
|
||||
return reservation.phase() == RevivalStore.DeliveryPhase.DELIVERY_STARTED
|
||||
&& SHEETS.identity(item).filter(value -> !value.legacy()
|
||||
&& value.character().equals(reservation.character())
|
||||
&& value.generation().equals(reservation.sheetGeneration())).isPresent()
|
||||
&& SHEETS.hasRevival(item, reservation.id());
|
||||
}
|
||||
|
||||
/** Count marked units, not UUID references. Revoked/unrelated same-character sheets stay untouched. */
|
||||
public static int uniqueProofSlot(Player target, RevivalStore.Reservation reservation) {
|
||||
int found = -1;
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
var item = target.getInventory().getItem(slot);
|
||||
if (!proves(item, reservation)) continue;
|
||||
if (found >= 0 || item.getAmount() != 1) return -2;
|
||||
found = slot;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static boolean unchangedTail(Player target, RevivalStore.Reservation reservation) {
|
||||
var source = target.getInventory().getItem(reservation.sourceSlot());
|
||||
return source != null && source.getAmount() == reservation.sourceAmount() && matchingTail(source, reservation);
|
||||
}
|
||||
|
||||
public static boolean consumedTail(Player target, RevivalStore.Reservation reservation) {
|
||||
var source = target.getInventory().getItem(reservation.sourceSlot());
|
||||
if (reservation.sourceAmount() == 1) return !matchingTail(source, reservation);
|
||||
return source != null && source.getAmount() == reservation.sourceAmount() - 1 && matchingTail(source, reservation);
|
||||
}
|
||||
|
||||
private static boolean matchingTail(ItemStack item, RevivalStore.Reservation reservation) {
|
||||
return TAILS.identify(item).filter(value -> value.characterId().equals(reservation.character())
|
||||
&& value.deathId().equals(reservation.death())).isPresent();
|
||||
}
|
||||
|
||||
/** A RESERVED journal proves no writes began; item absence alone never proves non-delivery. */
|
||||
public static boolean provablyUnperformed(Player target, RevivalStore.Reservation reservation) {
|
||||
if (reservation.phase() != RevivalStore.DeliveryPhase.RESERVED || !unchangedTail(target, reservation)) return false;
|
||||
for (int slot = 0; slot <= 40; slot++) {
|
||||
if (slot > 35 && slot != 40) continue;
|
||||
if (SHEETS.hasRevival(target.getInventory().getItem(slot), reservation.id())) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** All native access runs on the caller's server thread, including reentrant setter postconditions. */
|
||||
public static boolean deliver(Player target, ItemStack captured, CharacterRecord dead,
|
||||
RevivalStore.Reservation reservation, java.util.function.Function<java.util.UUID, Player> players, BooleanSupplier current) {
|
||||
if (!current.getAsBoolean() || !currentRecipient(target, players)
|
||||
|| !captured.equals(target.getInventory().getItem(reservation.sourceSlot()))
|
||||
|| !destinationAvailable(target, reservation)) return false;
|
||||
var sheet = provisional(dead, reservation);
|
||||
if (reservation.sourceAmount() == 1) target.getInventory().setItem(reservation.sourceSlot(), sheet);
|
||||
else {
|
||||
target.getInventory().setItem(reservation.destinationSlot(), sheet);
|
||||
// A setter may reenter plugin/native code. Never overwrite a changed source after it returns.
|
||||
if (!current.getAsBoolean() || !currentRecipient(target, players)
|
||||
|| !captured.equals(target.getInventory().getItem(reservation.sourceSlot()))
|
||||
|| !sheet.equals(target.getInventory().getItem(reservation.destinationSlot()))) return false;
|
||||
var remaining = captured.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
|
||||
target.getInventory().setItem(reservation.sourceSlot(), remaining);
|
||||
}
|
||||
target.updateInventory();
|
||||
return current.getAsBoolean() && currentRecipient(target, players) && consumedTail(target, reservation)
|
||||
&& uniqueProofSlot(target, reservation) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload both durable authorities before acknowledgment. The provisional reference already is the
|
||||
* current-generation sheet: finalization does not mint/upgrade/replace another item. Only the
|
||||
* conditional-clear winner may report success. The issued marker remains harmless final provenance.
|
||||
* Later ordinary inventory movement is not an atomic part of the saved completion acknowledgment.
|
||||
*/
|
||||
public static void complete(CharacterStorage characters, RevivalStore reservations, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures, Player target, RevivalStore.Reservation reservation, java.util.function.Function<java.util.UUID, Player> players,
|
||||
BooleanSupplier current, java.util.function.Function<CharacterRecord, Runnable> acknowledgment, Runnable uncertain) {
|
||||
reservations.find(reservation.character()).whenComplete((saved, saveFailure) -> {
|
||||
if (saveFailure != null || saved.isEmpty() || !saved.orElseThrow().equals(reservation)) {
|
||||
schedule(nextTick, failures, () -> { if (saveFailure != null) failures.accept(saveFailure); uncertain.run(); });
|
||||
return;
|
||||
}
|
||||
characters.find(reservation.character()).whenComplete((found, loadFailure) -> schedule(nextTick, failures, () -> {
|
||||
if (loadFailure != null) { failures.accept(loadFailure); uncertain.run(); return; }
|
||||
if (!current.getAsBoolean() || !currentRecipient(target, players) || found.isEmpty()) { uncertain.run(); return; }
|
||||
var record = found.orElseThrow();
|
||||
int slot = uniqueProofSlot(target, reservation);
|
||||
if (record.dead() || !matchesRecord(record, reservation) || slot < 0
|
||||
|| !consumedTail(target, reservation)
|
||||
|| !SHEETS.authorizes(target.getInventory().getItem(slot), record)) { uncertain.run(); return; }
|
||||
final Runnable acknowledged;
|
||||
try { acknowledged = java.util.Objects.requireNonNull(acknowledgment.apply(record)); }
|
||||
catch (RuntimeException captureFailure) { failures.accept(captureFailure); uncertain.run(); return; }
|
||||
reservations.clear(reservation).whenComplete((cleared, clearFailure) ->
|
||||
schedule(nextTick, failures, () -> {
|
||||
if (clearFailure != null) { failures.accept(clearFailure); uncertain.run(); return; }
|
||||
// Successful clearance commits already proved historical completion. Later quit/expiry
|
||||
// cannot turn it into unresolved custody after deleting its proof. Feedback is gated
|
||||
// by each route; audit facts were captured above, while the native context was valid.
|
||||
if (Boolean.TRUE.equals(cleared)) acknowledged.run();
|
||||
else uncertain.run();
|
||||
}));
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private static void schedule(Consumer<Runnable> nextTick, Consumer<Throwable> failures, Runnable action) {
|
||||
try { nextTick.accept(action); } catch (RuntimeException rejected) { failures.accept(rejected); }
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
/** Apply only the shares frozen in the winning durable final-death claim. */
|
||||
public CompletionStage<Void> settleDownedDefeat(games.dmg.fantasystick.session.DownedEpisode episode){
|
||||
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
|
||||
if(episode.finalRewards().isEmpty())return CompletableFuture.completedStage(null);var capturedRewards=rewards;var settlements=new ArrayList<CompletableFuture<Void>>();for(var reward:episode.finalRewards()){UUID receipt=UUID.nameUUIDFromBytes(("downed:"+episode.id()+":"+reward.character()).getBytes(java.nio.charset.StandardCharsets.UTF_8));var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(episode.player(),"player","fantasy_character","pvp_balance",null,null,"final_death",episode.resolvedAt(),episode.attribution().contributors(),reward.unitId(),reward.unitCount(),capturedRewards.pool(CombatRewardRules.Target.FANTASY_CHARACTER,episode.attribution().victimLevel()),episode.attribution().victimCoins(),Map.of(),episode.finalRewards().stream().filter(value->value.unitId().equals(reward.unitId())).map(games.dmg.fantasystick.session.DownedEpisode.Reward::character).toList());var proposed=new PendingAward(receipt,reward.character(),reward.experience(),reward.coins(),null,capturedRewards,capture);var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);}return CompletableFuture.allOf(settlements.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -260,7 +260,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
for (UUID character : xp.keySet()) {
|
||||
String receiptAuthority=managed.isPresent()?authority:targetReward+":"+now+":"+authority;
|
||||
UUID receipt = UUID.nameUUIDFromBytes((receiptAuthority+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence);
|
||||
var unit=units.stream().filter(value->value.recipients().contains(character)).findFirst().orElseThrow();var capture=new games.dmg.fantasystick.events.CombatRewardEventBridge.Capture(target.getUniqueId(),target.getType().name().toLowerCase(java.util.Locale.ROOT),kind.name().toLowerCase(java.util.Locale.ROOT),coinTarget(target).name().toLowerCase(java.util.Locale.ROOT),defeatedCharacter==null?position(target.getLocation()):null,managed.orElse(null),defeatedCharacter==null?"mob_defeat":"fantasy_character_defeat",now,direct,String.valueOf(unit.key()),units.size(),experiencePool,coinPool,managedEvidence,unit.recipients());
|
||||
var proposed = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,capturedRewards,capture);
|
||||
var existing=pending.putIfAbsent(receipt,proposed);var operation=existing==null?proposed:existing;settlements.add(operation.settled);attempt(operation);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,13 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
|
||||
if (events != null) events.refused(audit, healing.isCancelled() ? "native_event_cancelled" : "no_positive_healing_accepted"); return;
|
||||
}
|
||||
var validated = sessions.currentRecord(active).orElse(null);
|
||||
var validatedMaximum = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (!sessions.current(active) || validated == null || !validated.revision().equals(record.revision())
|
||||
|| Double.compare(player.getHealth(), current) != 0 || validatedMaximum == null
|
||||
|| Double.compare(Math.min(validatedMaximum.getValue(), validated.maximumHealth().doubleValue()), maximum) != 0) {
|
||||
if (events != null) events.refused(audit, "native_healing_context_changed"); return;
|
||||
}
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) { if (events != null) events.refused(audit, "pending_heal_capacity"); return; }
|
||||
queue.addLast(new Heal(active, accepted, audit));
|
||||
@@ -185,46 +192,148 @@ public final class HealthSteal implements Listener, AutoCloseable {
|
||||
auditFailed(operation, "character_unavailable_or_passive_removed"); finish(character); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
double maximum = attribute == null ? Double.NaN : Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
if (!Double.isFinite(nativeBefore) || nativeBefore <= 0 || !Double.isFinite(maximum) || nativeBefore >= maximum) {
|
||||
auditFailed(operation, "no_current_native_healing_capacity"); finish(character); return;
|
||||
}
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { auditFailed(operation, "native_health_limit_unavailable"); report(character, failure); finish(character); return; }
|
||||
// Native combat, not stale saved health, supplies the baseline. The native pool stores floats:
|
||||
// persist a representable target rounded down so publication cannot amplify the accepted budget.
|
||||
double bounded = Math.min(maximum, Math.min(limit.doubleValue(), nativeBefore + operation.amount()));
|
||||
float nativeTarget = (float) bounded;
|
||||
if (!Float.isFinite(nativeTarget)) nativeTarget = Float.MAX_VALUE;
|
||||
if (nativeTarget > bounded || (double) nativeTarget - nativeBefore > operation.amount())
|
||||
nativeTarget = Math.nextDown(nativeTarget);
|
||||
if (nativeTarget <= nativeBefore) {
|
||||
auditFailed(operation, "no_positive_representable_heal"); finish(character); return;
|
||||
}
|
||||
BigDecimal target = BigDecimal.valueOf((double) nativeTarget);
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, record, nativeBefore, saved, failure)); }
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.prepareHealthStealHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> {
|
||||
if (completed.compareAndSet(false, true))
|
||||
complete(character, operation, expected, record, nativeBefore, maximum, saved, failure);
|
||||
}); }
|
||||
catch (RuntimeException rejected) { auditFailed(operation, "scheduler_rejected_after_save_uncertain"); report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, CharacterRecord before, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
double capturedMaximum, java.util.Optional<CharacterStorage.PendingHealthSteal> saved, Throwable failure) {
|
||||
CharacterRecord repair = null;
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
auditFailed(operation, closed ? "closed_after_save" : failure != null ? "health_save_failed" : "character_unavailable");
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
var proof = saved.orElseThrow(); var record = proof.saved();
|
||||
// An already matching saved target is a successful no-op save, not a new revision.
|
||||
boolean unchanged = record.equals(before) && sessions.currentRecord(operation.active()).filter(before::equals).isPresent();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) {
|
||||
auditFailed(operation, "saved_but_session_publication_refused"); return;
|
||||
|| !(unchanged || sessions.acknowledgeProgress(operation.active(), expected, record))) {
|
||||
repair = record; auditFailed(operation, "saved_but_session_publication_refused"); return;
|
||||
}
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {
|
||||
auditFailed(operation, "saved_but_native_health_changed"); return;
|
||||
repair = record; auditFailed(operation, "saved_but_native_health_changed"); return;
|
||||
}
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
|
||||
if (attribute == null) { repair = record; auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, record.health().doubleValue());
|
||||
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
|
||||
if (!Double.isFinite(maximum) || Double.compare(maximum, capturedMaximum) != 0) {
|
||||
repair = record; auditFailed(operation, "saved_but_maximum_health_changed"); return;
|
||||
}
|
||||
double publish = Math.min(maximum, Math.min(record.health().doubleValue(), nativeBefore + operation.amount()));
|
||||
if (Double.isFinite(publish) && publish > nativeBefore && Double.compare(publish, record.health().doubleValue()) == 0) {
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) { auditFailed(operation, "native_health_publication_failed"); throw nativeFailure; }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
repair = record; auditFailed(operation, "native_health_publication_failed"); throw nativeFailure;
|
||||
}
|
||||
// A setter call alone is not proof of the actual native result or retained session authority.
|
||||
if (Double.compare(player.getHealth(), publish) != 0
|
||||
|| sessions.currentRecord(operation.active()).filter(record::equals).isEmpty()) {
|
||||
repair = record; auditFailed(operation, "native_health_publication_unconfirmed"); return;
|
||||
}
|
||||
retire(character, proof, record);
|
||||
if (events != null) events.healed(operation.audit(), before, record, nativeBefore, publish, maximum, operation.amount());
|
||||
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
|
||||
} else auditFailed(operation, "saved_but_no_positive_native_heal");
|
||||
} finally { finish(character); }
|
||||
} else { repair = record; auditFailed(operation, "saved_but_no_positive_native_heal"); }
|
||||
} finally {
|
||||
if (repair == null) finish(character); else reconcile(character, operation, saved.orElseThrow(), repair, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Repair only our saved health in the same session. Preserve newer health and all unrelated progress. No native replay. */
|
||||
private void reconcile(UUID character, Heal operation, CharacterStorage.PendingHealthSteal proof,
|
||||
CharacterRecord committed, int retries) {
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
var snapshot = sessions.currentRecord(operation.active()).orElse(null);
|
||||
if (closed || player == null || snapshot == null || snapshot.revision().compareTo(committed.revision()) < 0
|
||||
|| snapshot.health().compareTo(committed.health()) != 0) {
|
||||
finish(character); return;
|
||||
}
|
||||
try {
|
||||
double current = player.getHealth();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
double maximum = attribute == null ? Double.NaN : Math.min(attribute.getValue(), snapshot.maximumHealth().doubleValue());
|
||||
if (!Double.isFinite(current) || current < 0 || !Double.isFinite(maximum) || maximum <= 0)
|
||||
throw new IllegalStateException("Native health unavailable for reconciliation");
|
||||
var completed = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
characters.reconcileHealthStealHealth(proof, snapshot, BigDecimal.valueOf(current),
|
||||
nativeLimit.get().min(BigDecimal.valueOf(maximum)))
|
||||
.whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (!completed.compareAndSet(false, true)) return;
|
||||
boolean retry = false;
|
||||
try {
|
||||
if (failure != null) report(character, failure);
|
||||
else if (saved == null || saved.isEmpty()) {
|
||||
// A combat reward can advance the revision while retaining our health.
|
||||
// Retry only a newly acknowledged snapshot, bounded; never retry a heal or an uncertain write.
|
||||
retry = retries > 0 && sessions.currentRecord(operation.active())
|
||||
.filter(value -> value.revision().compareTo(snapshot.revision()) > 0
|
||||
&& value.health().compareTo(committed.health()) == 0).isPresent();
|
||||
if (!retry) report(character, new IllegalStateException("Health reconciliation revision refused"));
|
||||
} else if (!closed && sessions.current(operation.active())) {
|
||||
var record = saved.orElseThrow();
|
||||
boolean unchanged = record.equals(snapshot)
|
||||
&& sessions.currentRecord(operation.active()).filter(snapshot::equals).isPresent();
|
||||
if (Double.compare(record.health().doubleValue(), current) == 0
|
||||
&& nativeContext(operation, current, maximum)
|
||||
&& (unchanged || sessions.acknowledgeProgress(operation.active(), snapshot.revision(), record))
|
||||
&& nativeContext(operation, current, maximum)) retire(character, proof, record);
|
||||
else report(character, new IllegalStateException("Health reconciliation publication refused"));
|
||||
}
|
||||
} finally {
|
||||
if (retry) reconcile(character, operation, proof, committed, retries - 1); else finish(character);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(character, rejected); }
|
||||
});
|
||||
} catch (RuntimeException failure) { report(character, failure); finish(character); }
|
||||
}
|
||||
private boolean nativeContext(Heal operation, double health, double maximum) {
|
||||
if (closed || !sessions.current(operation.active())) return false;
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var attribute = player == null ? null : player.getAttribute(Attribute.MAX_HEALTH);
|
||||
return record != null && attribute != null && Double.compare(player.getHealth(), health) == 0
|
||||
&& Double.compare(Math.min(attribute.getValue(), record.maximumHealth().doubleValue()), maximum) == 0;
|
||||
}
|
||||
|
||||
private void retire(UUID character, CharacterStorage.PendingHealthSteal proof, CharacterRecord settled) {
|
||||
characters.completeHealthStealHealth(proof, settled).whenComplete((completed, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(completed))
|
||||
report(character, failure != null ? failure : new IllegalStateException("Health Steal proof cleanup unconfirmed; later entry remains blocked"));
|
||||
});
|
||||
}
|
||||
|
||||
private void auditFailed(Heal operation, String reason) { if (events != null) events.failed(operation.audit(), reason); }
|
||||
|
||||
private void finish(UUID character) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
import org.bukkit.craftbukkit.potion.CraftPotionEffectType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Read-only Purpur 2651 phase/instance proof. Never changes native maps, events or mutation flags. */
|
||||
final class NativePotionState {
|
||||
private static final Field TICKING = tickingField();
|
||||
private static final StackWalker STACK = StackWalker.getInstance();
|
||||
private NativePotionState() { }
|
||||
|
||||
static void prepare() {
|
||||
if (TICKING.getType() != boolean.class) throw new IllegalStateException("Unsupported native potion phase");
|
||||
}
|
||||
|
||||
/** Reentrant event/lifecycle calls must not consume an in-flight native decision or mutate by type. */
|
||||
static boolean dispatching() {
|
||||
return STACK.walk(frames -> {
|
||||
var iterator = frames.iterator();
|
||||
for (int depth = 0; depth < 256 && iterator.hasNext(); depth++) {
|
||||
var frame = iterator.next();
|
||||
if (frame.getClassName().equals("org.bukkit.plugin.RegisteredListener")
|
||||
&& frame.getMethodName().equals("callEvent")) return true;
|
||||
if (frame.getClassName().equals("net.minecraft.world.entity.LivingEntity")
|
||||
&& frame.getMethodName().contains("Effect")) return true;
|
||||
}
|
||||
return iterator.hasNext(); // Unknown deeper context cannot authorize a mutation.
|
||||
});
|
||||
}
|
||||
|
||||
static boolean quiet(Player player) {
|
||||
try { return !TICKING.getBoolean(handle(player)); }
|
||||
catch (IllegalAccessException failure) { throw new IllegalStateException("Native potion phase unavailable", failure); }
|
||||
}
|
||||
|
||||
static MobEffectInstance identity(Player player, PotionEffectType type) {
|
||||
return handle(player).getEffect(CraftPotionEffectType.bukkitToMinecraftHolder(type));
|
||||
}
|
||||
|
||||
private static net.minecraft.server.level.ServerPlayer handle(Player player) {
|
||||
if (!(player instanceof CraftPlayer craft)) throw new IllegalStateException("Unsupported native potion holder");
|
||||
return java.util.Objects.requireNonNull(craft.getHandle(), "Native potion holder unavailable");
|
||||
}
|
||||
|
||||
private static Field tickingField() {
|
||||
try {
|
||||
var field = net.minecraft.world.entity.LivingEntity.class.getDeclaredField("tickingEffects");
|
||||
field.setAccessible(true); return field;
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Unsupported native potion internals; no unsafe fallback", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.level.EmptyBlockGetter;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.bukkit.craftbukkit.entity.CraftPlayer;
|
||||
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.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Automatic finite road travel, independent of learned passive slots and rewards. */
|
||||
public final class PathSpeed implements Listener, AutoCloseable {
|
||||
private static final int CHECKS = 256;
|
||||
public record Settings(boolean enabled, int leaseTicks) {
|
||||
public Settings { if (leaseTicks < 1 || leaseTicks > 200) throw new IllegalArgumentException("Path lease must be 1–200 ticks"); }
|
||||
public static Settings defaults() { return new Settings(true, 20); }
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new HashMap<>();
|
||||
private final ArrayDeque<State> rotation = new ArrayDeque<>();
|
||||
private volatile Settings settings;
|
||||
private BukkitTask task;
|
||||
private org.bukkit.plugin.Plugin plugin;
|
||||
private boolean closed;
|
||||
private boolean closeScheduled;
|
||||
private long generation;
|
||||
private games.dmg.fantasystick.events.PathSpeedEventBridge events;
|
||||
public void attachEvents(games.dmg.fantasystick.events.PathSpeedEventBridge bridge) {
|
||||
if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Road events already attached or evaluation started");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
public Settings settings() { return settings; }
|
||||
|
||||
public PathSpeed(EquipmentSessions sessions, Settings settings) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.settings = Objects.requireNonNull(settings);
|
||||
}
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); if (!closed) sweep(); }
|
||||
public void install(org.bukkit.plugin.Plugin owner) {
|
||||
if (closed || plugin != null) throw new IllegalStateException("Road travel already installed or closed");
|
||||
NativePotionState.prepare(); plugin = Objects.requireNonNull(owner);
|
||||
owner.getServer().getPluginManager().registerEvents(this, owner);
|
||||
task = owner.getServer().getScheduler().runTaskTimer(owner, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed || NativePotionState.dispatching()) return;
|
||||
generation++;
|
||||
for (var snapshot : sessions.rotateActiveCharacters(CHECKS)) {
|
||||
var active = snapshot.session(); var state = states.get(active.player());
|
||||
if (state != null && !state.active.equals(active)) {
|
||||
state.refresh(); if (states.get(active.player()) == state) continue;
|
||||
state = null;
|
||||
}
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) continue;
|
||||
state = new State(active, player, snapshot.record().name()); states.put(active.player(), state); rotation.addLast(state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
int count = Math.min(CHECKS, rotation.size());
|
||||
for (int i = 0; i < count; i++) {
|
||||
var state = rotation.removeFirst();
|
||||
if (states.get(state.active.player()) != state) continue;
|
||||
if (state.processedAt != generation) state.refresh();
|
||||
if (states.get(state.active.player()) == state) rotation.addLast(state);
|
||||
}
|
||||
}
|
||||
private boolean eligible(State state) {
|
||||
if (closed || !settings.enabled() || !sessions.current(state.active)) return false;
|
||||
var record = sessions.currentRecord(state.active).orElse(null);
|
||||
if (record == null || record.dead()) return false;
|
||||
var player = state.player;
|
||||
if (player.isDead() || player.getHealth() <= 0 || !player.isOnGround() || player.isInsideVehicle()
|
||||
|| player.isSwimming() || player.isFlying() || player.isGliding()) return false;
|
||||
var velocity = player.getVelocity();
|
||||
if (velocity == null || !Double.isFinite(velocity.getY()) || velocity.getY() > 0.000001) return false;
|
||||
var location = player.getLocation();
|
||||
if (location == null || !Double.isFinite(location.getX()) || !Double.isFinite(location.getY())
|
||||
|| !Double.isFinite(location.getZ()) || Math.abs(location.getX()) > 30_000_000
|
||||
|| Math.abs(location.getZ()) > 30_000_000 || Math.abs(location.getY()) > Integer.MAX_VALUE - 1.0) return false;
|
||||
if (!(player instanceof CraftPlayer craft)) return false;
|
||||
var pos = BlockPos.containing(location.getX(), location.getY() - 0.000001, location.getZ());
|
||||
// Never request a chunk load. The pinned native path collision shape provides its lowered top.
|
||||
var block = craft.getHandle().level().getBlockStateIfLoaded(pos);
|
||||
if (block == null || !block.is(Blocks.DIRT_PATH)) return false;
|
||||
var shape = block.getCollisionShape(EmptyBlockGetter.INSTANCE, pos);
|
||||
return !shape.isEmpty() && Math.abs(location.getY() - pos.getY() - shape.max(Direction.Axis.Y)) <= 0.000001;
|
||||
}
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
MobEffectInstance owned;
|
||||
int capturedTicks;
|
||||
int capturedDuration;
|
||||
long processedAt;
|
||||
int mutating;
|
||||
private static final int MAX_MUTATION_PROOFS = 32;
|
||||
final java.util.List<EntityPotionEffectEvent> foreignEvents = new java.util.ArrayList<>();
|
||||
boolean foreignUncertain;
|
||||
final java.util.List<EntityPotionEffectEvent> mutationEvents = new java.util.ArrayList<>();
|
||||
boolean mutationUncertain;
|
||||
void observeForeign(EntityPotionEffectEvent event) {
|
||||
if (foreignEvents.size() < MAX_MUTATION_PROOFS) foreignEvents.add(event);
|
||||
else foreignUncertain = true;
|
||||
}
|
||||
void beginMutation() { mutating++; mutationEvents.clear(); mutationUncertain = false; }
|
||||
void finishMutation(boolean accepted) {
|
||||
mutating--;
|
||||
long finalDecisions = mutationEvents.stream().filter(event -> !event.isCancelled()).count();
|
||||
// Only a returned native acceptance plus one completed decision proves our sole mutation.
|
||||
if (!(accepted && finalDecisions == 1 && !mutationUncertain)) {
|
||||
for (var event : mutationEvents) observeForeign(event);
|
||||
if (mutationUncertain || accepted) foreignUncertain = true;
|
||||
}
|
||||
mutationEvents.clear();
|
||||
}
|
||||
boolean foreignChange(MobEffectInstance current) {
|
||||
if (foreignUncertain) return true;
|
||||
for (var event : foreignEvents) {
|
||||
if (event.isCancelled()) continue; // Revalidate the final event after complete dispatch.
|
||||
if (event.getAction() == EntityPotionEffectEvent.Action.CHANGED && event.isOverride()) return true;
|
||||
if (event.getAction() == EntityPotionEffectEvent.Action.REMOVED && current != owned) return true;
|
||||
if (event.getAction() == EntityPotionEffectEvent.Action.ADDED) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void clearForeignProof() { foreignEvents.clear(); foreignUncertain = false; }
|
||||
final String capturedName;
|
||||
games.dmg.fantasystick.events.PathSpeedEventBridge.Application application;
|
||||
boolean applicationCaptureAttempted;
|
||||
State(EquipmentSessions.Active active, Player player, String name) { this.active = active; this.player = player; capturedName = name; }
|
||||
void end(String reason, String disposition) {
|
||||
if (events != null && application != null) events.ended(application, player, reason, disposition);
|
||||
application = null; applicationCaptureAttempted = false;
|
||||
}
|
||||
String endReason() {
|
||||
if (closed) return "plugin_shutdown";
|
||||
if (!settings.enabled()) return "road_policy_disabled";
|
||||
if (!sessions.current(active)) return "session_no_longer_current";
|
||||
return "road_eligibility_ended";
|
||||
}
|
||||
boolean quiet() { return mutating == 0 && !NativePotionState.dispatching() && NativePotionState.quiet(player); }
|
||||
boolean matches(MobEffectInstance current) {
|
||||
long age = Integer.toUnsignedLong(player.getTicksLived() - capturedTicks);
|
||||
// A zero-duration instance whose native expiry was refused still requires owned cleanup.
|
||||
return current != null && current == owned && !foreignChange(current) && current.hiddenEffect == null
|
||||
&& current.getAmplifier() == 0 && !current.isAmbient() && !current.isVisible() && current.showIcon()
|
||||
&& current.getDuration() == Math.max(0L, capturedDuration - age);
|
||||
}
|
||||
void refresh() {
|
||||
processedAt = generation;
|
||||
try {
|
||||
if (!quiet()) return;
|
||||
var current = NativePotionState.identity(player, PotionEffectType.SPEED);
|
||||
boolean allowed = eligible(this);
|
||||
if (owned != null && !matches(current)) {
|
||||
long age = Integer.toUnsignedLong(player.getTicksLived() - capturedTicks);
|
||||
if (!(allowed && current == null && !foreignChange(current) && age >= capturedDuration))
|
||||
end("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved");
|
||||
owned = null; clearForeignProof();
|
||||
}
|
||||
if (!allowed) {
|
||||
if (owned != null && matches(current)) {
|
||||
beginMutation();
|
||||
try { player.removePotionEffect(PotionEffectType.SPEED); }
|
||||
finally { finishMutation(false); }
|
||||
current = NativePotionState.identity(player, PotionEffectType.SPEED);
|
||||
if (current == owned) return; // Final cancellation/uncertain removal is not cleanup.
|
||||
owned = null;
|
||||
end(endReason(), current == null ? "owned_layer_removed" : "different_native_layer_preserved");
|
||||
}
|
||||
if (application != null) end(endReason(), "native_layer_absent");
|
||||
states.remove(active.player(), this); return;
|
||||
}
|
||||
if (current != null && owned == null) return; // Equal/stronger/foreign layers are never ours.
|
||||
var requested = new PotionEffect(PotionEffectType.SPEED, settings.leaseTicks(), 0, false, false, true);
|
||||
clearForeignProof(); beginMutation();
|
||||
boolean accepted = false;
|
||||
try { accepted = player.addPotionEffect(requested, true); }
|
||||
finally { finishMutation(accepted); }
|
||||
if (!accepted || !quiet()) return;
|
||||
var installed = NativePotionState.identity(player, PotionEffectType.SPEED);
|
||||
if (installed == null || installed.hiddenEffect != null || foreignChange(installed)
|
||||
|| installed.getAmplifier() != 0 || installed.getDuration() != requested.getDuration()
|
||||
|| installed.isAmbient() || installed.isVisible() || !installed.showIcon()) return;
|
||||
owned = installed; capturedDuration = requested.getDuration(); capturedTicks = player.getTicksLived();
|
||||
// Native dispatch can exit/down/move the holder or close this owner before admission returns.
|
||||
if (!eligible(this)) { refresh(); return; }
|
||||
if (events != null && !applicationCaptureAttempted) {
|
||||
applicationCaptureAttempted = true;
|
||||
application = events.applied(player, active,
|
||||
sessions.currentRecord(active).map(record -> record.name()).orElse(capturedName), capturedDuration);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { /* Failed native reads/calls retain existing cleanup responsibility. */ }
|
||||
}
|
||||
}
|
||||
public boolean hasRoadLease(Player player) {
|
||||
var state = states.get(player.getUniqueId()); return state != null && state.player == player && state.owned != null;
|
||||
}
|
||||
|
||||
/** Internal equal-source handoff; only a proved road layer may be retired for a persistent source. */
|
||||
public boolean releaseForPersistent(Player player) {
|
||||
var state = states.get(player.getUniqueId());
|
||||
if (state == null || state.player != player) return false;
|
||||
try {
|
||||
if (!state.quiet() || !state.matches(NativePotionState.identity(player, PotionEffectType.SPEED))) return false;
|
||||
state.beginMutation();
|
||||
try { player.removePotionEffect(PotionEffectType.SPEED); }
|
||||
finally { state.finishMutation(false); }
|
||||
if (NativePotionState.identity(player, PotionEffectType.SPEED) != null) return false;
|
||||
state.owned = null; state.clearForeignProof();
|
||||
state.end("persistent_speed_handoff", "owned_layer_removed"); return true;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void mutation(EntityPotionEffectEvent event) {
|
||||
if (event.getModifiedType() != PotionEffectType.SPEED) return;
|
||||
var state = states.get(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
if (state.mutating != 0) {
|
||||
if (state.mutationEvents.size() < State.MAX_MUTATION_PROOFS) state.mutationEvents.add(event);
|
||||
else state.mutationUncertain = true;
|
||||
return; // Capture cancelled and nested decisions too; dispatch order is not source identity.
|
||||
}
|
||||
if (event.isCancelled()) return;
|
||||
if (event.getCause() == EntityPotionEffectEvent.Cause.EXPIRATION
|
||||
&& event.getAction() == EntityPotionEffectEvent.Action.REMOVED) {
|
||||
try {
|
||||
if (state.owned != null && state.owned == NativePotionState.identity(state.player, PotionEffectType.SPEED)) return;
|
||||
} catch (RuntimeException unavailable) { /* Unknown expiry cannot acquire renewal/removal authority. */ }
|
||||
}
|
||||
state.observeForeign(event); // Final outcomes are checked later, outside dispatch.
|
||||
}
|
||||
private void closingAttempt() {
|
||||
generation++;
|
||||
int count = Math.min(CHECKS, rotation.size());
|
||||
for (int index = 0; index < count; index++) {
|
||||
var state = rotation.removeFirst();
|
||||
if (states.get(state.active.player()) != state) continue;
|
||||
state.refresh();
|
||||
if (states.get(state.active.player()) == state) rotation.addLast(state);
|
||||
}
|
||||
}
|
||||
private void finishClose() {
|
||||
closingAttempt();
|
||||
if (!states.isEmpty() && !closeScheduled && plugin != null && plugin.isEnabled()) {
|
||||
closeScheduled = true;
|
||||
try { plugin.getServer().getScheduler().runTask(plugin, this::closingAttempt); }
|
||||
catch (RuntimeException unavailable) { /* Finite/native-refusal fallback; no population-wide or endless retry. */ }
|
||||
}
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (task != null) task.cancel();
|
||||
finishClose();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
@@ -18,31 +19,52 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
MobEffectInstance nativeOwned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
long applications;
|
||||
PassivePotionEventBridge.Application application;
|
||||
int mutating;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
boolean quiet() {
|
||||
if (mutating != 0) return false;
|
||||
try { return NativePotionState.quiet(player); }
|
||||
catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
boolean owns(PotionEffect current, MobEffectInstance identity) {
|
||||
return nativeOwned != null && nativeOwned == identity && same(current, owned);
|
||||
}
|
||||
void refresh() {
|
||||
if (!quiet()) return;
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
PotionEffect current; MobEffectInstance identity;
|
||||
try { current = player.getPotionEffect(type); identity = NativePotionState.identity(player, type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) {
|
||||
if (owned != null && nativeOwned == null && current != null) return; // Unconfirmed application, not removal authority.
|
||||
if (owned != null && !owns(current, identity)) {
|
||||
endAudit("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
|
||||
owned = null; displaced = null;
|
||||
owned = null; nativeOwned = null; displaced = null;
|
||||
}
|
||||
if (!eligible()) { remove(endReason()); return; }
|
||||
if (road != null && current != null && !current.isInfinite() && current.getAmplifier() == amplifier
|
||||
&& road.releaseForPersistent(player)) {
|
||||
current = null; identity = null;
|
||||
if (!eligible()) { remove(endReason()); return; }
|
||||
}
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
if (!quiet()) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
mutating++;
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
nativeOwned = NativePotionState.identity(player, type);
|
||||
if (nativeOwned == null) return; // Preserve responsibility without inventing a native acknowledgment.
|
||||
if (events != null) {
|
||||
try {
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
@@ -52,7 +74,11 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
}
|
||||
applications++;
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
} catch (RuntimeException unavailable) { /* Preserve any captured native ownership and displaced evidence. */ }
|
||||
finally {
|
||||
mutating--;
|
||||
if (closed) finishClose();
|
||||
}
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
@@ -76,29 +102,36 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
application = null;
|
||||
}
|
||||
void remove(String reason) {
|
||||
if (!quiet()) return;
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
PotionEffect current; MobEffectInstance identity;
|
||||
try { current = player.getPotionEffect(type); identity = NativePotionState.identity(player, type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
if (nativeOwned == null && current != null) return;
|
||||
if (owns(current, identity)) {
|
||||
if (!quiet()) return;
|
||||
mutating++;
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
current = player.getPotionEffect(type);
|
||||
identity = NativePotionState.identity(player, type);
|
||||
// The original instance, not an event flag or an equal Bukkit snapshot, is the lease.
|
||||
if (identity == nativeOwned) return;
|
||||
boolean restored = false;
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
if (current == null && identity == null && displaced != null
|
||||
&& (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
if (!NativePotionState.quiet(player)) return;
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
restored = player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
if (events != null) {
|
||||
try {
|
||||
var after = player.getPotionEffect(type);
|
||||
if (!same(after, owned)) endAudit(reason, "owned_layer_removed", after, restored);
|
||||
} catch (RuntimeException unavailable) { /* No native acknowledgement: do not invent an end record. */ }
|
||||
}
|
||||
if (restored) current = player.getPotionEffect(type);
|
||||
endAudit(reason, current != null && !restored ? "different_native_layer_preserved" : "owned_layer_removed", current, restored);
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
finally { mutating--; }
|
||||
} else endAudit(reason, current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
|
||||
owned = null; displaced = null;
|
||||
owned = null; nativeOwned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
@@ -114,8 +147,17 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private org.bukkit.plugin.Plugin plugin;
|
||||
private boolean closed;
|
||||
private boolean closing;
|
||||
private boolean closeScheduled;
|
||||
private PassivePotionEventBridge events;
|
||||
private PathSpeed road;
|
||||
|
||||
void attachRoad(PathSpeed value) {
|
||||
if (closed || road != null || !states.isEmpty()) throw new IllegalStateException("Road coordination already attached or started");
|
||||
road = Objects.requireNonNull(value);
|
||||
}
|
||||
|
||||
void attachEvents(PassivePotionEventBridge bridge) {
|
||||
if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Passive potion events already attached or evaluation started");
|
||||
@@ -131,12 +173,15 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
|
||||
void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
if (closed || sweepTask != null || this.plugin != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
NativePotionState.prepare();
|
||||
// Native identity proof needs no event listener, including during partial installation failure.
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
void sweep() {
|
||||
if (closed) return;
|
||||
if (closed || NativePotionState.dispatching()) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
@@ -176,11 +221,33 @@ final class PersistentPotionEffect implements AutoCloseable {
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
private void finishClose() {
|
||||
if (!closed || closing) return;
|
||||
if (NativePotionState.dispatching() || states.values().stream().anyMatch(state -> !state.quiet())) {
|
||||
if (!closeScheduled && plugin != null && plugin.isEnabled()) {
|
||||
closeScheduled = true;
|
||||
try { plugin.getServer().getScheduler().runTask(plugin, () -> {
|
||||
// One bounded post-dispatch attempt, never a self-rescheduling shutdown loop.
|
||||
if (NativePotionState.dispatching()) return;
|
||||
sweepGeneration++;
|
||||
closing = true;
|
||||
try { for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown"); }
|
||||
finally { closing = false; }
|
||||
}); }
|
||||
catch (RuntimeException unavailable) { /* Retain unresolved ownership; no false cleanup acknowledgment. */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
closing = true;
|
||||
try { for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown"); }
|
||||
finally { closing = false; }
|
||||
// Unconfirmed leases remain explicit in this owner. There is no cross-restart removal authority.
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown");
|
||||
states.clear();
|
||||
finishClose();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public final class SpeedI implements AutoCloseable {
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void attachRoad(PathSpeed road) { effect.attachRoad(road); }
|
||||
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Frozen operation proofs, independent of the bounded gameplay reward ledger. All access owns the records lock. */
|
||||
final class AdminCoinOperationStore {
|
||||
enum Phase { PREPARED, APPLIED, FAILED, UNCERTAIN }
|
||||
record Proof(UUID operation, BigInteger delta, CharacterRecord before, CharacterRecord after, Phase phase) {
|
||||
Proof phase(Phase value) { return new Proof(operation, delta, before, after, value); }
|
||||
}
|
||||
private final Path directory;
|
||||
AdminCoinOperationStore(Path data) { directory = data.resolve("admin-coin-operations"); }
|
||||
Optional<Proof> find(UUID operation) throws IOException {
|
||||
var path = directory.resolve(operation + ".proof");
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe coin operation proof");
|
||||
var fields = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(3_145_729);
|
||||
if (bytes.length > 3_145_728) throw new IOException("Coin operation proof too large");
|
||||
fields.load(new ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
if (!fields.getProperty("schema").equals("1")) throw new IllegalArgumentException();
|
||||
var proof = new Proof(UUID.fromString(fields.getProperty("operation")), new BigInteger(fields.getProperty("delta")),
|
||||
CharacterYaml.decode(fields.getProperty("before")), CharacterYaml.decode(fields.getProperty("after")),
|
||||
Phase.valueOf(fields.getProperty("phase")));
|
||||
var before = proof.before();
|
||||
var receipts = new java.util.HashSet<>(before.rewardReceipts());
|
||||
if (receipts.size() < 4096) receipts.add(operation);
|
||||
var expected = new CharacterRecord(before.id(), before.name(), before.revision().add(BigInteger.ONE), before.stats(),
|
||||
before.level(), before.experience(), before.unspentPoints(), before.health(), before.learned(), before.loadout(),
|
||||
before.passives(), before.cooldowns(), before.restrictedKills(), before.dead(), before.deathId(), before.diedAt(),
|
||||
before.coins().add(proof.delta()), before.coinsInitialized(), receipts, before.pvpCoinEscrow(), before.gear(), before.sheetGeneration());
|
||||
if (!operation.equals(proof.operation()) || proof.delta().signum() == 0 || before.dead()
|
||||
|| !before.coinsInitialized() || before.rewardReceipts().contains(operation)
|
||||
|| !expected.equals(proof.after())) throw new IllegalArgumentException();
|
||||
return Optional.of(proof);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid coin operation proof"); }
|
||||
}
|
||||
void write(Proof proof) throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Unsafe coin operation directory");
|
||||
Files.createDirectories(directory);
|
||||
var fields = new Properties();
|
||||
fields.setProperty("schema", "1"); fields.setProperty("operation", proof.operation().toString());
|
||||
fields.setProperty("delta", proof.delta().toString()); fields.setProperty("phase", proof.phase().name());
|
||||
fields.setProperty("before", CharacterYaml.encode(proof.before())); fields.setProperty("after", CharacterYaml.encode(proof.after()));
|
||||
var bytes = new ByteArrayOutputStream(); fields.store(bytes, "frozen administrator coin operation");
|
||||
var temporary = Files.createTempFile(directory, ".coins-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
var buffer = ByteBuffer.wrap(bytes.toByteArray());
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, directory.resolve(proof.operation() + ".proof"), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public final class AdminReviveSettings implements AutoCloseable {
|
||||
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();}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.timeout());publishedSequence=update.sequence();return true;}
|
||||
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);}
|
||||
}
|
||||
@@ -34,9 +34,11 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
|
||||
public static final String ADDITIONAL = "chain-lightning-additional-targets";
|
||||
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
|
||||
public static final String INT_STEP = "chain-lightning-target-int-step";
|
||||
public static final String MAX_TARGETS = "chain-lightning-max-targets";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER, INT_STEP, MAX_TARGETS);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "4", MULTIPLIER, "0.75", INT_STEP, "5", MAX_TARGETS, "12");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) {
|
||||
@@ -112,7 +114,8 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
public static ChainLightning.Settings parse(Map<String, String> values) {
|
||||
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER))); }
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER)),
|
||||
new BigDecimal(values.get(INT_STEP)).toBigIntegerExact(), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
|
||||
}
|
||||
|
||||
@@ -23,19 +23,44 @@ final class CharacterRecoveryStore {
|
||||
enum Phase { PREPARED, COMMITTED, DELIVERED }
|
||||
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
|
||||
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase,
|
||||
java.util.List<games.dmg.fantasystick.events.EventInput> events) {
|
||||
Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration, BigInteger replacementGeneration,
|
||||
boolean revive, UUID death, Phase phase) {
|
||||
this(operation, character, administrator, recipient, expectedRevision, expectedGeneration,
|
||||
replacementGeneration, revive, death, phase, java.util.List.of());
|
||||
}
|
||||
Operation {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
|
||||
events = java.util.List.copyOf(events);
|
||||
if (events.size() > 8 || events.stream().map(games.dmg.fantasystick.events.EventInput::id).distinct().count() != events.size())
|
||||
throw new IllegalArgumentException("Invalid recovery audit identities");
|
||||
for (var event : events) {
|
||||
if (!event.actionId().equals(operation) || !java.util.Set.of("character.recovery_committed",
|
||||
"character.recovery_uncertain", "character.recovery_completed").contains(event.type())
|
||||
|| !character.toString().equals(event.data().get("recovered_character_id"))
|
||||
|| !administrator.toString().equals(event.data().get("admin_player_id"))
|
||||
|| !recipient.toString().equals(event.data().get("recipient_player_id"))
|
||||
|| !expectedRevision.toString().equals(event.data().get("revision_before"))
|
||||
|| !expectedRevision.add(BigInteger.ONE).toString().equals(event.data().get("revision_after"))
|
||||
|| !expectedGeneration.toString().equals(event.data().get("generation_before"))
|
||||
|| !replacementGeneration.toString().equals(event.data().get("generation_after"))
|
||||
|| event.type().equals("character.recovery_completed") && phase != Phase.DELIVERED)
|
||||
throw new IllegalArgumentException("Recovery audit does not match operation proof");
|
||||
}
|
||||
if (!events.isEmpty() && !events.getFirst().type().equals("character.recovery_committed"))
|
||||
throw new IllegalArgumentException("Missing committed recovery capture");
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|
||||
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
|
||||
}
|
||||
Operation phase(Phase value) {
|
||||
return new Operation(operation, character, administrator, recipient, expectedRevision,
|
||||
expectedGeneration, replacementGeneration, revive, death, value);
|
||||
expectedGeneration, replacementGeneration, revive, death, value, events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,16 +90,85 @@ final class CharacterRecoveryStore {
|
||||
var existing = find(operation.character());
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
|
||||
throw new IOException("Character recovery is unresolved");
|
||||
// Preserve the previous acknowledged audit before a new operation replaces character custody.
|
||||
if (existing.isPresent() && !existing.orElseThrow().events().isEmpty()) archive(existing.orElseThrow());
|
||||
write(operation);
|
||||
});
|
||||
}
|
||||
|
||||
@FunctionalInterface interface AuditVisitor { void accept(Operation operation, boolean historical) throws IOException; }
|
||||
|
||||
/** Stream owner proofs, not a materialized backlog; history is audit-only and never gameplay authority. */
|
||||
void visitAudits(AuditVisitor visitor) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
safeDirectory();
|
||||
visit(directory, false, visitor);
|
||||
Path history = directory.resolve("audit-history");
|
||||
if (Files.exists(history, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(history, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(history))
|
||||
throw new IOException("Unsafe recovery audit history");
|
||||
visit(history, true, visitor);
|
||||
}
|
||||
}
|
||||
|
||||
private void visit(Path folder, boolean historical, AuditVisitor visitor) throws IOException {
|
||||
try (var files = Files.newDirectoryStream(folder, "*.yml")) {
|
||||
for (var path : files) {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)
|
||||
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Unsafe recovery audit proof");
|
||||
var operation = decode(Files.readString(path, StandardCharsets.UTF_8));
|
||||
String expectedName = (historical ? operation.operation() : operation.character()) + ".yml";
|
||||
if (!path.getFileName().toString().equals(expectedName) || historical && operation.phase() != Phase.DELIVERED)
|
||||
throw new IOException("Recovery audit filename or phase conflicts");
|
||||
if (!operation.events().isEmpty()) visitor.accept(operation, historical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void archive(Operation operation) throws IOException {
|
||||
Path history = directory.resolve("audit-history");
|
||||
if (Files.exists(history, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(history, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(history))
|
||||
throw new IOException("Unsafe recovery audit history");
|
||||
} else { Files.createDirectory(history); DurableFiles.forceDirectory(directory); }
|
||||
Path destination = history.resolve(operation.operation() + ".yml");
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(destination)
|
||||
|| Files.size(destination) > MAX_FILE_BYTES || !decode(Files.readString(destination)).equals(operation))
|
||||
throw new IOException("Recovery audit history conflicts");
|
||||
return;
|
||||
}
|
||||
writeTo(operation, destination);
|
||||
}
|
||||
|
||||
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
|
||||
advance(character, operation, expected, replacement, null);
|
||||
}
|
||||
|
||||
void advance(UUID character, UUID operation, Phase expected, Phase replacement,
|
||||
games.dmg.fantasystick.events.EventInput event) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!current.operation().equals(operation) || current.phase() != expected)
|
||||
throw new IOException("Recovery operation changed");
|
||||
write(current.phase(replacement));
|
||||
var events = new java.util.ArrayList<>(current.events());
|
||||
if (event != null) events.add(event);
|
||||
write(new Operation(current.operation(), current.character(), current.administrator(), current.recipient(),
|
||||
current.expectedRevision(), current.expectedGeneration(), current.replacementGeneration(),
|
||||
current.revive(), current.death(), replacement, events));
|
||||
});
|
||||
}
|
||||
|
||||
void retainEvent(UUID character, UUID operation, games.dmg.fantasystick.events.EventInput event) throws IOException {
|
||||
withLock(() -> {
|
||||
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
|
||||
if (!current.operation().equals(operation) || current.phase() != Phase.COMMITTED)
|
||||
throw new IOException("Recovery uncertainty conflicts with custody");
|
||||
if (current.events().stream().anyMatch(saved -> saved.id().equals(event.id()))) return;
|
||||
var events = new java.util.ArrayList<>(current.events()); events.add(event);
|
||||
write(new Operation(current.operation(), current.character(), current.administrator(), current.recipient(),
|
||||
current.expectedRevision(), current.expectedGeneration(), current.replacementGeneration(),
|
||||
current.revive(), current.death(), current.phase(), events));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,8 +185,13 @@ final class CharacterRecoveryStore {
|
||||
}
|
||||
|
||||
private void write(Operation operation) throws IOException {
|
||||
writeTo(operation, file(operation.character()));
|
||||
}
|
||||
|
||||
private void writeTo(Operation operation, Path destination) throws IOException {
|
||||
safeDirectory();
|
||||
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Recovery proof too large");
|
||||
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
@@ -100,9 +199,9 @@ final class CharacterRecoveryStore {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, file(operation.character()), StandardCopyOption.ATOMIC_MOVE,
|
||||
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
DurableFiles.forceDirectory(destination.getParent());
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
@@ -136,7 +235,11 @@ final class CharacterRecoveryStore {
|
||||
+ "replacement-generation: " + value.replacementGeneration() + "\n"
|
||||
+ "revive: " + value.revive() + "\n"
|
||||
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
|
||||
+ "phase: " + value.phase() + "\n";
|
||||
+ "phase: " + value.phase() + "\n"
|
||||
+ (value.events().isEmpty() ? "" : "events: " + java.util.Base64.getEncoder().encodeToString(
|
||||
new com.google.gson.Gson().toJson(value.events().stream()
|
||||
.map(games.dmg.fantasystick.events.CharacterRecoveryEventBridge::encode).toList())
|
||||
.getBytes(StandardCharsets.UTF_8)) + "\n");
|
||||
}
|
||||
|
||||
private static Operation decode(String text) throws IOException {
|
||||
@@ -148,6 +251,13 @@ final class CharacterRecoveryStore {
|
||||
throw new IOException("Invalid recovery record");
|
||||
}
|
||||
try {
|
||||
String audit = values.remove("events");
|
||||
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
if (audit != null) {
|
||||
var array = com.google.gson.JsonParser.parseString(new String(java.util.Base64.getDecoder().decode(audit),
|
||||
StandardCharsets.UTF_8)).getAsJsonArray();
|
||||
for (var item : array) events.add(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.decode(item.getAsString()));
|
||||
}
|
||||
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
|
||||
"administrator", "recipient", "expected-revision", "expected-generation",
|
||||
"replacement-generation", "revive", "death", "phase"))
|
||||
@@ -159,7 +269,7 @@ final class CharacterRecoveryStore {
|
||||
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
|
||||
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
|
||||
new BigInteger(values.get("replacement-generation")), revive,
|
||||
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
|
||||
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")), events);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,50 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final CharacterRecoveryStore recoveries;
|
||||
private final SecondWindHealthStore secondWindHealth;
|
||||
private final HealthStealHealthStore healthStealHealth;
|
||||
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge recoveryEvents;
|
||||
|
||||
public void attachRecoveryEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
|
||||
if (worker.isShutdown() || recoveryEvents != null) throw new IllegalStateException("Recovery events already attached or storage closed");
|
||||
recoveryEvents = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
private void publishRecovery(CharacterRecoveryStore.Operation operation) {
|
||||
var bridge = recoveryEvents;
|
||||
if (bridge != null) for (var event : operation.events()) bridge.publish(event);
|
||||
}
|
||||
|
||||
/** Owner proof closes the mutation-to-outbox gap; replay never invokes native delivery or game mutation. */
|
||||
public CompletableFuture<Void> replayRecoveryEvents() {
|
||||
return submit(() -> {
|
||||
recoveries.visitAudits((operation, historical) -> {
|
||||
if (historical) { publishRecovery(operation); return; }
|
||||
var record = repository.load(operation.character()).orElseThrow(() -> new IOException("Recovery audit character missing"));
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
|
||||
if (expectedMatches(record, operation)) { recoveries.cancelPrepared(record.id(), operation.operation()); return; }
|
||||
if (!replacementMatches(record, operation)) throw new IOException("Prepared recovery audit conflicts");
|
||||
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED, CharacterRecoveryStore.Phase.COMMITTED);
|
||||
operation = operation.phase(CharacterRecoveryStore.Phase.COMMITTED);
|
||||
}
|
||||
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
|
||||
if (!replacementMatches(record, operation)) throw new IOException("Committed recovery audit conflicts");
|
||||
// Visibility after a failed rename/fsync is not itself a durability acknowledgment.
|
||||
DurableFiles.forceDirectory(repository.pluginDataDirectory().resolve("characters"));
|
||||
if (operation.events().size() == 1 && recoveryEvents != null) {
|
||||
var uncertain = recoveryEvents.uncertain(operation.events().getFirst(), "reconciliation", "unresolved_native_delivery", true);
|
||||
recoveries.retainEvent(record.id(), operation.operation(), uncertain);
|
||||
operation = recoveries.find(record.id()).orElseThrow();
|
||||
}
|
||||
} else if (!record.sheetGeneration().equals(operation.replacementGeneration())
|
||||
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0) {
|
||||
throw new IOException("Delivered recovery audit conflicts");
|
||||
}
|
||||
publishRecovery(operation);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
@@ -44,6 +88,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
this.recoveries = new CharacterRecoveryStore(repository.pluginDataDirectory());
|
||||
this.secondWindHealth = new SecondWindHealthStore(repository.pluginDataDirectory());
|
||||
this.healthStealHealth = new HealthStealHealthStore(repository.pluginDataDirectory());
|
||||
repository.attachMutationUnavailable(recoveries::blocks);
|
||||
}
|
||||
|
||||
@@ -227,6 +273,94 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Pure authoritative read for administration; unlike gameplay loading, this never performs legacy migration. */
|
||||
public enum CoinAdjustmentStatus { APPLIED, DUPLICATE, STALE, UNAVAILABLE, BELOW_ZERO, FAILED, UNCERTAIN }
|
||||
public record CoinAdjustment(CoinAdjustmentStatus status, Optional<CharacterRecord> before,
|
||||
Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Administrator arithmetic, not a combat reward; uses the same serialized write authority. */
|
||||
public CompletableFuture<CoinAdjustment> adjustCoins(UUID id, BigInteger revision, UUID operation, BigInteger delta) {
|
||||
return adjustCoins(id, revision, operation, delta, () -> true);
|
||||
}
|
||||
|
||||
/** Acceptance guard reads only process authority, never native player state on the disk worker. */
|
||||
public CompletableFuture<CoinAdjustment> adjustCoins(UUID id, BigInteger revision, UUID operation, BigInteger delta,
|
||||
java.util.function.BooleanSupplier eligible) {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(revision); Objects.requireNonNull(operation); Objects.requireNonNull(eligible);
|
||||
if (Objects.requireNonNull(delta).signum() == 0) throw new IllegalArgumentException("Nonzero delta required");
|
||||
return submit(() -> {
|
||||
var replacementStarted = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
try { return repository.transaction(() -> {
|
||||
var proofs = new AdminCoinOperationStore(repository.pluginDataDirectory());
|
||||
var existing = proofs.find(operation);
|
||||
if (existing.isPresent()) {
|
||||
var proof = existing.orElseThrow();
|
||||
if (!proof.before().id().equals(id) || !proof.delta().equals(delta))
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, Optional.empty(), Optional.empty());
|
||||
if (proof.phase() == AdminCoinOperationStore.Phase.APPLIED) {
|
||||
var current = repository.load(id).orElse(null);
|
||||
if (current == null || current.revision().compareTo(proof.after().revision()) < 0
|
||||
|| current.revision().equals(proof.after().revision()) && !current.equals(proof.after()))
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.FAILED, Optional.empty(), Optional.empty());
|
||||
}
|
||||
var status = switch (proof.phase()) {
|
||||
case APPLIED -> CoinAdjustmentStatus.DUPLICATE;
|
||||
case FAILED -> CoinAdjustmentStatus.FAILED;
|
||||
case PREPARED, UNCERTAIN -> CoinAdjustmentStatus.UNCERTAIN;
|
||||
};
|
||||
return new CoinAdjustment(status, Optional.of(proof.before()), Optional.of(proof.after()));
|
||||
}
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || found.orElseThrow().dead() || !found.orElseThrow().coinsInitialized()
|
||||
|| !repository.mutationAvailable(id) || !eligible.getAsBoolean())
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
|
||||
var old = found.orElseThrow();
|
||||
// A receipt without a frozen proof cannot authorize replay or fabricate an acknowledgment.
|
||||
if (old.rewardReceipts().contains(operation))
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
|
||||
if (!old.revision().equals(revision)) return new CoinAdjustment(CoinAdjustmentStatus.STALE, found, found);
|
||||
if (old.coins().add(delta).signum() < 0) return new CoinAdjustment(CoinAdjustmentStatus.BELOW_ZERO, found, found);
|
||||
var receipts = new HashSet<>(old.rewardReceipts());
|
||||
// The permanent proof is authoritative; never evict someone else's receipt or freeze gameplay at ledger capacity.
|
||||
if (receipts.size() < 4096) receipts.add(operation);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.passives(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
|
||||
old.coins().add(delta), old.coinsInitialized(), receipts, old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
var proof = new AdminCoinOperationStore.Proof(operation, delta, old, saved, AdminCoinOperationStore.Phase.PREPARED);
|
||||
try { proofs.write(proof); }
|
||||
catch (IOException precommit) { return new CoinAdjustment(CoinAdjustmentStatus.FAILED, found, found); }
|
||||
try {
|
||||
// Repeat custody/session authority after durable preparation, immediately before replacement.
|
||||
if (!repository.mutationAvailable(id) || !eligible.getAsBoolean()) {
|
||||
proofs.write(proof.phase(AdminCoinOperationStore.Phase.FAILED));
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.UNAVAILABLE, found, found);
|
||||
}
|
||||
replacementStarted.set(true);
|
||||
repository.replaceLocked(saved, old.revision(), true);
|
||||
proofs.write(proof.phase(AdminCoinOperationStore.Phase.APPLIED));
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.APPLIED, found, Optional.of(saved));
|
||||
} catch (IOException failure) {
|
||||
boolean unchanged = false;
|
||||
try { unchanged = repository.load(id).filter(old::equals).isPresent(); }
|
||||
catch (IOException unreadable) { /* Cannot establish a definite noncommit. */ }
|
||||
var status = unchanged ? CoinAdjustmentStatus.FAILED : CoinAdjustmentStatus.UNCERTAIN;
|
||||
try { proofs.write(proof.phase(unchanged ? AdminCoinOperationStore.Phase.FAILED : AdminCoinOperationStore.Phase.UNCERTAIN)); }
|
||||
catch (IOException retainedPreparation) { /* PREPARED remains fail-closed, never replayable. */ }
|
||||
return new CoinAdjustment(status, found, unchanged ? found : Optional.empty());
|
||||
}
|
||||
}); } catch (IOException | RuntimeException failure) {
|
||||
// Lookup/proof/lock failure before replacement is a proved noncommit, never a speculative uncertain write.
|
||||
return new CoinAdjustment(replacementStarted.get() ? CoinAdjustmentStatus.UNCERTAIN : CoinAdjustmentStatus.FAILED,
|
||||
Optional.empty(), Optional.empty());
|
||||
}
|
||||
}).exceptionally(failure -> {
|
||||
var cause = failure instanceof CompletionException ? failure.getCause() : failure;
|
||||
if (cause instanceof RejectedExecutionException)
|
||||
return new CoinAdjustment(CoinAdjustmentStatus.FAILED, Optional.empty(), Optional.empty());
|
||||
throw new CompletionException(failure); // Unexpected accepted-work failure remains conservative for its owner.
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> inspect(UUID id) {
|
||||
return submit(() -> repository.load(Objects.requireNonNull(id)));
|
||||
}
|
||||
@@ -238,18 +372,36 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
|
||||
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
|
||||
boolean revive, UUID death, java.math.BigDecimal nativeLimit,
|
||||
games.dmg.fantasystick.events.CharacterRecoveryEventBridge.Capture capture) {
|
||||
public RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
|
||||
BigInteger expectedRevision, BigInteger expectedGeneration,
|
||||
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
|
||||
this(operation, character, administrator, recipient, expectedRevision, expectedGeneration, revive, death, nativeLimit, null);
|
||||
}
|
||||
public RecoveryRequest {
|
||||
Objects.requireNonNull(operation); Objects.requireNonNull(character);
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
|
||||
Objects.requireNonNull(nativeLimit);
|
||||
if (capture != null && (!operation.equals(capture.operation()) || !recipient.equals(capture.recipient())
|
||||
|| !administrator.equals(capture.administrator().playerId())))
|
||||
throw new IllegalArgumentException("Recovery capture identity mismatch");
|
||||
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|
||||
|| nativeLimit.signum() <= 0 || revive != (death != null))
|
||||
throw new IllegalArgumentException("Invalid character recovery request");
|
||||
}
|
||||
}
|
||||
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
|
||||
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation,
|
||||
games.dmg.fantasystick.events.EventInput committedEvent) {
|
||||
public RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) {
|
||||
this(status, record, operation, null);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> retainRecoveryEvent(UUID character, UUID operation, games.dmg.fantasystick.events.EventInput event) {
|
||||
return submit(() -> { recoveries.retainEvent(character, operation, event); return null; });
|
||||
}
|
||||
|
||||
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
|
||||
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
|
||||
@@ -270,29 +422,51 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
|
||||
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
|
||||
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
|
||||
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
|
||||
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
|
||||
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
|
||||
recoveries.prepare(operation);
|
||||
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
|
||||
var replacement = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
|
||||
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
|
||||
old.gear(), nextGeneration);
|
||||
var audit = request.capture() == null ? java.util.List.<games.dmg.fantasystick.events.EventInput>of()
|
||||
: java.util.List.of(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.committed(request.capture(), old, replacement));
|
||||
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
|
||||
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
|
||||
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED, audit);
|
||||
recoveries.prepare(operation);
|
||||
try { repository.replaceForRecovery(replacement, old.revision()); }
|
||||
catch (IOException failure) {
|
||||
try { recoveries.cancelPrepared(old.id(), request.operation()); }
|
||||
catch (IOException retained) { failure.addSuppressed(retained); }
|
||||
// A mover/fsync can fail after replacement. Cancel only when unchanged authoritative
|
||||
// state proves no commit; missing/conflicting/read-failed proof stays fail-closed.
|
||||
try {
|
||||
var current = repository.load(old.id()).orElseThrow(() -> new IOException("Recovery state unavailable after failed replacement"));
|
||||
if (expectedMatches(current, operation)) recoveries.cancelPrepared(old.id(), request.operation());
|
||||
else if (replacementMatches(current, operation)) {
|
||||
DurableFiles.forceDirectory(repository.pluginDataDirectory().resolve("characters"));
|
||||
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED, CharacterRecoveryStore.Phase.COMMITTED);
|
||||
if (!audit.isEmpty() && recoveryEvents != null) {
|
||||
var uncertain = recoveryEvents.uncertain(audit.getFirst(), "commit_acknowledgment", "commit_unacknowledged", false);
|
||||
recoveries.retainEvent(old.id(), request.operation(), uncertain);
|
||||
publishRecovery(recoveries.find(old.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
} catch (IOException retained) { failure.addSuppressed(retained); }
|
||||
throw failure;
|
||||
}
|
||||
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
|
||||
CharacterRecoveryStore.Phase.COMMITTED);
|
||||
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
|
||||
publishRecovery(operation.phase(CharacterRecoveryStore.Phase.COMMITTED));
|
||||
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation(),
|
||||
audit.isEmpty() ? null : audit.getFirst());
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
|
||||
return completeRecovery(character, operation, false);
|
||||
}
|
||||
|
||||
/** Native caller supplies explicit delivery acknowledgment; offline proof alone cannot assert delivery. */
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation, boolean nativeDelivered) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(operation);
|
||||
return submit(() -> {
|
||||
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
|
||||
@@ -300,14 +474,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|
||||
|| !replacementMatches(record, proof))
|
||||
throw new IOException("Recovery completion proof conflicts with saved character");
|
||||
var completed = nativeDelivered && recoveryEvents != null && !proof.events().isEmpty()
|
||||
? recoveryEvents.completed(proof.events().getFirst()) : null;
|
||||
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
|
||||
CharacterRecoveryStore.Phase.DELIVERED);
|
||||
CharacterRecoveryStore.Phase.DELIVERED, completed);
|
||||
if (completed != null) recoveryEvents.publish(completed);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist a legacy character's one-time configured migration roll before exposing it. */
|
||||
private Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
if (secondWindHealth.blocks(id)) throw new IOException("Second Wind native publication is unresolved; character entry refused");
|
||||
if (healthStealHealth.blocks(id)) throw new IOException("Health Steal native publication is unresolved; character entry refused");
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty()) {
|
||||
if (recoveries.find(id).filter(operation -> operation.phase() != CharacterRecoveryStore.Phase.DELIVERED).isPresent())
|
||||
@@ -771,6 +950,140 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Opaque, one-operation authority; only its worker may advance or retire the load barrier. */
|
||||
public static final class PendingHealth {
|
||||
private final UUID operation;
|
||||
private final CharacterRecord saved;
|
||||
private CharacterRecord latest;
|
||||
private boolean completed;
|
||||
private PendingHealth(UUID operation, CharacterRecord saved) {
|
||||
this.operation = operation; this.saved = saved; this.latest = saved;
|
||||
}
|
||||
public CharacterRecord saved() { return saved; }
|
||||
}
|
||||
|
||||
/** Proof is durable before the provisional health target can become visible on disk. */
|
||||
public CompletableFuture<Optional<PendingHealth>> prepareSecondWindHealth(UUID characterId,
|
||||
BigInteger expectedRevision, java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (secondWindHealth.blocks(characterId) || !repository.mutationAvailable(characterId)) return Optional.empty();
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()
|
||||
|| !found.orElseThrow().revision().equals(expectedRevision)) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
var saved = withHealth(old, health.min(old.maximumHealth()).min(nativeLimit));
|
||||
var operation = UUID.randomUUID();
|
||||
secondWindHealth.prepare(characterId, operation);
|
||||
if (saved != old) repository.replaceLocked(saved, expectedRevision, true);
|
||||
return Optional.of(new PendingHealth(operation, saved));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Refused native publication reconciles only this operation's exact unchanged revision. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> reconcileSecondWindHealth(PendingHealth pending,
|
||||
java.math.BigDecimal observedHealth, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(pending);
|
||||
if (observedHealth.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed) return Optional.empty();
|
||||
var id = pending.saved.id();
|
||||
secondWindHealth.verify(id, pending.operation);
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || !found.orElseThrow().equals(pending.latest)) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
var saved = withHealth(old, observedHealth.min(old.maximumHealth()).min(nativeLimit));
|
||||
if (saved != old) repository.replaceLocked(saved, old.revision(), true);
|
||||
pending.latest = saved;
|
||||
return Optional.of(saved);
|
||||
}));
|
||||
}
|
||||
|
||||
/** No newer record or other operation's proof may be cleared by this acknowledgment. */
|
||||
public CompletableFuture<Boolean> completeSecondWindHealth(PendingHealth pending, CharacterRecord settled) {
|
||||
Objects.requireNonNull(pending); Objects.requireNonNull(settled);
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed || !pending.latest.equals(settled)) return false;
|
||||
secondWindHealth.verify(pending.saved.id(), pending.operation);
|
||||
if (repository.load(pending.saved.id()).filter(settled::equals).isEmpty()) return false;
|
||||
secondWindHealth.complete(pending.saved.id(), pending.operation);
|
||||
pending.completed = true;
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
/** Opaque Health Steal authority, separate from Second Wind and confined to the character worker. */
|
||||
public static final class PendingHealthSteal {
|
||||
private final UUID operation;
|
||||
private final CharacterRecord saved;
|
||||
private CharacterRecord latest;
|
||||
private boolean completed;
|
||||
private PendingHealthSteal(UUID operation, CharacterRecord saved) {
|
||||
this.operation = operation; this.saved = saved; this.latest = saved;
|
||||
}
|
||||
public CharacterRecord saved() { return saved; }
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<PendingHealthSteal>> prepareHealthStealHealth(UUID characterId,
|
||||
BigInteger expectedRevision, java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (healthStealHealth.blocks(characterId) || !repository.mutationAvailable(characterId)) return Optional.empty();
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()
|
||||
|| !found.orElseThrow().revision().equals(expectedRevision)) return Optional.empty();
|
||||
var old = found.orElseThrow();
|
||||
var saved = withHealth(old, health.min(old.maximumHealth()).min(nativeLimit));
|
||||
var operation = UUID.randomUUID();
|
||||
healthStealHealth.prepare(characterId, operation);
|
||||
if (saved != old) repository.replaceLocked(saved, expectedRevision, true);
|
||||
return Optional.of(new PendingHealthSteal(operation, saved));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Preserve newer unrelated progress only under its exact acknowledged snapshot and our still-owned health. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> reconcileHealthStealHealth(PendingHealthSteal pending,
|
||||
CharacterRecord expected, java.math.BigDecimal observedHealth, java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(pending); Objects.requireNonNull(expected);
|
||||
if (observedHealth.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed || !pending.saved.id().equals(expected.id()) || expected.dead()
|
||||
|| expected.revision().compareTo(pending.latest.revision()) < 0
|
||||
|| expected.health().compareTo(pending.latest.health()) != 0) return Optional.empty();
|
||||
var id = pending.saved.id();
|
||||
healthStealHealth.verify(id, pending.operation);
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || !found.orElseThrow().equals(expected)) return Optional.empty();
|
||||
var saved = withHealth(expected, observedHealth.min(expected.maximumHealth()).min(nativeLimit));
|
||||
if (saved != expected) repository.replaceLocked(saved, expected.revision(), true);
|
||||
pending.latest = saved;
|
||||
return Optional.of(saved);
|
||||
}));
|
||||
}
|
||||
|
||||
/** A native acknowledgment cannot clear another operation's proof or a newer, unacknowledged record. */
|
||||
public CompletableFuture<Boolean> completeHealthStealHealth(PendingHealthSteal pending, CharacterRecord settled) {
|
||||
Objects.requireNonNull(pending); Objects.requireNonNull(settled);
|
||||
return submit(() -> repository.transaction(() -> {
|
||||
if (pending.completed || !pending.latest.equals(settled)) return false;
|
||||
healthStealHealth.verify(pending.saved.id(), pending.operation);
|
||||
if (repository.load(pending.saved.id()).filter(settled::equals).isEmpty()) return false;
|
||||
healthStealHealth.complete(pending.saved.id(), pending.operation);
|
||||
pending.completed = true;
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
private static CharacterRecord withHealth(CharacterRecord old, java.math.BigDecimal bounded) {
|
||||
if (old.health().compareTo(bounded) == 0) return old;
|
||||
return new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
old.pvpCoinEscrow(), old.gear(), old.sheetGeneration());
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -788,10 +1101,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
var saved = withHealth(old, bounded);
|
||||
if (saved == old) return Optional.of(old);
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -844,17 +1155,27 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** 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);
|
||||
return restore(characterId, deathId, nativeLimit, true, null);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return restore(characterId, deathId, nativeLimit, false, null);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) {
|
||||
return restore(characterId, deathId, nativeLimit, true, Objects.requireNonNull(expectedGeneration));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(UUID characterId,
|
||||
UUID deathId, java.math.BigDecimal nativeLimit, BigInteger expectedGeneration) {
|
||||
return restore(characterId, deathId, nativeLimit, false, Objects.requireNonNull(expectedGeneration));
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> restore(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit, boolean ageLimited) {
|
||||
java.math.BigDecimal nativeLimit, boolean ageLimited, BigInteger expectedGeneration) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(deathId);
|
||||
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
|
||||
Instant selectedAt = clock.instant();
|
||||
@@ -864,6 +1185,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
Duration age = Duration.between(old.diedAt(), selectedAt);
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative()
|
||||
|| expectedGeneration != null && !expectedGeneration.equals(old.sheetGeneration())
|
||||
|| 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(),
|
||||
|
||||
@@ -35,7 +35,7 @@ public final class FrostShardSettings implements AutoCloseable {
|
||||
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",
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "48",
|
||||
LIFETIME, "5", SPEED, "3", AMPLIFIER, "0", SLOWNESS, "2");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Unresolved native-publication proof is a load barrier, never permission to replay a heal. */
|
||||
final class HealthStealHealthStore {
|
||||
private final Path directory;
|
||||
|
||||
HealthStealHealthStore(Path pluginDataDirectory) {
|
||||
directory = pluginDataDirectory.resolve("health-steal-health").toAbsolutePath();
|
||||
}
|
||||
|
||||
boolean blocks(UUID character) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return false;
|
||||
safeDirectory();
|
||||
// Unknown, malformed, unreadable and symlink proof all remain unresolved.
|
||||
return !Files.notExists(file(character), LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
/** Caller holds the character repository lock through proof and character replacement. */
|
||||
void prepare(UUID character, UUID operation) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectory(directory);
|
||||
DurableFiles.forceDirectory(directory.getParent());
|
||||
}
|
||||
safeDirectory();
|
||||
byte[] bytes = operation.toString().getBytes(StandardCharsets.UTF_8);
|
||||
try (var channel = FileChannel.open(file(character), StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
void verify(UUID character, UUID operation) throws IOException {
|
||||
safeDirectory();
|
||||
var path = file(character);
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != 36)
|
||||
throw new IOException("Health Steal publication proof is unsafe or missing");
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = ByteBuffer.allocate(37);
|
||||
while (bytes.hasRemaining() && channel.read(bytes) > 0) { /* Bound proof reads. */ }
|
||||
bytes.flip();
|
||||
if (!StandardCharsets.UTF_8.decode(bytes).toString().equals(operation.toString()))
|
||||
throw new IOException("Health Steal publication proof does not match its operation");
|
||||
}
|
||||
}
|
||||
|
||||
void complete(UUID character, UUID operation) throws IOException {
|
||||
verify(character, operation);
|
||||
Files.delete(file(character));
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(character + ".pending"); }
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Health Steal publication directory is unsafe");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.PathSpeed;
|
||||
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.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 road configuration; accepted queued writes compose from worker-owned state. */
|
||||
public final class PathSpeedSettings implements AutoCloseable {
|
||||
public static final String ENABLED = "path-speed-enabled";
|
||||
public static final String LEASE = "path-speed-lease-ticks";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(PathSpeed.Settings settings, long sequence, String key) {
|
||||
public Update { Objects.requireNonNull(settings); if (!keys().contains(key) || sequence < 1) throw new IllegalArgumentException("Invalid saved road update"); }
|
||||
}
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-path-speed-configuration"));
|
||||
private volatile PathSpeed.Settings effective;
|
||||
private PathSpeed.Settings persisted;
|
||||
private long savedSequence;
|
||||
private final Map<String, Long> publishedSequences = new java.util.HashMap<>();
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drain = new CompletableFuture<>();
|
||||
|
||||
public PathSpeedSettings(Path directory, PathSpeed.Settings initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
PathSpeedSettings(Path directory, PathSpeed.Settings initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = Objects.requireNonNull(initial); persisted = initial;
|
||||
}
|
||||
public static List<String> keys() { return List.of(ENABLED, LEASE); }
|
||||
public static String defaultValue(String key) { return value(PathSpeed.Settings.defaults(), key); }
|
||||
private static String value(PathSpeed.Settings settings, String key) {
|
||||
return switch (key) {
|
||||
case ENABLED -> Boolean.toString(settings.enabled());
|
||||
case LEASE -> Integer.toString(settings.leaseTicks());
|
||||
default -> throw new IllegalArgumentException("Unknown road setting");
|
||||
};
|
||||
}
|
||||
public String value(String key) { return value(effective, key); }
|
||||
public PathSpeed.Settings settings() { return effective; }
|
||||
public static PathSpeed.Settings load(Object enabled, Object ticks) {
|
||||
if (enabled != null && !(enabled instanceof Boolean)) throw new IllegalArgumentException(ENABLED + " must be true or false");
|
||||
if (ticks != null && !(ticks instanceof Integer || ticks instanceof Long)) throw new IllegalArgumentException(LEASE + " must be an integer from 1–200");
|
||||
long lease = ticks == null ? 20 : ((Number) ticks).longValue();
|
||||
if (lease < 1 || lease > 200) throw new IllegalArgumentException(LEASE + " must be an integer from 1–200");
|
||||
return new PathSpeed.Settings(enabled == null || (Boolean) enabled, (int) lease);
|
||||
}
|
||||
private static Object validated(String key, String raw) {
|
||||
if (ENABLED.equals(key)) {
|
||||
if (!"true".equals(raw) && !"false".equals(raw)) throw new IllegalArgumentException(ENABLED + " must be true or false");
|
||||
return Boolean.valueOf(raw);
|
||||
}
|
||||
if (!LEASE.equals(key)) throw new IllegalArgumentException("Unknown road setting");
|
||||
try {
|
||||
if (raw == null || !raw.matches("[0-9]+")) throw new NumberFormatException();
|
||||
int ticks = Integer.parseInt(raw);
|
||||
if (ticks < 1 || ticks > 200) throw new NumberFormatException();
|
||||
return ticks;
|
||||
} catch (NumberFormatException invalid) { throw new IllegalArgumentException(LEASE + " must be an integer from 1–200"); }
|
||||
}
|
||||
public CompletionStage<Update> set(String key, String raw) {
|
||||
Object accepted = validated(key, raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Road configuration store closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = ENABLED.equals(key) ? new PathSpeed.Settings((Boolean) accepted, persisted.leaseTicks())
|
||||
: new PathSpeed.Settings(persisted.enabled(), (Integer) accepted);
|
||||
save(key, accepted); persisted = next; return new Update(next, ++savedSequence, key);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException("Road configuration save failed; effective policy unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) { finished(); return CompletableFuture.failedStage(new IOException("Road configuration store closed")); }
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (closed || update.sequence() <= publishedSequences.getOrDefault(update.key(), 0L)) return false;
|
||||
// A saved composite is disk evidence, not authority to publish another callback's key.
|
||||
effective = ENABLED.equals(update.key()) ? new PathSpeed.Settings(update.settings().enabled(), effective.leaseTicks())
|
||||
: new PathSpeed.Settings(effective.enabled(), update.settings().leaseTicks());
|
||||
publishedSequences.put(update.key(), update.sequence()); return true;
|
||||
}
|
||||
private void save(String key, Object accepted) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration 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 configuration 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 file");
|
||||
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, accepted); byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
var 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);
|
||||
try (var parent = FileChannel.open(directory, StandardOpenOption.READ)) { parent.force(true); }
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drain.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drain.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drain.complete(null); }
|
||||
}
|
||||
@@ -33,8 +33,30 @@ public final class RevivalStore implements AutoCloseable {
|
||||
"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());
|
||||
private static final Set<String> KEYS_V3 = java.util.stream.Stream.concat(KEYS_V2.stream(),
|
||||
java.util.stream.Stream.of("sheet-generation", "delivery-phase"))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
public enum DeliveryPhase { LEGACY, RESERVED, DELIVERY_STARTED }
|
||||
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot,
|
||||
boolean administrative, java.math.BigInteger sheetGeneration, DeliveryPhase phase) {
|
||||
/** Old reservations deliberately carry no inferred generation or delivery authority. */
|
||||
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
|
||||
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount,
|
||||
destinationSlot, administrative, null, DeliveryPhase.LEGACY);
|
||||
}
|
||||
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot,
|
||||
boolean administrative, java.math.BigInteger sheetGeneration) {
|
||||
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount,
|
||||
destinationSlot, administrative, Objects.requireNonNull(sheetGeneration), DeliveryPhase.RESERVED);
|
||||
}
|
||||
public Reservation deliveryStarted() {
|
||||
if (phase != DeliveryPhase.RESERVED) throw new IllegalStateException("Delivery is not reserved");
|
||||
return new Reservation(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot,
|
||||
sourceAmount, destinationSlot, administrative, sheetGeneration, DeliveryPhase.DELIVERY_STARTED);
|
||||
}
|
||||
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);
|
||||
@@ -42,6 +64,11 @@ public final class RevivalStore implements AutoCloseable {
|
||||
public Reservation {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
|
||||
Objects.requireNonNull(phase);
|
||||
if ((sheetGeneration == null) != (phase == DeliveryPhase.LEGACY)
|
||||
|| sheetGeneration != null && (sheetGeneration.signum() < 0
|
||||
|| (sourceAmount == 1 ? destinationSlot != -1 : destinationSlot < 0)))
|
||||
throw new IllegalArgumentException("Invalid revival authority");
|
||||
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) {
|
||||
@@ -74,6 +101,25 @@ public final class RevivalStore implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Durable attempted-delivery fence precedes every native write; stale/legacy work cannot begin. */
|
||||
public CompletableFuture<Optional<Reservation>> beginDelivery(Reservation expected) {
|
||||
Objects.requireNonNull(expected);
|
||||
return submit(() -> {
|
||||
var found = load(file(expected.character()));
|
||||
if (expected.phase() != DeliveryPhase.RESERVED || found.isEmpty()
|
||||
|| !found.orElseThrow().equals(expected)) return Optional.empty();
|
||||
var started = expected.deliveryStarted();
|
||||
Path temporary = directory.resolve("." + expected.character() + "." + UUID.randomUUID() + ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, encode(started), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
Files.move(temporary, file(expected.character()), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return Optional.of(started);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<Reservation>> find(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
return submit(() -> load(file(character)));
|
||||
@@ -99,6 +145,23 @@ public final class RevivalStore implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** A stale RESERVED callback cannot retire a later attempted-delivery phase of the same operation. */
|
||||
public CompletableFuture<Boolean> cancelReserved(Reservation expected) {
|
||||
Objects.requireNonNull(expected);
|
||||
if (expected.phase() != DeliveryPhase.RESERVED) return CompletableFuture.completedFuture(false);
|
||||
return clear(expected);
|
||||
}
|
||||
|
||||
/** Completion/cancellation must match the full captured authority, not merely an operation UUID. */
|
||||
public CompletableFuture<Boolean> clear(Reservation expected) {
|
||||
Objects.requireNonNull(expected);
|
||||
return submit(() -> {
|
||||
var found = load(file(expected.character()));
|
||||
if (found.isEmpty() || !found.orElseThrow().equals(expected)) return false;
|
||||
return Files.deleteIfExists(file(expected.character()));
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
|
||||
return submit(() -> {
|
||||
@@ -127,13 +190,15 @@ public final class RevivalStore implements AutoCloseable {
|
||||
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");
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2) || raw.keySet().equals(KEYS_V3))) 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");
|
||||
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)||schema.equals("3")&&raw.keySet().equals(KEYS_V3)))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"));
|
||||
integer(raw, "destination-slot"), !schema.equals("1") && bool(raw,"administrative"),
|
||||
schema.equals("3") ? new java.math.BigInteger(text(raw,"sheet-generation")) : null,
|
||||
schema.equals("3") ? DeliveryPhase.valueOf(text(raw,"delivery-phase")) : DeliveryPhase.LEGACY);
|
||||
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); }
|
||||
@@ -141,12 +206,16 @@ public final class RevivalStore implements AutoCloseable {
|
||||
|
||||
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("schema-version", value.phase() == DeliveryPhase.LEGACY ? "2" : "3"); 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()));
|
||||
if (value.phase() != DeliveryPhase.LEGACY) {
|
||||
map.put("sheet-generation", value.sheetGeneration().toString());
|
||||
map.put("delivery-phase", value.phase().name());
|
||||
}
|
||||
return new Yaml().dump(map);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Unresolved native-publication proof is a load barrier, never permission to replay a heal. */
|
||||
final class SecondWindHealthStore {
|
||||
private final Path directory;
|
||||
|
||||
SecondWindHealthStore(Path pluginDataDirectory) {
|
||||
directory = pluginDataDirectory.resolve("second-wind-health").toAbsolutePath();
|
||||
}
|
||||
|
||||
boolean blocks(UUID character) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return false;
|
||||
safeDirectory();
|
||||
// Unknown, malformed, unreadable and symlink proof all remain unresolved.
|
||||
return !Files.notExists(file(character), LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
/** Caller holds the character repository lock through proof and character replacement. */
|
||||
void prepare(UUID character, UUID operation) throws IOException {
|
||||
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectory(directory);
|
||||
DurableFiles.forceDirectory(directory.getParent());
|
||||
}
|
||||
safeDirectory();
|
||||
byte[] bytes = operation.toString().getBytes(StandardCharsets.UTF_8);
|
||||
try (var channel = FileChannel.open(file(character), StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
void verify(UUID character, UUID operation) throws IOException {
|
||||
safeDirectory();
|
||||
var path = file(character);
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) != 36)
|
||||
throw new IOException("Second Wind publication proof is unsafe or missing");
|
||||
try (var channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = ByteBuffer.allocate(37);
|
||||
while (bytes.hasRemaining() && channel.read(bytes) > 0) { /* Bound proof reads. */ }
|
||||
bytes.flip();
|
||||
if (!StandardCharsets.UTF_8.decode(bytes).toString().equals(operation.toString()))
|
||||
throw new IOException("Second Wind publication proof does not match its operation");
|
||||
}
|
||||
}
|
||||
|
||||
void complete(UUID character, UUID operation) throws IOException {
|
||||
verify(character, operation);
|
||||
Files.delete(file(character));
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
private Path file(UUID character) { return directory.resolve(character + ".pending"); }
|
||||
|
||||
private void safeDirectory() throws IOException {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new IOException("Second Wind publication directory is unsafe");
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public final class SwitchDelaySettings implements AutoCloseable {
|
||||
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();}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.delay());publishedSequence=update.sequence();return true;}
|
||||
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);}
|
||||
}
|
||||
@@ -94,14 +94,26 @@ public final class YamlCharacterRepository {
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(() -> {
|
||||
if (unavailable.test(character.id())) throw new IOException("Character is in dungeon boss custody");
|
||||
if (enforceMutationAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character has unresolved recovery custody");
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
});
|
||||
withLock(() -> replaceLocked(character, expectedRevision, enforceMutationAvailability));
|
||||
}
|
||||
|
||||
boolean mutationAvailable(UUID id) { return !unavailable.test(id) && !mutationUnavailable.test(id); }
|
||||
|
||||
/** Caller owns the same directory lock used by every character writer. */
|
||||
void replaceLocked(CharacterRecord character, BigInteger expectedRevision, boolean enforceAvailability) throws IOException {
|
||||
if (unavailable.test(character.id()) || enforceAvailability && mutationUnavailable.test(character.id()))
|
||||
throw new IOException("Character custody is unresolved");
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)
|
||||
|| !character.revision().equals(expectedRevision.add(BigInteger.ONE))) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
}
|
||||
|
||||
@FunctionalInterface interface Transaction<T> { T run() throws IOException; }
|
||||
<T> T transaction(Transaction<T> operation) throws IOException {
|
||||
var result = new java.util.concurrent.atomic.AtomicReference<T>();
|
||||
withLock(() -> result.set(operation.run()));
|
||||
return result.get();
|
||||
}
|
||||
|
||||
/** Only the in-memory creator of a never-issued batch may request this cleanup. */
|
||||
|
||||
@@ -25,7 +25,7 @@ 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,
|
||||
* Display-only codec pinned to Purpur 26.3 build 2651. 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
|
||||
|
||||
@@ -445,7 +445,7 @@ public final class ProtocolPresentation implements CharacterPresentation.Output
|
||||
}
|
||||
}
|
||||
} else if (packet instanceof ClientboundRemoveEntitiesPacket removal
|
||||
&& removal.getEntityIds().contains(decision.entity())) { mask |= UNTRACKED; }
|
||||
&& removal.entityIds().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; }
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.events.AdminCoinsEventBridge;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Full-UUID request owner. Disk work is serialized; native permission/session/feedback stays on the tick. */
|
||||
public final class AdminCharacterCoins implements AutoCloseable {
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private volatile boolean closed;
|
||||
private final Set<Completion> pending = ConcurrentHashMap.newKeySet();
|
||||
private AdminActionEventBridge audit;
|
||||
private AdminCoinsEventBridge events;
|
||||
public AdminCharacterCoins(CharacterStorage characters, EquipmentSessions sessions, Consumer<Runnable> nextTick) {
|
||||
this.characters = characters; this.sessions = sessions; this.nextTick = nextTick;
|
||||
}
|
||||
public void attachAdminAudit(AdminActionEventBridge bridge) { audit = bridge; }
|
||||
public void attachEvents(AdminCoinsEventBridge bridge) { events = bridge; }
|
||||
public void adjust(CommandSender sender, UUID target, BigInteger delta) {
|
||||
var action = audit == null ? null : audit.current();
|
||||
var intent = events == null ? new AdminCoinsEventBridge.Intent(UUID.randomUUID(), action == null ? null : action.id(),
|
||||
java.time.Instant.now(), sender instanceof Player ? "player" : "console", null)
|
||||
: events.capture(sender, action == null ? null : action.id());
|
||||
result(action, AdminActionEventBridge.Outcome.PENDING, "owner_pending", Map.of());
|
||||
var acceptedLookup = new AtomicBoolean();
|
||||
characters.inspect(target).whenComplete((found, failure) -> {
|
||||
if (closed) { result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of()); return; }
|
||||
try { nextTick.accept(() -> {
|
||||
if (!acceptedLookup.compareAndSet(false, true)) return;
|
||||
if (!valid(sender)) {
|
||||
result(action, AdminActionEventBridge.Outcome.REFUSED, "permission_changed", Map.of()); return;
|
||||
}
|
||||
if (failure != null || found == null || found.isEmpty() || found.orElseThrow().dead()
|
||||
|| !sessions.acceptsCoinAdjustment(target)) {
|
||||
result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
|
||||
feedback(sender, "Coin adjustment refused: saved character is unavailable. Nothing changed."); return;
|
||||
}
|
||||
var before = found.orElseThrow();
|
||||
var active = sessions.activeCharacter(target).orElse(null);
|
||||
var completion = new Completion(sender, action, intent, before, active, delta);
|
||||
pending.add(completion);
|
||||
characters.adjustCoins(target, before.revision(), intent.operation(), delta,
|
||||
() -> !closed && sessions.acceptsCoinAdjustment(target)).whenComplete(completion::saved);
|
||||
}); } catch (RuntimeException rejected) {
|
||||
result(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
|
||||
}
|
||||
});
|
||||
}
|
||||
private boolean valid(CommandSender sender) {
|
||||
return !closed && sender.hasPermission("fantasystick.admin") && (!(sender instanceof Player player) || player.isOnline());
|
||||
}
|
||||
|
||||
/** The disk acknowledgment is owned even when shutdown cancels every queued native task. */
|
||||
private final class Completion {
|
||||
final CommandSender sender;
|
||||
final AdminActionEventBridge.Action action;
|
||||
final AdminCoinsEventBridge.Intent intent;
|
||||
final CharacterRecord captured;
|
||||
final EquipmentSessions.Active active;
|
||||
final BigInteger delta;
|
||||
final AtomicBoolean finished = new AtomicBoolean();
|
||||
CharacterStorage.CoinAdjustment outcome;
|
||||
Throwable failure;
|
||||
boolean ready;
|
||||
Completion(CommandSender sender, AdminActionEventBridge.Action action, AdminCoinsEventBridge.Intent intent,
|
||||
CharacterRecord captured, EquipmentSessions.Active active, BigInteger delta) {
|
||||
this.sender = sender; this.action = action; this.intent = intent; this.captured = captured;
|
||||
this.active = active; this.delta = delta;
|
||||
}
|
||||
synchronized void saved(CharacterStorage.CoinAdjustment saved, Throwable error) {
|
||||
if (ready) return;
|
||||
outcome = saved; failure = error; ready = true;
|
||||
if (closed) { finish(false); return; }
|
||||
try { nextTick.accept(() -> finish(true)); }
|
||||
catch (RuntimeException rejected) { finish(false); }
|
||||
}
|
||||
synchronized void finish(boolean nativeAllowed) {
|
||||
if (!ready || !finished.compareAndSet(false, true)) return;
|
||||
pending.remove(this);
|
||||
boolean nativeFeedback = nativeAllowed && !closed;
|
||||
if (failure != null || outcome == null || outcome.status() == CharacterStorage.CoinAdjustmentStatus.UNCERTAIN) {
|
||||
result(action, AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", Map.of());
|
||||
if (nativeFeedback) feedback(sender, "Coin adjustment outcome uncertain; do not blindly repeat the command.");
|
||||
return;
|
||||
}
|
||||
if (outcome.status() != CharacterStorage.CoinAdjustmentStatus.APPLIED) {
|
||||
result(action, outcome.status() == CharacterStorage.CoinAdjustmentStatus.FAILED
|
||||
? AdminActionEventBridge.Outcome.FAILED : AdminActionEventBridge.Outcome.REFUSED,
|
||||
outcome.status() == CharacterStorage.CoinAdjustmentStatus.STALE ? "stale" : "owner_refused", Map.of());
|
||||
if (nativeFeedback) {
|
||||
var old = outcome.before().orElse(captured);
|
||||
feedback(sender, outcome.status() == CharacterStorage.CoinAdjustmentStatus.BELOW_ZERO
|
||||
? "Balance " + old.coins() + "; adjustment " + delta + " would produce " + old.coins().add(delta) + ". Nothing changed."
|
||||
: "Coin adjustment refused: character changed or is unavailable. Nothing changed.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
var before = outcome.before().orElseThrow(); var after = outcome.record().orElseThrow();
|
||||
String sync = active == null ? "not_applicable" : "not_current";
|
||||
if (nativeFeedback && active != null) {
|
||||
try { if (sessions.acknowledgeProgress(active, before.revision(), after)) sync = "acknowledged"; }
|
||||
catch (RuntimeException nativeFailure) { sync = "uncertain"; diagnostic("session acknowledgment", nativeFailure); }
|
||||
}
|
||||
try {
|
||||
if (events != null) events.adjusted(intent, before, after, delta, active == null ? "inactive" : "active", sync);
|
||||
} catch (RuntimeException eventFailure) { diagnostic("domain audit submission", eventFailure); }
|
||||
result(action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged", Map.of("domain_operation_id", intent.operation(),
|
||||
"domain_event_id", AdminCoinsEventBridge.eventId(intent.operation()), "subject_id", after.id()));
|
||||
if (nativeFeedback) feedback(sender, "Adjusted " + after.name() + " (" + after.id() + "): balance " + before.coins()
|
||||
+ "; adjustment " + delta + "; resulting balance " + after.coins() + ".");
|
||||
}
|
||||
}
|
||||
private void result(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome outcome, String reason, Map<String,Object> facts) {
|
||||
try { if (audit != null) audit.result(action, outcome, reason, facts); }
|
||||
catch (RuntimeException failure) { diagnostic("admin audit submission", failure); }
|
||||
}
|
||||
private void feedback(CommandSender sender, String message) {
|
||||
try { if (valid(sender)) sender.sendMessage(message); }
|
||||
catch (RuntimeException failure) { diagnostic("administrator feedback", failure); }
|
||||
}
|
||||
private static void diagnostic(String phase, Throwable failure) {
|
||||
java.util.logging.Logger.getLogger(AdminCharacterCoins.class.getName()).log(java.util.logging.Level.WARNING,
|
||||
"Administrator coins " + phase + " failed; this cannot change or replay the saved operation", failure);
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var completion : pending) completion.finish(false);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
@@ -24,6 +25,18 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
|
||||
public void attachAdminAudit(AdminActionEventBridge audit) {
|
||||
if (closed || adminAudit != null) throw new IllegalStateException("Character issue audit already attached");
|
||||
adminAudit = Objects.requireNonNull(audit);
|
||||
}
|
||||
|
||||
private void audit(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome outcome,
|
||||
String reason, java.util.Map<String, Object> facts) {
|
||||
var bridge = adminAudit;
|
||||
if (bridge != null) bridge.result(action, outcome, reason, facts);
|
||||
}
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
identityEvents = Objects.requireNonNull(events);
|
||||
@@ -40,20 +53,33 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
/** Must be entered on the server thread. Completion is delivered on that thread. */
|
||||
public void issue(CommandSender sender, Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(sender); Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
|
||||
var action = adminAudit == null ? null : adminAudit.current();
|
||||
if (closed || !player.isOnline()) {
|
||||
audit(action, AdminActionEventBridge.Outcome.REFUSED, "stale", java.util.Map.of());
|
||||
response.accept(Outcome.STALE); return;
|
||||
}
|
||||
int slot = player.getInventory().firstEmpty();
|
||||
if (slot < 0) { response.accept(Outcome.FULL); return; }
|
||||
var audit = identityEvents;
|
||||
var attempt = new Attempt(player, slot, response, audit == null ? null : audit.issue(sender, player));
|
||||
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
|
||||
if (slot < 0) {
|
||||
audit(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", java.util.Map.of());
|
||||
response.accept(Outcome.FULL); return;
|
||||
}
|
||||
var attempt = new Attempt(player, slot, response, action);
|
||||
if (pending.putIfAbsent(attempt.playerId, attempt) != null) {
|
||||
audit(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", java.util.Map.of());
|
||||
response.accept(Outcome.BUSY); return;
|
||||
}
|
||||
try {
|
||||
var identity = identityEvents;
|
||||
attempt.issue = identity == null ? null : identity.issue(sender, player);
|
||||
attempt.record = allocate.get();
|
||||
attempt.preparation = storage.prepareCraft(List.of(attempt.record),"admin_issue");
|
||||
audit(action, AdminActionEventBridge.Outcome.PENDING, "owner_pending", java.util.Map.of());
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.playerId, attempt);
|
||||
abandon(attempt);
|
||||
report(attempt, "prepare", failure);
|
||||
auditOutcome(attempt, attempt.preparation == null ? Outcome.SAVE_FAILED : Outcome.RETAINED, null);
|
||||
response.accept(Outcome.SAVE_FAILED);
|
||||
}
|
||||
}
|
||||
@@ -63,13 +89,25 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
|
||||
catch (RuntimeException rejected) {
|
||||
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
|
||||
auditOutcome(attempt, Outcome.RETAINED, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
|
||||
if (closed || !pending.remove(attempt.playerId, attempt)) {
|
||||
abandon(attempt); auditOutcome(attempt, Outcome.RETAINED, null); return;
|
||||
}
|
||||
if (failure != null) {
|
||||
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
|
||||
abandon(attempt);
|
||||
report(attempt, "save", failure);
|
||||
var cause = failure instanceof java.util.concurrent.CompletionException wrapper
|
||||
? wrapper.getCause() : failure;
|
||||
var provedRemoved = cause instanceof CharacterStorage.CraftPreparationException preparationFailure
|
||||
&& preparationFailure.cleanup().retained().isEmpty();
|
||||
auditOutcome(attempt, provedRemoved ? Outcome.SAVE_FAILED : Outcome.RETAINED, null);
|
||||
try { attempt.response.accept(Outcome.SAVE_FAILED); }
|
||||
catch (RuntimeException responseFailure) { report(attempt, "response", responseFailure); }
|
||||
return;
|
||||
}
|
||||
var occupying = attempt.player.getInventory().getItem(attempt.slot);
|
||||
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|
||||
@@ -100,14 +138,39 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
var events = identityEvents;
|
||||
if (events != null) events.issued(attempt.issue, attempt.player, savedRecord, attempt.slot);
|
||||
auditOutcome(attempt, Outcome.DELIVERED, savedRecord);
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
private void respond(Attempt attempt, Outcome outcome) {
|
||||
if (outcome != Outcome.DELIVERED) auditOutcome(attempt, outcome, null);
|
||||
try { attempt.response.accept(outcome); }
|
||||
catch (RuntimeException failure) { report(attempt, "response", failure); }
|
||||
}
|
||||
|
||||
private void auditOutcome(Attempt attempt, Outcome outcome, CharacterRecord delivered) {
|
||||
if (outcome == Outcome.DELIVERED && delivered != null) {
|
||||
var facts = new java.util.LinkedHashMap<String, Object>();
|
||||
if (attempt.issue != null) facts.put("domain_operation_id", attempt.issue.operation());
|
||||
facts.put("subject_id", delivered.id());
|
||||
facts.put("target_id", attempt.playerId);
|
||||
audit(attempt.action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged", facts);
|
||||
} else {
|
||||
var classification = switch (outcome) {
|
||||
case FULL, BUSY, STALE -> AdminActionEventBridge.Outcome.REFUSED;
|
||||
case SAVE_FAILED -> AdminActionEventBridge.Outcome.FAILED;
|
||||
case RETAINED -> AdminActionEventBridge.Outcome.UNCERTAIN;
|
||||
case DELIVERED -> AdminActionEventBridge.Outcome.UNCERTAIN;
|
||||
};
|
||||
var reason = switch (classification) {
|
||||
case REFUSED -> outcome == Outcome.STALE ? "stale" : "owner_refused";
|
||||
case FAILED -> "operation_failed";
|
||||
default -> "operation_uncertain";
|
||||
};
|
||||
audit(attempt.action, classification, reason, java.util.Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
|
||||
attempt.preparation.abandon().thenAccept(cleanup -> {
|
||||
@@ -123,18 +186,22 @@ public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var attempt : List.copyOf(pending.values())) {
|
||||
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
|
||||
if (pending.remove(attempt.playerId, attempt)) {
|
||||
abandon(attempt);
|
||||
auditOutcome(attempt, Outcome.RETAINED, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
|
||||
final AdminActionEventBridge.Action action;
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue;
|
||||
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,
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Issue issue) {
|
||||
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.issue=issue;
|
||||
AdminActionEventBridge.Action action) {
|
||||
this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; this.action=action;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
@@ -24,7 +25,7 @@ import org.bukkit.inventory.ItemStack;
|
||||
public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, UUID recipient, String operation, Throwable cause) { }
|
||||
private record View(UUID nonce, Player administrator, Player recipient, CharacterRecord record,
|
||||
int destination, Inventory inventory) { }
|
||||
int destination, Inventory inventory, AdminActionEventBridge.Action action) { }
|
||||
private record Prerequisites(java.util.Optional<CharacterRecord> record,
|
||||
java.util.Optional<EquipmentSessionStore.State> receipt,
|
||||
java.util.Optional<RevivalStore.Reservation> revival) { }
|
||||
@@ -40,6 +41,23 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final CharacterSheets sheets = new CharacterSheets();
|
||||
private volatile boolean closed;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge events;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
|
||||
if (closed || events != null) throw new IllegalStateException("Recovery events already attached or closed");
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void attachAdminAudit(AdminActionEventBridge audit) {
|
||||
if (closed || adminAudit != null) throw new IllegalStateException("Character recovery audit already attached");
|
||||
adminAudit = Objects.requireNonNull(audit);
|
||||
}
|
||||
|
||||
private void outcome(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome status,
|
||||
String reason, Map<String, Object> facts) {
|
||||
if (adminAudit != null) adminAudit.result(action, status, reason, facts);
|
||||
}
|
||||
|
||||
public AdminCharacterRecovery(CharacterStorage characters, EquipmentSessionStore receipts,
|
||||
RevivalStore revivals, EquipmentSessions sessions, Server server, Consumer<Runnable> nextTick,
|
||||
@@ -59,6 +77,7 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient is unavailable.");
|
||||
return;
|
||||
}
|
||||
var action = adminAudit == null ? null : adminAudit.current();
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
@@ -67,7 +86,7 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
characters.inspect(character).whenComplete((record, recordFailure) -> {
|
||||
if (recordFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, recordFailure));
|
||||
administrator, recipient, character, null, recordFailure, action));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -75,61 +94,67 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
receiptStage.whenComplete((receipt, receiptFailure) -> {
|
||||
if (receiptFailure != null) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, receiptFailure));
|
||||
administrator, recipient, character, null, receiptFailure, action));
|
||||
return;
|
||||
}
|
||||
revivals.find(character).whenComplete((revival, revivalFailure) ->
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character,
|
||||
revivalFailure == null ? new Prerequisites(record, receipt, revival) : null,
|
||||
revivalFailure)));
|
||||
revivalFailure, action)));
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
schedule(character, recipientId, () -> finishPreview(administratorId, ticket,
|
||||
administrator, recipient, character, null, rejected));
|
||||
administrator, recipient, character, null, rejected, action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void finishPreview(UUID administratorId, Object ticket, Player administrator, Player recipient,
|
||||
UUID character, Prerequisites loaded, Throwable failure) {
|
||||
UUID character, Prerequisites loaded, Throwable failure, AdminActionEventBridge.Action action) {
|
||||
if (closed || !pending.remove(administratorId, ticket)) return;
|
||||
Player currentRecipient = server.getPlayer(recipient.getUniqueId());
|
||||
if (!current(administrator) || currentRecipient != recipient || !recipient.isOnline()) {
|
||||
outcome(action, AdminActionEventBridge.Outcome.REFUSED, "stale", Map.of());
|
||||
administrator.sendMessage("Character recovery refused: administrator or recipient changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(character, recipient.getUniqueId(), "preview", failure);
|
||||
outcome(action, AdminActionEventBridge.Outcome.FAILED, "operation_failed", Map.of());
|
||||
administrator.sendMessage("Character recovery preview could not be loaded; no state changed.");
|
||||
return;
|
||||
}
|
||||
var record = loaded.record().orElse(null);
|
||||
if (record == null) {
|
||||
outcome(action, AdminActionEventBridge.Outcome.REFUSED, "not_found", Map.of());
|
||||
administrator.sendMessage("Character recovery refused: saved character not found.");
|
||||
return;
|
||||
}
|
||||
if (loaded.receipt().map(EquipmentSessionStore.State::pending).orElse(null) != null
|
||||
|| loaded.revival().isPresent()
|
||||
|| !sessions.availableForCharacterRecovery(character, recipient.getUniqueId())) {
|
||||
outcome(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
|
||||
administrator.sendMessage("Character recovery refused: active or unresolved custody must be resolved first.");
|
||||
return;
|
||||
}
|
||||
int destination = recipient.getInventory().firstEmpty();
|
||||
if (destination < 0 || destination >= 36) {
|
||||
outcome(action, AdminActionEventBridge.Outcome.REFUSED, "owner_refused", Map.of());
|
||||
administrator.sendMessage("Character recovery refused: recipient needs one empty main-inventory slot.");
|
||||
return;
|
||||
}
|
||||
show(administrator, recipient, record, destination);
|
||||
show(administrator, recipient, record, destination, action);
|
||||
}
|
||||
|
||||
private void show(Player administrator, Player recipient, CharacterRecord record, int destination) {
|
||||
private void show(Player administrator, Player recipient, CharacterRecord record, int destination,
|
||||
AdminActionEventBridge.Action action) {
|
||||
var inventory = server.createInventory(null, 9, Component.text("Recover saved character"));
|
||||
inventory.setItem(2, action(record.dead() ? Material.BARRIER : Material.PAPER,
|
||||
record.dead() ? "Living recovery unavailable" : "Recover living character sheet", record, recipient));
|
||||
inventory.setItem(6, action(record.dead() ? Material.TOTEM_OF_UNDYING : Material.BARRIER,
|
||||
record.dead() ? "Revive without Tail and recover sheet" : "No-Tail revival unavailable", record, recipient));
|
||||
var view = new View(UUID.randomUUID(), administrator, recipient, record, destination, inventory);
|
||||
var view = new View(UUID.randomUUID(), administrator, recipient, record, destination, inventory, action);
|
||||
views.put(administrator.getUniqueId(), view);
|
||||
administrator.openInventory(inventory);
|
||||
}
|
||||
@@ -164,29 +189,37 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
boolean revive = slot == 6;
|
||||
if (!(slot == 2 || slot == 6) || revive != view.record().dead()) return;
|
||||
if (!views.remove(administrator.getUniqueId(), view)) return;
|
||||
var confirmation = UUID.randomUUID();
|
||||
if (adminAudit != null) adminAudit.confirmation(view.action(), current(administrator), "inventory_confirmation", confirmation);
|
||||
administrator.closeInventory();
|
||||
confirm(view, revive);
|
||||
confirm(view, revive, confirmation);
|
||||
}
|
||||
|
||||
private void confirm(View view, boolean revive) {
|
||||
private void confirm(View view, boolean revive, UUID confirmation) {
|
||||
Player administrator = view.administrator(); Player recipient = view.recipient();
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
if (!current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.REFUSED, "stale", Map.of());
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var custody = sessions.claimCharacterRecovery(view.record().id(), recipientId);
|
||||
if (custody.isEmpty()) {
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.REFUSED, "stale", Map.of());
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
var claim = custody.orElseThrow();
|
||||
UUID operation = UUID.randomUUID();
|
||||
var capture = events == null ? null : events.capture(operation, view.action(), confirmation,
|
||||
administrator, recipient, view.destination());
|
||||
java.util.concurrent.CompletionStage<java.util.Optional<EquipmentSessionStore.State>> receiptRead;
|
||||
try { receiptRead = receipts.find(recipientId); }
|
||||
catch (RuntimeException rejected) {
|
||||
claim.close();
|
||||
report(view.record().id(), recipientId, "receipt", rejected);
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.FAILED, "operation_failed", Map.of());
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
@@ -197,6 +230,8 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
|| !recipient.isOnline() || !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (failure != null) report(view.record().id(), recipient.getUniqueId(), "receipt", failure);
|
||||
outcome(view.action(), failure == null ? AdminActionEventBridge.Outcome.REFUSED
|
||||
: AdminActionEventBridge.Outcome.FAILED, failure == null ? "stale" : "operation_failed", Map.of());
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
@@ -207,29 +242,44 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
|| !empty(recipient, view.destination())) {
|
||||
claim.close();
|
||||
if (revivalFailure != null) report(view.record().id(), recipientId, "revival", revivalFailure);
|
||||
outcome(view.action(), revivalFailure == null ? AdminActionEventBridge.Outcome.REFUSED
|
||||
: AdminActionEventBridge.Outcome.FAILED, revivalFailure == null ? "stale" : "operation_failed", Map.of());
|
||||
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
|
||||
return;
|
||||
}
|
||||
UUID operation = UUID.randomUUID();
|
||||
var request = new CharacterStorage.RecoveryRequest(operation, view.record().id(),
|
||||
administrator.getUniqueId(), recipientId, view.record().revision(),
|
||||
view.record().sheetGeneration(), revive, revive ? view.record().deathId() : null,
|
||||
NativeHealthLimit.read());
|
||||
NativeHealthLimit.read(), capture);
|
||||
characters.beginRecovery(request).whenComplete((commit, commitFailure) -> schedule(
|
||||
view.record().id(), recipientId, () -> deliver(view, claim, commit, commitFailure)));
|
||||
view.record().id(), recipientId, () -> deliver(view, claim, operation, commit, commitFailure)));
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim,
|
||||
private void uncertain(CharacterStorage.RecoveryCommit commit, String stage, String reason, boolean mayHaveWritten) {
|
||||
if (events == null || commit.committedEvent() == null) return;
|
||||
var event = events.uncertain(commit.committedEvent(), stage, reason, mayHaveWritten);
|
||||
// A failed custody write cannot suppress truthful native uncertainty. The writer owns the
|
||||
// transient observation handoff; the original committed capture remains in the owner receipt.
|
||||
events.publish(event);
|
||||
var owner = events;
|
||||
characters.retainRecoveryEvent(commit.record().orElseThrow().id(), commit.operation(), event)
|
||||
.whenComplete((ignored, failure) -> { if (failure != null) owner.diagnostic(failure); });
|
||||
}
|
||||
|
||||
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim, UUID operation,
|
||||
CharacterStorage.RecoveryCommit commit, Throwable failure) {
|
||||
if (failure != null) {
|
||||
report(view.record().id(), view.recipient().getUniqueId(), "commit", failure);
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
|
||||
Map.of("domain_operation_id", operation));
|
||||
view.administrator().sendMessage("Character recovery failed or is retained for inspection; no retry was performed.");
|
||||
return;
|
||||
}
|
||||
if (commit.status() != CharacterStorage.RecoveryStatus.APPLIED || commit.record().isEmpty()) {
|
||||
claim.close();
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.REFUSED, "stale", Map.of());
|
||||
view.administrator().sendMessage("Character recovery refused: saved state or custody changed ("
|
||||
+ commit.status().name().toLowerCase(java.util.Locale.ROOT) + ").");
|
||||
return;
|
||||
@@ -238,6 +288,9 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
Player recipient = view.recipient();
|
||||
if (!current(view.administrator()) || server.getPlayer(recipient.getUniqueId()) != recipient || !recipient.isOnline()
|
||||
|| !empty(recipient, view.destination())) {
|
||||
uncertain(commit, "delivery_revalidation", "stale_destination_or_lifetime", false);
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
|
||||
Map.of("domain_operation_id", commit.operation()));
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
@@ -247,20 +300,30 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
throw new IllegalStateException("Native inventory did not retain the recovered sheet");
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(record.id(), recipient.getUniqueId(), "native-delivery", uncertain);
|
||||
uncertain(commit, "native_delivery", "native_write_uncertain", true);
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
|
||||
Map.of("domain_operation_id", commit.operation()));
|
||||
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
|
||||
return;
|
||||
}
|
||||
try { recipient.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(record.id(), recipient.getUniqueId(), "inventory-sync", syncFailure); }
|
||||
UUID recipientId = recipient.getUniqueId();
|
||||
characters.completeRecovery(record.id(), commit.operation()).whenComplete((completed, completionFailure) ->
|
||||
characters.completeRecovery(record.id(), commit.operation(), true).whenComplete((completed, completionFailure) ->
|
||||
schedule(record.id(), recipientId, () -> {
|
||||
if (completionFailure != null || !completed) {
|
||||
if (completionFailure != null) report(record.id(), recipient.getUniqueId(), "completion", completionFailure);
|
||||
uncertain(commit, "completion", "completion_unacknowledged", true);
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
|
||||
Map.of("domain_operation_id", commit.operation()));
|
||||
view.administrator().sendMessage("Recovery sheet was delivered, but durable completion is retained for inspection.");
|
||||
return;
|
||||
}
|
||||
claim.close();
|
||||
outcome(view.action(), AdminActionEventBridge.Outcome.COMPLETED, "acknowledged",
|
||||
Map.of("domain_operation_id", commit.operation(), "subject_id", record.id(),
|
||||
"target_id", recipientId, "revision_before", view.record().revision().toString(),
|
||||
"revision_after", record.revision().toString()));
|
||||
if (current(view.administrator())) view.administrator().sendMessage(
|
||||
(view.record().dead() ? "Revived without a Tail and recovered " : "Recovered ")
|
||||
+ record.name() + " for " + recipient.getName() + ". Older sheets are revoked.");
|
||||
@@ -284,7 +347,8 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
|
||||
|
||||
private void schedule(UUID character, UUID recipient, Runnable task) {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> { if (!closed) task.run(); }); }
|
||||
var invoked = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
try { nextTick.accept(() -> { if (!closed && invoked.compareAndSet(false, true)) task.run(); }); }
|
||||
catch (RuntimeException rejected) { report(character, recipient, "schedule", rejected); }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.events.AdminExperienceEventBridge;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
@@ -30,17 +31,28 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
private final games.dmg.fantasystick.presentation.ParticleDelivery particles;
|
||||
private volatile boolean closed;
|
||||
private AdminExperienceEventBridge events;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
|
||||
public void attachAdminAudit(AdminActionEventBridge audit) {
|
||||
if (closed || adminAudit != null) throw new IllegalStateException("Experience grant audit already attached");
|
||||
adminAudit = Objects.requireNonNull(audit);
|
||||
}
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Pending> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
private static final class Pending {
|
||||
final AdminExperienceEventBridge.Intent intent;
|
||||
final AdminActionEventBridge.Action action;
|
||||
final UUID targetPlayer;
|
||||
final CharacterRecord captured;
|
||||
final BigInteger amount;
|
||||
CharacterStorage.ExperienceAward award;
|
||||
boolean saveFailed;
|
||||
boolean resolved;
|
||||
boolean emitted;
|
||||
Pending(AdminExperienceEventBridge.Intent intent, CharacterRecord captured, BigInteger amount) {
|
||||
this.intent = intent; this.captured = captured; this.amount = amount;
|
||||
Pending(AdminExperienceEventBridge.Intent intent, AdminActionEventBridge.Action action,
|
||||
UUID targetPlayer, CharacterRecord captured, BigInteger amount) {
|
||||
this.intent = intent; this.action = action; this.targetPlayer = targetPlayer;
|
||||
this.captured = captured; this.amount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +89,12 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
Objects.requireNonNull(active); Objects.requireNonNull(captured);
|
||||
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
|
||||
var action = adminAudit == null ? null : adminAudit.current();
|
||||
if (closed || !sessions.current(active)
|
||||
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
|
||||
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) {
|
||||
audit(action, AdminActionEventBridge.Outcome.REFUSED, "stale", java.util.Map.of());
|
||||
return false;
|
||||
}
|
||||
AdminExperienceEventBridge.Intent intent = null;
|
||||
if (events != null && administrator != null) {
|
||||
Player target = null;
|
||||
@@ -87,17 +103,30 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
try { intent = events.capture(administrator, active, captured, target); }
|
||||
catch (RuntimeException failure) { report(captured.id(), failure); }
|
||||
}
|
||||
var operation = new Pending(intent, captured, amount);
|
||||
var operation = new Pending(intent, action, active.player(), captured, amount);
|
||||
if (intent != null) pending.put(intent.operation(), operation);
|
||||
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
|
||||
.whenComplete((award, failure) -> schedule(active, operation, completion, award, failure));
|
||||
audit(action, AdminActionEventBridge.Outcome.PENDING, "owner_pending", java.util.Map.of());
|
||||
try {
|
||||
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
|
||||
.whenComplete((award, failure) -> schedule(active, operation, completion, award, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
report(captured.id(), failure);
|
||||
// An exception from the storage port cannot prove whether its write was accepted.
|
||||
audit(action, AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", java.util.Map.of());
|
||||
if (intent != null) pending.remove(intent.operation(), operation);
|
||||
throw failure;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void schedule(EquipmentSessions.Active active, Pending operation,
|
||||
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
|
||||
if (failure != null) report(operation.captured.id(), failure);
|
||||
synchronized (operation) { operation.award = award; operation.resolved = true; }
|
||||
synchronized (operation) {
|
||||
operation.award = award;
|
||||
operation.saveFailed = failure != null;
|
||||
operation.resolved = true;
|
||||
}
|
||||
if (closed) { publish(operation, false); return; }
|
||||
try {
|
||||
nextTick.accept(() -> complete(active, operation, completion, award, failure));
|
||||
@@ -149,14 +178,40 @@ public final class AdminExperienceGrant implements AutoCloseable {
|
||||
if (!operation.resolved) return; // A later worker completion will observe close and retain the intent.
|
||||
operation.emitted = true;
|
||||
if (award != null && award.status() == CharacterStorage.ExperienceAwardStatus.APPLIED
|
||||
&& award.record().isPresent() && events != null) {
|
||||
events.granted(operation.intent, operation.captured, award.record().orElseThrow(),
|
||||
&& award.record().isPresent()) {
|
||||
var saved = award.record().orElseThrow();
|
||||
if (events != null) events.granted(operation.intent, operation.captured, saved,
|
||||
operation.amount, award.levels(), feedbackDelivered);
|
||||
var facts = new java.util.LinkedHashMap<String, Object>();
|
||||
if (operation.intent != null) facts.put("domain_operation_id", operation.intent.operation());
|
||||
facts.put("subject_id", saved.id());
|
||||
facts.put("target_id", operation.targetPlayer);
|
||||
facts.put("requested", operation.amount.toString());
|
||||
facts.put("applied", saved.experience().subtract(operation.captured.experience()).toString());
|
||||
audit(operation.action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged", facts);
|
||||
} else if (award == null) {
|
||||
audit(operation.action, operation.saveFailed ? AdminActionEventBridge.Outcome.FAILED
|
||||
: AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
operation.saveFailed ? "operation_failed" : "operation_uncertain", java.util.Map.of());
|
||||
} else {
|
||||
var outcome = switch (award.status()) {
|
||||
case AT_CAP, UNCHANGED, STALE, UNAVAILABLE -> AdminActionEventBridge.Outcome.REFUSED;
|
||||
case APPLIED -> AdminActionEventBridge.Outcome.UNCERTAIN;
|
||||
};
|
||||
audit(operation.action, outcome, outcome == AdminActionEventBridge.Outcome.UNCERTAIN
|
||||
? "operation_uncertain" : award.status() == CharacterStorage.ExperienceAwardStatus.STALE
|
||||
? "stale" : "owner_refused", java.util.Map.of());
|
||||
}
|
||||
}
|
||||
if (operation.intent != null) pending.remove(operation.intent.operation(), operation);
|
||||
}
|
||||
|
||||
private void audit(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome outcome,
|
||||
String reason, java.util.Map<String, Object> facts) {
|
||||
var bridge = adminAudit;
|
||||
if (bridge != null) bridge.result(action, outcome, reason, facts);
|
||||
}
|
||||
|
||||
private boolean feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
|
||||
Player player;
|
||||
try { player = players.apply(active.player()); }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.items.TailRevivalDelivery;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
@@ -34,9 +35,23 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
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 Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();
|
||||
private final java.util.function.Function<UUID,Player> players;
|
||||
private final Map<Inventory,View> views=new IdentityHashMap<>();
|
||||
private final java.util.Set<View> activeViews=java.util.Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private boolean closed;
|
||||
private games.dmg.fantasystick.events.AdminRevivalEventBridge auditEvents;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
|
||||
public void attachAdminAudit(AdminActionEventBridge audit) {
|
||||
if (closed || adminAudit != null) throw new IllegalStateException("Admin revival audit already attached");
|
||||
adminAudit = Objects.requireNonNull(audit);
|
||||
}
|
||||
|
||||
private void outcome(AdminActionEventBridge.Action action, AdminActionEventBridge.Outcome status,
|
||||
String reason, Map<String, Object> facts) {
|
||||
if (adminAudit != null) adminAudit.result(action, status, reason, facts);
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.AdminRevivalEventBridge events) {
|
||||
if (closed || auditEvents != null) throw new IllegalStateException("Admin revival events already attached");
|
||||
@@ -44,31 +59,67 @@ public final class AdminRevival implements Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
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);}}
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;final AdminActionEventBridge.Action action;boolean selecting,finished;
|
||||
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline,AdminActionEventBridge.Action action){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.action=action;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
|
||||
boolean current(){return !closed&&!finished&&TailRevivalDelivery.currentRecipient(admin,players)&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&TailRevivalDelivery.currentRecipient(target,players)&&clock.instant().isBefore(deadline);}
|
||||
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);activeViews.remove(this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{if(!selecting)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 AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures,java.util.function.Function<UUID,Player> players){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);this.players=Objects.requireNonNull(players);}
|
||||
|
||||
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);}));}
|
||||
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 action=adminAudit==null?null:adminAudit.current();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,action);}));}
|
||||
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);}
|
||||
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout,AdminActionEventBridge.Action action){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),action);views.put(inventory,view);activeViews.add(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 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);if(adminAudit!=null)adminAudit.confirmation(view.action,view.admin.isOnline()&&view.admin.hasPermission(EquipmentAdminCommand.PERMISSION),"inventory_confirmation",null);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();}
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(activeViews))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);var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);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,audit);}));}
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"stale",Map.of());view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);outcome(view.action,AdminActionEventBridge.Outcome.FAILED,"operation_failed",Map.of());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)){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"stale",Map.of());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){outcome(view.action,AdminActionEventBridge.Outcome.REFUSED,"owner_refused",Map.of());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,dead.sheetGeneration());var audit=auditEvents==null?null:auditEvents.selection(view.admin,view.target);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);outcome(view.action,AdminActionEventBridge.Outcome.UNCERTAIN,"operation_uncertain",Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)||!games.dmg.fantasystick.items.TailRevivalDelivery.destinationAvailable(view.target,reservation)){if(saved.isPresent())reservations.cancelReserved(reservation).exceptionally(error->{report(error);return false;});outcome(view.action,saved.isEmpty()?AdminActionEventBridge.Outcome.REFUSED:AdminActionEventBridge.Outcome.UNCERTAIN,saved.isEmpty()?"stale":"operation_uncertain",saved.isEmpty()?Map.of():Map.of("domain_operation_id",reservation.id()));view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}reservations.beginDelivery(reservation).whenComplete((begun,beginFailure)->schedule(()->{if(beginFailure!=null||begun.isEmpty()){if(beginFailure!=null)report(beginFailure);uncertain(view,reservation);return;}deliver(view,candidate,dead,begun.orElseThrow(),audit);}));}));}
|
||||
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,games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.passives(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt(),dead.sheetGeneration());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())){if(auditEvents!=null)auditEvents.revived(audit,reservation,dead,restored.orElseThrow(),view.target);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 uncertain(View view, RevivalStore.Reservation reservation) {
|
||||
outcome(view.action, AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
|
||||
Map.of("domain_operation_id", reservation.id()));
|
||||
view.admin.sendMessage("Admin revival could not acknowledge delivery safely; any pending custody proof is retained.");
|
||||
view.close();
|
||||
}
|
||||
|
||||
private void deliver(View view, Candidate candidate, CharacterRecord dead, RevivalStore.Reservation reservation,
|
||||
games.dmg.fantasystick.events.AdminRevivalEventBridge.Selection audit) {
|
||||
try {
|
||||
if (!TailRevivalDelivery.deliver(view.target, candidate.item, dead, reservation, players, view::current)) {
|
||||
uncertain(view, reservation); return;
|
||||
}
|
||||
} catch (RuntimeException failure) { report(failure); uncertain(view, reservation); return; }
|
||||
characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(),
|
||||
NativeHealthLimit.read(), reservation.sheetGeneration()).whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null || restored.isEmpty()) {
|
||||
if (failure != null) report(failure);
|
||||
uncertain(view, reservation); return;
|
||||
}
|
||||
TailRevivalDelivery.complete(characters, reservations, nextTick, this::report, view.target, reservation, players,
|
||||
view::current, record -> {
|
||||
var targetName = view.target.getName();
|
||||
return () -> {
|
||||
if (auditEvents != null) auditEvents.revived(audit, reservation, dead, record, targetName);
|
||||
outcome(view.action, AdminActionEventBridge.Outcome.COMPLETED, "acknowledged",
|
||||
Map.of("domain_operation_id", reservation.id(), "subject_id", dead.id(),
|
||||
"target_id", reservation.targetPlayer()));
|
||||
if (view.current()) {
|
||||
view.admin.sendMessage("Administratively restored " + record.name() + " for " + targetName + ".");
|
||||
view.target.sendMessage("An administrator restored character sheet: " + record.name() + ".");
|
||||
}
|
||||
view.close();
|
||||
};
|
||||
}, () -> uncertain(view, reservation));
|
||||
}));
|
||||
}
|
||||
|
||||
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();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(activeViews))view.close();views.clear();activeViews.clear();}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ import org.bukkit.plugin.Plugin;
|
||||
public final class DownedController implements Listener,AutoCloseable{
|
||||
public enum RescueStatus{APPLIED,APPLIED_PENDING_RECOVERY,STALE,EXPIRED,FAILED}
|
||||
private record Holder(UUID episode)implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private final java.util.Map<UUID,Boolean> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> revertingSlots=new java.util.HashSet<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private games.dmg.fantasystick.shop.PhoenixDownService phoenix;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
|
||||
private final Plugin plugin;private final EquipmentSessions sessions;private final DownedDirectory directory;private final Clock clock;private final Consumer<Throwable> failures;private record FloatingCount(Object connection,java.lang.reflect.Field field,int prior){}
|
||||
private record Freeze(Player player,boolean gravity,FloatingCount floating){}
|
||||
private final java.util.Map<UUID,Freeze> gravity=new java.util.HashMap<>();private final java.util.Set<UUID> endedProtection=new java.util.HashSet<>();private games.dmg.fantasystick.events.DownedEventBridge events;private games.dmg.fantasystick.shop.PhoenixDownService phoenix;private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;
|
||||
public DownedController(Plugin plugin,EquipmentSessions sessions,DownedDirectory directory,Clock clock,Consumer<Throwable> failures){this.plugin=Objects.requireNonNull(plugin);this.sessions=Objects.requireNonNull(sessions);this.directory=Objects.requireNonNull(directory);this.clock=Objects.requireNonNull(clock);this.failures=Objects.requireNonNull(failures);}
|
||||
public void attachEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(pulse!=null||this.events!=null)throw new IllegalStateException("Downed events already attached or controller installed");this.events=Objects.requireNonNull(events);}
|
||||
public void attachPhoenix(games.dmg.fantasystick.shop.PhoenixDownService phoenix){if(pulse!=null||this.phoenix!=null)throw new IllegalStateException("Phoenix Down already attached or controller installed");this.phoenix=Objects.requireNonNull(phoenix);}
|
||||
@@ -32,8 +34,40 @@ public final class DownedController implements Listener,AutoCloseable{
|
||||
private void pulse(){if(closed)return;for(var episode:directory.all()){if(episode.phase()!=DownedEpisode.Phase.PENDING){if(episode.outcome()==DownedEpisode.Outcome.REVIVED&&episode.protectionUntil()!=null&&!clock.instant().isBefore(episode.protectionUntil())&&endedProtection.add(episode.id())&&events!=null)events.protectionEnded(episode);continue;}if(!clock.instant().isBefore(episode.expiresAt()))finalizeDeath(episode,"timeout");else{var player=plugin.getServer().getPlayer(episode.player());if(player!=null&&player.isOnline()){if(player.getOpenInventory().getTopInventory().getHolder() instanceof Holder holder&&holder.episode().equals(episode.id()))player.getOpenInventory().setTitle("Downed — "+remaining(episode)+"s");else open(episode);}}}}
|
||||
public boolean downed(Player player){return episode(player)!=null;}
|
||||
public DownedEpisode episode(Player player){return sessions.downed(player.getUniqueId()).orElse(null);}
|
||||
private void freeze(Player player){gravity.putIfAbsent(player.getUniqueId(),player.hasGravity());player.setGravity(false);player.setFallDistance(0);player.setVelocity(new org.bukkit.util.Vector());if(player.isInsideVehicle())player.leaveVehicle();}
|
||||
private void release(Player player){var original=gravity.remove(player.getUniqueId());if(original!=null)player.setGravity(original);revertingSlots.remove(player.getUniqueId());}
|
||||
private void freeze(Player player){
|
||||
gravity.computeIfAbsent(player.getUniqueId(),ignored->new Freeze(player,player.hasGravity(),captureFloating(player)));
|
||||
player.setGravity(false);player.setFallDistance(0);player.setVelocity(new org.bukkit.util.Vector());if(player.isInsideVehicle())player.leaveVehicle();
|
||||
}
|
||||
private static Object connection(Player player)throws ReflectiveOperationException{
|
||||
var handle=player.getClass().getMethod("getHandle").invoke(player);
|
||||
return handle==null?null:handle.getClass().getField("connection").get(handle);
|
||||
}
|
||||
private FloatingCount captureFloating(Player player){
|
||||
try{
|
||||
var connection=connection(player);if(connection==null)return null;
|
||||
Class<?> type=connection.getClass();java.lang.reflect.Field field=null;
|
||||
while(type!=null){try{field=type.getDeclaredField("aboveGroundTickCount");break;}catch(NoSuchFieldException absent){type=type.getSuperclass();}}
|
||||
if(field==null)throw new NoSuchFieldException("Pinned native player floating count unavailable");
|
||||
field.setAccessible(true);return new FloatingCount(connection,field,field.getInt(connection));
|
||||
}catch(NoSuchMethodException unavailable){return null;/* Non-native API adapter has no connection to own. */}
|
||||
catch(ReflectiveOperationException|RuntimeException unavailable){report(unavailable);return null;}
|
||||
}
|
||||
private void release(Player player){
|
||||
var original=gravity.get(player.getUniqueId());
|
||||
if(original==null||original.player()!=player)return;
|
||||
gravity.remove(player.getUniqueId(),original);
|
||||
if(player.hasGravity())return;/* An intervening gravity change is not ours to undo. */
|
||||
var floating=original.floating();
|
||||
if(original.gravity()&&floating!=null){
|
||||
try{
|
||||
if(connection(player)==floating.connection()){
|
||||
int current=floating.field().getInt(floating.connection());
|
||||
if(current>floating.prior())floating.field().setInt(floating.connection(),floating.prior());
|
||||
}
|
||||
}catch(ReflectiveOperationException|RuntimeException unavailable){report(unavailable);}
|
||||
}
|
||||
player.setGravity(original.gravity());
|
||||
}
|
||||
public void revive(Player target,Duration protection,Consumer<RescueStatus> result){revive(target,protection,null,result);}
|
||||
public void revive(Player target,Duration protection,DownedEpisode.Decision decision,Consumer<RescueStatus> result){var episode=sessions.downed(target.getUniqueId()).orElse(null);if(episode==null){result.accept(RescueStatus.STALE);return;}var operation=decision==null?UUID.randomUUID():decision.actionId();directory.claim(episode.id(),DownedEpisode.Outcome.REVIVED,operation,decision,protection,java.util.List.of()).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);result.accept(RescueStatus.FAILED);return;}if(claim.status()==DownedDirectory.ClaimStatus.EXPIRED){result.accept(RescueStatus.EXPIRED);return;}if(claim.status()!=DownedDirectory.ClaimStatus.APPLIED){result.accept(RescueStatus.STALE);return;}var resolved=claim.episode().orElseThrow();if(events!=null&&!"phoenix_down".equals(resolved.decision()==null?null:resolved.decision().source())){events.resolved(resolved);events.abilityResolved(resolved);}sessions.reviveDowned(resolved,applied->{if(!applied){result.accept(RescueStatus.FAILED);return;}release(target);if(events!=null)events.outcomePublished(resolved);directory.publish(resolved.id(),resolved.outcomeOperation()).whenComplete((published,publishFailure)->schedule(()->{if(publishFailure!=null){report(publishFailure);result.accept(RescueStatus.APPLIED_PENDING_RECOVERY);}else{if(events!=null)events.outcomePublished(published);result.accept(RescueStatus.APPLIED);}}));});}));}
|
||||
private void finalizeDeath(DownedEpisode episode,String source){java.util.List<DownedEpisode.Reward> rewards;try{rewards=sessions.finalRewards(episode);}catch(RuntimeException failure){report(failure);return;}var operation=UUID.randomUUID();var decision=new DownedEpisode.Decision(source,"accept_death".equals(source)?episode.player():null,"accept_death".equals(source)?episode.playerName():null,"accept_death".equals(source)?episode.character():null,"accept_death".equals(source)?episode.characterName():null,"accept_death".equals(source)?episode.session():null,operation,null,null,null,null);directory.claim(episode.id(),DownedEpisode.Outcome.FINAL_DEATH,operation,decision,Duration.ZERO,rewards).whenComplete((claim,failure)->schedule(()->{if(failure!=null){report(failure);return;}if(claim.status()==DownedDirectory.ClaimStatus.APPLIED){var resolved=claim.episode().orElseThrow();if(events!=null)events.resolved(resolved);var player=plugin.getServer().getPlayer(episode.player());if(player!=null)release(player);sessions.finalizeDowned(resolved);}}));}
|
||||
@@ -55,8 +89,9 @@ public final class DownedController implements Listener,AutoCloseable{
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void effect(org.bukkit.event.entity.EntityPotionEffectEvent event){if(event.getEntity() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(event.getEntered() instanceof Player player&&sessions.downed(player.getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void flight(org.bukkit.event.player.PlayerToggleFlightEvent event){if(sessions.downed(event.getPlayer().getUniqueId()).isPresent())event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void slot(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent event){var player=event.getPlayer();if(sessions.downed(player.getUniqueId()).isEmpty()||!revertingSlots.add(player.getUniqueId()))return;var old=event.getOldItemStack()==null?null:event.getOldItemStack().clone();schedule(()->{try{if(sessions.downed(player.getUniqueId()).isPresent())player.getInventory().setItem(event.getSlot(),old);}finally{revertingSlots.remove(player.getUniqueId());}});}
|
||||
/** Native slot cache notifications are observations, not inventory-action or rollback authority. */
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void slot(io.papermc.paper.event.player.PlayerInventorySlotChangeEvent event){/* Actual actions are cancelled at their input boundaries; never replay cached old stacks. */}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(org.bukkit.event.player.PlayerQuitEvent event){release(event.getPlayer());}
|
||||
private void schedule(Runnable action){try{plugin.getServer().getScheduler().runTask(plugin,action);}catch(RuntimeException failure){report(failure);}}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){closed=true;if(pulse!=null)pulse.cancel();pulse=null;for(UUID player:java.util.List.copyOf(gravity.keySet())){var online=plugin.getServer().getPlayer(player);if(online!=null)release(online);}gravity.clear();revertingSlots.clear();endedProtection.clear();}
|
||||
@Override public void close(){closed=true;if(pulse!=null)pulse.cancel();pulse=null;for(UUID player:java.util.List.copyOf(gravity.keySet())){var online=plugin.getServer().getPlayer(player);if(online!=null)release(online);}gravity.clear();endedProtection.clear();}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -176,7 +176,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
int slot = player.getInventory().getHeldItemSlot();
|
||||
if (reservedExchange(player, slot)) {
|
||||
event.setCancelled(true);
|
||||
// Purpur 2618 snapshots the proposed hand stacks BEFORE dispatch. Never let its
|
||||
// Purpur 2651 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))) {
|
||||
@@ -408,7 +408,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
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.
|
||||
// Purpur 2651 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,
|
||||
@@ -542,7 +542,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
|| !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;
|
||||
// Purpur 2651 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++) {
|
||||
|
||||
@@ -54,6 +54,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
|
||||
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
|
||||
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
|
||||
@@ -72,6 +73,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final Map<UUID, Attempt> pending = new HashMap<>();
|
||||
private final Map<UUID, Attempt> owned = new HashMap<>();
|
||||
private final Map<UUID, Attempt> active = new HashMap<>();
|
||||
private final java.util.ArrayDeque<Active> activeRotation = new java.util.ArrayDeque<>();
|
||||
private final Map<UUID, UUID> characterRecoveries = new HashMap<>();
|
||||
private final java.util.Set<UUID> recoveryRecipients = new java.util.HashSet<>();
|
||||
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
|
||||
@@ -116,6 +118,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean healthAcknowledged = true;
|
||||
boolean nativeRestored;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
boolean restorationRefused;
|
||||
boolean receiptUncertain;
|
||||
boolean presentationInstalled;
|
||||
boolean cleaning;
|
||||
boolean cleanupAttempted;
|
||||
@@ -136,6 +140,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action restoreAction;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action recoveryAction;
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action sessionRecoveryAction;
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action adminRecoveryAction;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryOriginalLocation;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryCurrentLocation;
|
||||
String recoveryPriorPhase = "unavailable";
|
||||
@@ -145,6 +150,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean restoreEventWritten;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
boolean completedDeathRead;
|
||||
Attempt recoveryOwner;
|
||||
org.bukkit.Location entryLocation;
|
||||
boolean defeated;
|
||||
@@ -242,6 +248,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events){if(!owned.isEmpty()||sessionRecoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or sessions started");sessionRecoveryEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge audit){if(closed||adminAudit!=null)throw new IllegalStateException("Admin audit already attached or sessions closed");adminAudit=Objects.requireNonNull(audit);}
|
||||
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
|
||||
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
|
||||
|
||||
@@ -373,6 +380,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
.filter(snapshot -> current(snapshot.session())).toList();
|
||||
}
|
||||
|
||||
/** Bounded fair server-thread traversal; stale session identities retire without population snapshots. */
|
||||
public synchronized List<ActiveCharacter> rotateActiveCharacters(int limit) {
|
||||
if (limit < 1 || limit > 256) throw new IllegalArgumentException("Active traversal limit must be 1–256");
|
||||
if (closed) return List.of();
|
||||
var result = new java.util.ArrayList<ActiveCharacter>();
|
||||
int count = Math.min(limit, activeRotation.size());
|
||||
for (int index = 0; index < count; index++) {
|
||||
var session = activeRotation.removeFirst(); var attempt = active.get(session.player());
|
||||
if (attempt == null || !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())) continue;
|
||||
activeRotation.addLast(session);
|
||||
if (attempt.record != null) result.add(new ActiveCharacter(session, attempt.record));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
|
||||
public synchronized Optional<Active> activeCharacter(UUID character) {
|
||||
Objects.requireNonNull(character);
|
||||
@@ -381,6 +403,19 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
|
||||
}
|
||||
|
||||
/** Process-authority eligibility: safe on the serialized write worker; no native inventory/player calls. */
|
||||
public synchronized boolean acceptsCoinAdjustment(UUID character) {
|
||||
if (closed || characterRecoveries.containsKey(character) || unavailableCharacter.test(character)
|
||||
|| downedDirectory != null && downedDirectory.all().stream().anyMatch(value -> value.character().equals(character)
|
||||
&& (value.phase() == DownedEpisode.Phase.PENDING || !value.published()))) return false;
|
||||
if (java.util.stream.Stream.of(pending.values(), transitioning.values()).flatMap(java.util.Collection::stream)
|
||||
.anyMatch(attempt -> character.equals(attempt.character))) return false;
|
||||
return owned.values().stream().filter(attempt -> character.equals(attempt.character))
|
||||
.allMatch(attempt -> active.get(attempt.owner) == attempt && attempt.acknowledged && !attempt.downing
|
||||
&& attempt.downed == null && !attempt.exitRequested && !attempt.defeated && !attempt.cleaning
|
||||
&& !attempt.receiptUncertain && !attempt.uncertainSave && !attempt.nativeUncertain);
|
||||
}
|
||||
|
||||
/** Current native holder for a still-authorized server-thread action. */
|
||||
public synchronized Optional<Player> currentPlayer(Active session) {
|
||||
var attempt = active.get(session.player());
|
||||
@@ -746,6 +781,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.record = record;
|
||||
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
|
||||
active.put(attempt.owner, attempt);
|
||||
activeRotation.addLast(new Active(attempt.owner, attempt.session, attempt.character));
|
||||
queueCooldownRefresh(attempt);
|
||||
attempt.presentationInstalled = true;
|
||||
try { presentation.activate(attempt.player, attempt.session, record); }
|
||||
@@ -920,8 +956,142 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private CompletionStage<Optional<CharacterRecord>> recordOfflineFinalDeath(DownedEpisode episode){return characters.find(episode.character()).thenCompose(found->{if(found.isEmpty())return CompletableFuture.completedStage(Optional.empty());var current=found.orElseThrow();return characters.recordFinalDeath(episode.character(),current.revision(),episode.id(),episode.resolvedAt(),episode.attribution().playerDefeat());}).thenCompose(dead->{if(dead.isEmpty())return CompletableFuture.completedStage(dead);if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);return finalConsequences(episode).thenApply(ignored->dead);});}
|
||||
|
||||
private void reconcileOfflineFinalDeath(Player player,DownedEpisode episode){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null){diagnostic(new Failure(episode.player(),episode.session(),failure));return;}if(dead.isEmpty()||closed||!player.isOnline())return;var record=dead.orElseThrow();var current=player.getInventory().getItemInOffHand();var existing=tails.identify(current).orElse(null);if(existing!=null&&existing.characterId().equals(record.id())&&existing.deathId().equals(record.deathId())){publishRecoveredDowned(episode);return;}if(!sheets.authorizes(current,record)){notice(player.getUniqueId(),"DOWNED_RECOVERY_REQUIRED","Expired downed custody does not match the exact saved sheet; no native item was changed.");return;}try{player.getInventory().setItemInOffHand(tails.create(record));player.updateInventory();publishRecoveredDowned(episode);}catch(RuntimeException nativeFailure){diagnostic(new Failure(episode.player(),episode.session(),nativeFailure));notice(player.getUniqueId(),"DOWNED_RECOVERY_UNCERTAIN","Final-death Tail delivery was uncertain; retain inventory for inspection.");}}}));}
|
||||
private void publishRecoveredDowned(DownedEpisode episode){if(downedEvents!=null)downedEvents.outcomePublished(episode);downedDirectory.publish(episode.id(),episode.outcomeOperation()).whenComplete((published,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
|
||||
/** Replay only the saved entry return after native custody proof; ordinary native refusal retains the existing Tail fallback. */
|
||||
private boolean returnRecoveredDeath(Attempt attempt, DownedEpisode episode) {
|
||||
var world = resolveWorld(attempt.player, episode.entryWorld());
|
||||
if (world == null) {
|
||||
retainRecovery(attempt, "return_world_unavailable");
|
||||
return false;
|
||||
}
|
||||
attempt.entryLocation = location(world, episode.entryX(), episode.entryY(), episode.entryZ(),
|
||||
episode.entryYaw(), episode.entryPitch());
|
||||
attempt.defeatReturnTeleport = true;
|
||||
try {
|
||||
boolean returned = attempt.player.teleport(attempt.entryLocation.clone());
|
||||
if (downedEvents != null) downedEvents.returnAttempted(episode, returned ? "returned" : "refused");
|
||||
if (!returned) notice(attempt.owner, "DEFEAT_RETURN_FAILED",
|
||||
"Return to the session entry location was refused; you remain here.");
|
||||
return true;
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.nativeUncertain = true;
|
||||
if (downedEvents != null) downedEvents.returnAttempted(episode, "uncertain");
|
||||
conversionUncertain(attempt, "Recovered final-death return was uncertain; retained native custody");
|
||||
diagnostic(attempt, failure);
|
||||
return false;
|
||||
} finally {
|
||||
attempt.defeatReturnTeleport = false;
|
||||
if (!attempt.deliveryInvalidated && owned.get(attempt.owner) == attempt && attempt.player.isOnline())
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
captureRecoveryLocations(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
/** Historical offline finalization has no pending receipt: the completed exit and native state must agree. */
|
||||
private void reconcileCompletedDeath(Attempt attempt, EquipmentSessionStore.State state, DownedEpisode episode) {
|
||||
var prior = attempt.recoveryOwner;
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (state == null || state.pending() != null || exit == null
|
||||
|| !exit.sessionId().equals(episode.session()) || !exit.characterId().equals(episode.character())
|
||||
|| marker == null || !marker.matches(episode.session(), episode.character())
|
||||
|| marker.phase() != EquipmentSessionMarker.Phase.RESTORED
|
||||
|| resolveWorld(attempt.player, episode.entryWorld()) == null
|
||||
|| hasRemnants(attempt.player) || prior != null && (prior.nativeUncertain
|
||||
|| !prior.defeated || prior.receiptUncertain || !prior.gearAcknowledged
|
||||
|| !deathProof(prior, episode))) {
|
||||
notice(attempt.owner, "DOWNED_RECOVERY_REQUIRED",
|
||||
"Completed exit, exact death and native restored custody could not be proved; no item was changed.");
|
||||
return;
|
||||
}
|
||||
if (transitioning.putIfAbsent(attempt.owner, attempt) != null) return;
|
||||
attempt.completedDeathRead = true;
|
||||
// Character work may outlive this Player. A new arrival is never allowed to publish
|
||||
// through a stale callback, and the original owner's outstanding claim is retained.
|
||||
persist(() -> characters.find(episode.character()).thenCompose(found -> {
|
||||
if (found.isPresent() && deadFor(found.orElseThrow(), episode))
|
||||
return finalConsequences(episode).thenApply(ignored -> found);
|
||||
if (found.isPresent() && found.orElseThrow().dead())
|
||||
return CompletableFuture.completedStage(Optional.empty());
|
||||
return recordOfflineFinalDeath(episode);
|
||||
}), (dead, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
try {
|
||||
if (failure != null) { diagnostic(attempt, failure); return; }
|
||||
var currentDeath = unresolvedFinalDeath(attempt.owner);
|
||||
if (dead == null || dead.isEmpty() || !deadFor(dead.orElseThrow(), episode)
|
||||
|| closed || !attempt.player.isOnline() || currentDeath == null
|
||||
|| !currentDeath.id().equals(episode.id())
|
||||
|| transitioning.get(attempt.owner) != attempt
|
||||
|| owned.get(attempt.owner) != prior) return;
|
||||
if (!unchangedRecovery(attempt) || resolveWorld(attempt.player, episode.entryWorld()) == null) {
|
||||
retainRecovery(attempt, "death_or_native_custody_changed");
|
||||
return;
|
||||
}
|
||||
var liveMarker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (liveMarker == null || !liveMarker.matches(episode.session(), episode.character())
|
||||
|| liveMarker.phase() != EquipmentSessionMarker.Phase.RESTORED
|
||||
|| hasRemnants(attempt.player)) {
|
||||
notice(attempt.owner, "DOWNED_RECOVERY_REQUIRED",
|
||||
"Completed exit or exact saved sheet changed before Tail delivery; no item was changed.");
|
||||
return;
|
||||
}
|
||||
if (prior != null && prior.player == attempt.player) {
|
||||
retryTail(attempt.player, prior);
|
||||
return;
|
||||
}
|
||||
var current = attempt.player.getInventory().getItemInOffHand();
|
||||
var existing = tails.identify(current).orElse(null);
|
||||
if (existing != null) {
|
||||
if (existing.characterId().equals(episode.character()) && existing.deathId().equals(episode.id()))
|
||||
publishRecoveredDowned(episode);
|
||||
return;
|
||||
}
|
||||
if (!sheets.authorizes(current, dead.orElseThrow())) {
|
||||
notice(attempt.owner, "DOWNED_RECOVERY_REQUIRED",
|
||||
"The current sheet does not match the saved death; no item was changed.");
|
||||
return;
|
||||
}
|
||||
attempt.defeated = true;
|
||||
attempt.finalDowned = episode;
|
||||
attempt.deathResolved = true;
|
||||
attempt.deathRecord = dead.orElseThrow();
|
||||
attempt.gearAcknowledged = true;
|
||||
attempt.partyDefeatAcknowledged = true;
|
||||
attempt.session = episode.session();
|
||||
attempt.character = episode.character();
|
||||
attempt.claim = prior == null ? null : prior.claim;
|
||||
owned.put(attempt.owner, attempt);
|
||||
if (prior != null) prior.deliveryInvalidated = true;
|
||||
attempt.deathReference = copy(current);
|
||||
attempt.deathNativeReference = nativeIdentity(current);
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
if (returnRecoveredDeath(attempt, episode)) deliverTail(attempt, dead.orElseThrow());
|
||||
} finally {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
diagnostic(attempt, rejected);
|
||||
finishDrain();
|
||||
}
|
||||
});
|
||||
}
|
||||
private void publishRecoveredDowned(DownedEpisode episode){
|
||||
var current = unresolvedFinalDeath(episode.player());
|
||||
if(closed || current == null || !current.id().equals(episode.id()))return;
|
||||
if(downedEvents!=null)downedEvents.outcomePublished(episode);
|
||||
downedDirectory.publish(episode.id(),episode.outcomeOperation()).whenComplete((published,failure)->{
|
||||
synchronized(EquipmentSessions.this){
|
||||
if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));
|
||||
else if(!closed&&downedEvents!=null)downedEvents.outcomePublished(published);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** No automatic delivery after quit/respawn, including a reconnect of the same Player ID. */
|
||||
public synchronized void invalidateContext(UUID owner) {
|
||||
@@ -948,7 +1118,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void queueTail(Attempt attempt) {
|
||||
if (closed || attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|
||||
// Persistence callbacks may enqueue immutable authority; native online/view/item checks
|
||||
// belong to deliverTail on the owner thread, never the receipt/death worker.
|
||||
if (closed || owned.get(attempt.owner) != attempt
|
||||
|| attempt.tailCallbackQueued || attempt.bookAttempted || attempt.bookDelivered
|
||||
|| attempt.deliveryInvalidated || !attempt.deathResolved || !attempt.partyDefeatAcknowledged
|
||||
|| !attempt.gearAcknowledged || attempt.deathRecord == null) return;
|
||||
attempt.tailCallbackQueued = true;
|
||||
@@ -985,8 +1158,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void deliverTail(Attempt attempt, CharacterRecord dead) {
|
||||
if (attempt.bookAttempted || attempt.bookDelivered) return;
|
||||
if (owned.get(attempt.owner) != attempt || attempt.bookAttempted || attempt.bookDelivered) return;
|
||||
try {
|
||||
if (attempt.finalDowned != null && (!attempt.deathResolved || !attempt.partyDefeatAcknowledged
|
||||
|| !attempt.gearAcknowledged || !deadFor(dead, attempt.finalDowned)
|
||||
|| !deathProof(attempt, unresolvedFinalDeath(attempt.owner)))) {
|
||||
conversionUncertain(attempt, "Exact final-death prerequisites were not acknowledged before Tail delivery");
|
||||
return;
|
||||
}
|
||||
if (!tailContext(attempt) || !tailCustody(attempt)) {
|
||||
conversionUncertain(attempt, "Tail destination/context is stale or equipment restoration is unresolved"); return;
|
||||
}
|
||||
@@ -1108,10 +1287,17 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
attempt.restorationRefused = result != EquipmentStorage.RestoreResult.RESTORED;
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && attempt.defeated && attempt.deathReference == null) {
|
||||
var retained = attempt.player.getInventory().getItemInOffHand();
|
||||
attempt.deathReference = copy(retained);
|
||||
attempt.deathNativeReference = nativeIdentity(retained);
|
||||
attempt.deathView = attempt.player.getOpenInventory();
|
||||
}
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (equipmentEvents != null) {
|
||||
@@ -1231,16 +1417,39 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.recoveryCurrentLocation = sessionRecoveryEvents.captureLocation(attempt.player.getLocation());
|
||||
}
|
||||
|
||||
private void adminRecoveryResult(Attempt attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome,
|
||||
String reason) {
|
||||
if (adminAudit == null || attempt.adminRecoveryAction == null) return;
|
||||
var facts = new java.util.LinkedHashMap<String, Object>();
|
||||
if (attempt.sessionRecoveryAction != null) facts.put("domain_operation_id", attempt.sessionRecoveryAction.id());
|
||||
if (attempt.character != null) facts.put("subject_id", attempt.character);
|
||||
adminAudit.result(attempt.adminRecoveryAction, outcome, reason, facts);
|
||||
}
|
||||
|
||||
private void sessionReconciled(Attempt attempt, String phase, String outcome, String quality,
|
||||
String reason, java.math.BigInteger revision) {
|
||||
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null || attempt.sessionRecoveryResolved) return;
|
||||
if (attempt.sessionRecoveryResolved
|
||||
|| (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null)
|
||||
&& attempt.adminRecoveryAction == null) return;
|
||||
attempt.sessionRecoveryResolved = true;
|
||||
try {
|
||||
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
|
||||
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
|
||||
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
|
||||
if (sessionRecoveryEvents != null && attempt.sessionRecoveryAction != null)
|
||||
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
|
||||
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
|
||||
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
adminRecoveryResult(attempt, switch (outcome) {
|
||||
case "restored" -> games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED;
|
||||
case "failed" -> games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED;
|
||||
case "retained" -> games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED;
|
||||
default -> games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN;
|
||||
}, switch (outcome) {
|
||||
case "restored" -> "acknowledged";
|
||||
case "failed" -> "operation_failed";
|
||||
case "retained" -> "owner_refused";
|
||||
default -> "operation_uncertain";
|
||||
});
|
||||
}
|
||||
|
||||
private void nutritionRetained(Attempt attempt, String reason) {
|
||||
@@ -1290,61 +1499,93 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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) {
|
||||
nutritionRetained(attempt, "receipt_save_failed");
|
||||
sessionReconciled(attempt,
|
||||
attempt.nativeRestored ? "restored" : "pending",
|
||||
attempt.nativeRestored ? "ambiguous" : "failed",
|
||||
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
|
||||
"receipt_save_failed", attempt.recoveryReceiptRevision);
|
||||
diagnostic(attempt, failure);
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
} else if (complete) {
|
||||
attempt.gearAcknowledged = true;
|
||||
if (!attempt.restoreEventWritten && equipmentEvents != null && attempt.restoreEvent != null) {
|
||||
attempt.restoreEventWritten = true;
|
||||
equipmentEvents.restored(attempt.restoreEvent, state.revision());
|
||||
}
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "accepted", "receipt_completed");
|
||||
if (attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "accepted", "receipt_completed");
|
||||
equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"restored", "receipt_completed", state.revision());
|
||||
if (attempt.finalDowned == null) {
|
||||
exitAcknowledged(attempt, complete, state, failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
exitAcknowledged(attempt, complete, state, failure);
|
||||
}
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"receipt_completed", state.revision());
|
||||
if (!attempt.switchEventWritten && switchEvents != null) {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
}
|
||||
if (!attempt.nutritionRestoreWritten && nutritionEvents != null
|
||||
&& attempt.nutritionAfterRestore != null) {
|
||||
attempt.nutritionRestoreWritten = true;
|
||||
nutritionEvents.restored(attempt.nutritionBeforeRestore, attempt.nutritionAfterRestore,
|
||||
attempt.ordinaryVitals, state.revision());
|
||||
}
|
||||
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
|
||||
attempt.exitEventWritten = true;
|
||||
sessionEvents.exited(attempt.session,attempt.exitEvent);
|
||||
}
|
||||
if (attempt.defeated) queueTail(attempt);
|
||||
release(attempt);
|
||||
} else {
|
||||
nutritionRetained(attempt, !attempt.healthAcknowledged ? "health_unverified" : "receipt_incomplete");
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
attempt.saving = false;
|
||||
attempt.receiptUncertain = true;
|
||||
conversionUncertain(attempt, "Native death acknowledgement scheduling was refused; retain completed receipt and custody");
|
||||
diagnostic(attempt, rejected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Final-death acknowledgment is handed back to the native owner before item/view validation. */
|
||||
private void exitAcknowledged(Attempt attempt, boolean complete, EquipmentSessionStore.State state, Throwable failure) {
|
||||
attempt.saving = false;
|
||||
if (failure != null) {
|
||||
if (attempt.defeated) attempt.receiptUncertain = true;
|
||||
nutritionRetained(attempt, "receipt_save_failed");
|
||||
sessionReconciled(attempt,
|
||||
attempt.nativeRestored ? "restored" : "pending",
|
||||
attempt.nativeRestored ? "ambiguous" : "failed",
|
||||
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
|
||||
"receipt_save_failed", attempt.recoveryReceiptRevision);
|
||||
diagnostic(attempt, failure);
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
} else if (complete && attempt.finalDowned != null && (closed || owned.get(attempt.owner) != attempt
|
||||
|| !attempt.player.isOnline() || state.pending() != null || state.lastExit() == null
|
||||
|| !state.lastExit().sessionId().equals(attempt.session)
|
||||
|| !state.lastExit().characterId().equals(attempt.character)
|
||||
|| attempt.deathResolved && !deathProof(attempt, unresolvedFinalDeath(attempt.owner))
|
||||
|| !tailContext(attempt) || !tailCustody(attempt))) {
|
||||
conversionUncertain(attempt, "Native death custody changed before receipt acknowledgement; completion is not Tail authority");
|
||||
} else if (complete) {
|
||||
attempt.gearAcknowledged = true;
|
||||
if (!attempt.restoreEventWritten && equipmentEvents != null && attempt.restoreEvent != null) {
|
||||
attempt.restoreEventWritten = true;
|
||||
equipmentEvents.restored(attempt.restoreEvent, state.revision());
|
||||
}
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "accepted", "receipt_completed");
|
||||
if (attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "accepted", "receipt_completed");
|
||||
equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"restored", "receipt_completed", state.revision());
|
||||
}
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"receipt_completed", state.revision());
|
||||
if (!attempt.switchEventWritten && switchEvents != null) {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
}
|
||||
if (!attempt.nutritionRestoreWritten && nutritionEvents != null
|
||||
&& attempt.nutritionAfterRestore != null) {
|
||||
attempt.nutritionRestoreWritten = true;
|
||||
nutritionEvents.restored(attempt.nutritionBeforeRestore, attempt.nutritionAfterRestore,
|
||||
attempt.ordinaryVitals, state.revision());
|
||||
}
|
||||
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
|
||||
attempt.exitEventWritten = true;
|
||||
sessionEvents.exited(attempt.session,attempt.exitEvent);
|
||||
}
|
||||
if (attempt.defeated) queueTail(attempt);
|
||||
release(attempt);
|
||||
} else {
|
||||
nutritionRetained(attempt, !attempt.healthAcknowledged ? "health_unverified" : "receipt_incomplete");
|
||||
if (attempt.defeated) {
|
||||
if (attempt.restorationRefused)
|
||||
conversionNotStarted(attempt, "Equipment restoration was refused; receipt and Tail custody retained");
|
||||
else conversionUncertain(attempt,
|
||||
"Equipment restoration was not acknowledged; Tail conversion remains unsafe");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A synchronous exit request is not proof of an acknowledged character/receipt restoration. */
|
||||
public record DisableSummary(int entryRefused, int exitRequested, int nativeRestoredPendingReceipt,
|
||||
int exitFailed, int exitPending) { }
|
||||
@@ -1373,7 +1614,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
public synchronized void cancelEntry(UUID player) {
|
||||
var attempt = pending.remove(player);
|
||||
if (attempt != null) abandon(attempt);
|
||||
if (attempt != null) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
"operation_uncertain");
|
||||
abandon(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
@@ -1417,6 +1662,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(attempt.owner, code, message);
|
||||
}
|
||||
private void fail(Attempt attempt, Throwable failure) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
"operation_uncertain");
|
||||
if (attempt.effectsStarted && owned.get(attempt.owner) == attempt) attempt.nativeUncertain = true;
|
||||
pending.remove(attempt.owner, attempt);
|
||||
active.remove(attempt.owner, attempt);
|
||||
@@ -1444,7 +1691,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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;
|
||||
if (closed) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
|
||||
"operation_uncertain");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
@@ -1463,40 +1714,56 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
|
||||
public synchronized void recover(Player player) { recover(player, false, null); }
|
||||
public synchronized void recover(Player player) { recover(player, false, null, null); }
|
||||
|
||||
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
recover(player, false, action);
|
||||
recover(player, false, action, null);
|
||||
}
|
||||
|
||||
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
|
||||
public synchronized void arrive(Player player) {
|
||||
var directory=downedDirectory;
|
||||
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
|
||||
recover(player,true,null);
|
||||
/** The command's captured action is carried across receipt I/O, not rediscovered from a callback thread. */
|
||||
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action adminAction) {
|
||||
recover(player, false, action, adminAction);
|
||||
}
|
||||
|
||||
/** Read the receipt before considering any historical no-pending-receipt Tail delivery. */
|
||||
public synchronized void arrive(Player player) { recover(player, true, null, null); }
|
||||
|
||||
private void recover(Player player, boolean arrival,
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action,
|
||||
games.dmg.fantasystick.events.AdminActionEventBridge.Action adminAction) {
|
||||
UUID owner = player.getUniqueId();
|
||||
invalidateCursor(owner);
|
||||
if (closed || !player.isOnline()) return;
|
||||
var refused = new Attempt(player, owner, null);
|
||||
refused.sessionRecoveryAction = action;
|
||||
refused.adminRecoveryAction = adminAction;
|
||||
if (closed || !player.isOnline()) {
|
||||
adminRecoveryResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "owner_refused");
|
||||
return;
|
||||
}
|
||||
var previous = owned.get(owner);
|
||||
if (previous != null && previous.defeated && (arrival || previous.gearAcknowledged
|
||||
|| previous.bookDelivered || previous.player != player)) {
|
||||
if (previous != null && previous.defeated && (previous.bookAttempted || previous.bookDelivered
|
||||
|| !arrival && previous.gearAcknowledged)) {
|
||||
adminRecoveryResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "owner_refused");
|
||||
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 (previous != null && (previous.nativeUncertain || previous.receiptUncertain)) {
|
||||
adminRecoveryResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain");
|
||||
notice(owner, "NATIVE_UNCERTAIN", "An equipment or receipt 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)) {
|
||||
adminRecoveryResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "owner_refused");
|
||||
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;
|
||||
attempt.snapshot = snapshot(player);
|
||||
attempt.cursor = copy(player.getItemOnCursor());
|
||||
attempt.entryMarker = EquipmentSessionMarker.read(player).orElse(null);
|
||||
attempt.sessionRecoveryAction = action;
|
||||
attempt.adminRecoveryAction = adminAction;
|
||||
// A recovered receipt has no process-local never-packed proof.
|
||||
attempt.effectsStarted = true;
|
||||
attempt.arrival = arrival;
|
||||
@@ -1505,11 +1772,149 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
schedule(attempt, () -> recovered(attempt, state, failure)));
|
||||
}
|
||||
|
||||
private DownedEpisode unresolvedFinalDeath(UUID owner) {
|
||||
return downedDirectory == null ? null : downedDirectory.all().stream()
|
||||
.filter(value -> value.player().equals(owner) && value.phase() == DownedEpisode.Phase.RESOLVED
|
||||
&& value.outcome() == DownedEpisode.Outcome.FINAL_DEATH && !value.published())
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private static boolean deadFor(CharacterRecord record, DownedEpisode episode) {
|
||||
return record != null && episode != null && record.id().equals(episode.character())
|
||||
&& record.dead() && episode.id().equals(record.deathId())
|
||||
&& episode.resolvedAt().equals(record.diedAt());
|
||||
}
|
||||
|
||||
private static boolean deathProof(Attempt previous, DownedEpisode episode) {
|
||||
return episode != null && previous.finalDowned != null && previous.finalDowned.id().equals(episode.id())
|
||||
&& previous.owner.equals(episode.player()) && previous.session.equals(episode.session())
|
||||
&& previous.character.equals(episode.character()) && previous.deathResolved
|
||||
&& deadFor(previous.deathRecord, episode);
|
||||
}
|
||||
|
||||
private void retainRecovery(Attempt attempt, String reason) {
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(attempt.recoveryAction, "retained", reason);
|
||||
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only", reason,
|
||||
attempt.recoveryReceiptRevision);
|
||||
notice(attempt.owner, "DEFEAT_INSPECTION_REQUIRED", "Death or equipment custody is unproved ("
|
||||
+ reason + "); the original receipt and native items were retained.");
|
||||
}
|
||||
|
||||
private boolean unchangedRecovery(Attempt attempt) {
|
||||
return Arrays.equals(attempt.snapshot, snapshot(attempt.player))
|
||||
&& Objects.equals(attempt.cursor, copy(attempt.player.getItemOnCursor()))
|
||||
&& Objects.equals(attempt.entryMarker, EquipmentSessionMarker.read(attempt.player).orElse(null));
|
||||
}
|
||||
|
||||
private boolean packedDeathCustody(Attempt attempt, DownedEpisode episode) {
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
return attempt.session.equals(episode.session()) && attempt.character.equals(episode.character())
|
||||
&& unchangedRecovery(attempt) && marker != null
|
||||
&& marker.matches(attempt.session, attempt.character)
|
||||
&& marker.phase() == EquipmentSessionMarker.Phase.PACKED
|
||||
&& equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).isPresent();
|
||||
}
|
||||
|
||||
/** A cold coordinator must continue the same death through restoration, receipt and Tail, on its first arrival. */
|
||||
private void reconcilePendingDeath(Attempt attempt, DownedEpisode episode) {
|
||||
if (!packedDeathCustody(attempt, episode) || resolveWorld(attempt.player, episode.entryWorld()) == null) {
|
||||
retainRecovery(attempt, "native_custody_unverified");
|
||||
return;
|
||||
}
|
||||
if (transitioning.putIfAbsent(attempt.owner, attempt) != null) return;
|
||||
attempt.completedDeathRead = true;
|
||||
attempt.defeated = true;
|
||||
attempt.finalDowned = episode;
|
||||
attempt.exitReason = "defeat";
|
||||
owned.put(attempt.owner, attempt);
|
||||
persist(() -> characters.find(episode.character()).thenCompose(found -> {
|
||||
if (found.isPresent() && deadFor(found.orElseThrow(), episode))
|
||||
return finalConsequences(episode).thenApply(ignored -> found);
|
||||
if (found.isPresent() && found.orElseThrow().dead())
|
||||
return CompletableFuture.completedStage(Optional.empty());
|
||||
return recordOfflineFinalDeath(episode);
|
||||
}), (dead, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
synchronized (EquipmentSessions.this) {
|
||||
try {
|
||||
if (failure != null) {
|
||||
diagnostic(attempt, failure);
|
||||
conversionUncertain(attempt, "Pending death persistence/consequences were not acknowledged");
|
||||
return;
|
||||
}
|
||||
var currentDeath = unresolvedFinalDeath(attempt.owner);
|
||||
if (closed || !attempt.player.isOnline() || owned.get(attempt.owner) != attempt
|
||||
|| transitioning.get(attempt.owner) != attempt || currentDeath == null
|
||||
|| !currentDeath.id().equals(episode.id())) return;
|
||||
if (dead == null || dead.isEmpty() || !deadFor(dead.orElseThrow(), episode)
|
||||
|| !packedDeathCustody(attempt, episode)
|
||||
|| resolveWorld(attempt.player, episode.entryWorld()) == null) {
|
||||
retainRecovery(attempt, "death_or_native_custody_changed");
|
||||
return;
|
||||
}
|
||||
attempt.record = dead.orElseThrow();
|
||||
attempt.deathRecord = attempt.record;
|
||||
attempt.deathResolved = true;
|
||||
attempt.partyDefeatAcknowledged = true; // Actual consequence future succeeded.
|
||||
attempt.recoveryPriorPhase = "packed";
|
||||
if (restoreExit(attempt) == EquipmentStorage.RestoreResult.RESTORED)
|
||||
returnRecoveredDeath(attempt, episode);
|
||||
} finally {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
finishDrain();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
conversionUncertain(attempt, "Pending death native scheduling was refused");
|
||||
diagnostic(attempt, rejected);
|
||||
finishDrain();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Transfer the original claim and death authority only after current-Player proof, not on UUID alone. */
|
||||
private Attempt rebindDeath(Attempt previous, Attempt rebound) {
|
||||
rebound.claim = previous.claim;
|
||||
rebound.record = previous.record;
|
||||
rebound.deathRecord = previous.deathRecord;
|
||||
rebound.finalDowned = previous.finalDowned;
|
||||
rebound.deathResolved = previous.deathResolved;
|
||||
rebound.partyDefeatAcknowledged = previous.partyDefeatAcknowledged;
|
||||
rebound.defeated = true;
|
||||
rebound.exitReason = previous.exitReason;
|
||||
rebound.entryLocation = previous.entryLocation;
|
||||
rebound.exitDelay = previous.exitDelay;
|
||||
rebound.healthAcknowledged = previous.healthAcknowledged;
|
||||
rebound.switchExit = previous.switchExit;
|
||||
rebound.switchEventWritten = previous.switchEventWritten;
|
||||
rebound.nutritionRestoreWritten = previous.nutritionRestoreWritten;
|
||||
rebound.nutritionRetainedWritten = previous.nutritionRetainedWritten;
|
||||
rebound.nutritionBeforeRestore = previous.nutritionBeforeRestore;
|
||||
rebound.nutritionAfterRestore = previous.nutritionAfterRestore;
|
||||
rebound.gearRestoredForModeRecovery = false; // Never inherit same-Player native proof.
|
||||
rebound.recoveryPriorPhase = EquipmentSessionMarker.read(rebound.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
previous.deliveryInvalidated = true;
|
||||
owned.put(rebound.owner, rebound);
|
||||
return rebound;
|
||||
}
|
||||
|
||||
private synchronized void recovered(Attempt attempt, Optional<EquipmentSessionStore.State> state, Throwable failure) {
|
||||
if (!current(attempt)) return;
|
||||
if (!current(attempt)) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain");
|
||||
return;
|
||||
}
|
||||
pending.remove(attempt.owner, attempt);
|
||||
if (!attempt.player.isOnline()) return;
|
||||
if (!attempt.player.isOnline()) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "owner_refused");
|
||||
return;
|
||||
}
|
||||
if (owned.get(attempt.owner) != attempt.recoveryOwner) {
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "stale");
|
||||
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
|
||||
return;
|
||||
}
|
||||
@@ -1521,12 +1926,19 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
"failed", "receipt_unreadable", "receipt_read_failed", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed");
|
||||
diagnostic(attempt, failure); return;
|
||||
}
|
||||
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
|
||||
if (receipt == null) {
|
||||
if (attempt.arrival) {
|
||||
var episode=downedDirectory==null?null:downedDirectory.all().stream().filter(value->value.player().equals(attempt.owner)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
var death = unresolvedFinalDeath(attempt.owner);
|
||||
if (death != null) {
|
||||
reconcileCompletedDeath(attempt, state.orElse(null), death);
|
||||
return;
|
||||
}
|
||||
var arrivingOwner = attempt.owner;
|
||||
var episode=downedDirectory==null?null:downedDirectory.all().stream().filter(value->value.player().equals(arrivingOwner)&&value.phase()==DownedEpisode.Phase.PENDING).findFirst().orElse(null);
|
||||
if (sheets.identify(attempt.player.getInventory().getItemInOffHand()).isPresent()) enter(attempt.player,episode);
|
||||
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.");
|
||||
@@ -1542,6 +1954,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
"receipt_missing", "no_receipt", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "not_found");
|
||||
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
@@ -1558,21 +1971,52 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.healthAcknowledged = !attempt.vitalsStarted;
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
var savedDeath = unresolvedFinalDeath(attempt.owner);
|
||||
if (savedDeath != null && (!attempt.session.equals(savedDeath.session())
|
||||
|| !attempt.character.equals(savedDeath.character()) || !unchangedRecovery(attempt))) {
|
||||
retainRecovery(attempt, "death_or_native_custody_changed");
|
||||
return;
|
||||
}
|
||||
if (previous == null && attempt.arrival && savedDeath != null) {
|
||||
reconcilePendingDeath(attempt, savedDeath);
|
||||
return;
|
||||
}
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(attempt.recoveryAction, "refused", "session_mismatch");
|
||||
adminRecoveryResult(attempt, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "stale");
|
||||
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;
|
||||
previous.recoveryAction = attempt.recoveryAction;
|
||||
previous.sessionRecoveryAction = attempt.sessionRecoveryAction;
|
||||
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
|
||||
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
restoreExit(previous);
|
||||
if (previous.finalDowned != null && !deathProof(previous, unresolvedFinalDeath(attempt.owner))) {
|
||||
retainRecovery(attempt, "death_unverified");
|
||||
return;
|
||||
}
|
||||
if (previous.player != attempt.player) {
|
||||
// Never use a same-UUID obsolete Player's inventory or its native identity as
|
||||
// custody proof. Rebind only after inspecting the current Player's PACKED box.
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (!previous.defeated || previous.finalDowned == null || previous.saving || !previous.deathResolved
|
||||
|| !previous.partyDefeatAcknowledged
|
||||
|| marker == null || !marker.matches(attempt.session, attempt.character)
|
||||
|| marker.phase() != EquipmentSessionMarker.Phase.PACKED
|
||||
|| equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).isEmpty()) {
|
||||
retainRecovery(attempt, "native_custody_unverified");
|
||||
return;
|
||||
}
|
||||
attempt = rebindDeath(previous, attempt);
|
||||
} else {
|
||||
// A refused same-Player restoration is retryable only while its first native
|
||||
// write returned a definite refusal; an exception is never automatically replayed.
|
||||
previous.recoveryAction = attempt.recoveryAction;
|
||||
previous.sessionRecoveryAction = attempt.sessionRecoveryAction;
|
||||
previous.adminRecoveryAction = attempt.adminRecoveryAction;
|
||||
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
|
||||
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
attempt = previous;
|
||||
}
|
||||
restoreExit(attempt);
|
||||
return;
|
||||
}
|
||||
if (previous != null) {
|
||||
@@ -1633,7 +2077,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
for (var attempt : transitioning.values()) {
|
||||
for (var attempt : java.util.List.copyOf(transitioning.values())) {
|
||||
if (attempt.completedDeathRead) {
|
||||
transitioning.remove(attempt.owner, attempt);
|
||||
continue; // No native mutation has begun; stale read callbacks must not publish.
|
||||
}
|
||||
attempt.exitRequested = true;
|
||||
withdrawPresentation(attempt);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import java.util.Objects;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
/**
|
||||
* Purpur 2618 main-storage-only exchange, called on the server thread after revalidation.
|
||||
* Purpur 2651 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.
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
* Purpur 2651 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.
|
||||
|
||||
@@ -6,6 +6,12 @@ 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
|
||||
# Automatic native Speed I for living active characters grounded/on foot on Dirt Paths.
|
||||
# Admin: /fstickadmin config <key> [value]; no points or passive slots.
|
||||
path-speed-enabled: true
|
||||
# Finite native ticks, inclusive 1–200 (default 20); subsequent grants/refreshes use acknowledged updates.
|
||||
# Refused/interrupted cleanup can leave this finite lease until native expiry; offline time is not tick expiry.
|
||||
path-speed-lease-ticks: 20
|
||||
party-max-size: 6
|
||||
party-registration-price: 10
|
||||
party-leader-change-price: 10
|
||||
@@ -117,7 +123,7 @@ great-sword-recovery-seconds: 0.75
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
frost-shard-base-damage: 4
|
||||
frost-shard-cooldown-seconds: 5
|
||||
frost-shard-range-blocks: 32
|
||||
frost-shard-range-blocks: 48
|
||||
frost-shard-lifetime-seconds: 5
|
||||
frost-shard-projectile-speed-blocks-per-tick: 3
|
||||
frost-shard-slowness-amplifier: 0
|
||||
@@ -212,8 +218,10 @@ chain-lightning-base-damage: 8
|
||||
chain-lightning-cooldown-seconds: 18
|
||||
chain-lightning-initial-range-blocks: 16
|
||||
chain-lightning-jump-range-blocks: 6
|
||||
chain-lightning-additional-targets: 3
|
||||
chain-lightning-additional-targets: 4
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
chain-lightning-target-int-step: 5
|
||||
chain-lightning-max-targets: 12
|
||||
|
||||
# Sanctuary defaults from the reviewed remaining-development packet.
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: FantasyStick
|
||||
version: ${version}
|
||||
main: games.dmg.fantasystick.FantasyStickPlugin
|
||||
api-version: "26.2"
|
||||
api-version: "26.3"
|
||||
depend: [ProtocolLib]
|
||||
description: Transferable characters, native fantasy health, recoverable equipment, character books and parties.
|
||||
author: dmg.games
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Registered command -> authoritative read -> native open -> privacy-bounded JSONL. */
|
||||
class ActiveInspectionEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredOtherAccountReadExportsOnlyRevalidatedIdentityAndViewMetadata() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
fixture.actor.accountName = "Administrator";
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
target.accountName = "Target";
|
||||
f.players.put(target.core.id, target.player);
|
||||
f.activate(target);
|
||||
target.core.health = 7;
|
||||
var active = sessions(f).active(target.core.id).orElseThrow();
|
||||
var record = storage(f).find(active.character()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var path = data.resolve("characters").resolve(record.id() + ".yml");
|
||||
var saved = Files.readAllBytes(path);
|
||||
var before = target.core.snapshot();
|
||||
var adminBefore = fixture.actor.core.snapshot();
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character view Target"));
|
||||
assertTrue(fixture.actor.books.isEmpty());
|
||||
assertTrue(rows(data).isEmpty(), "dispatch and queued load must not claim an inspection");
|
||||
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> fixture.actor.books.size() == 1);
|
||||
awaitRows(f, 1);
|
||||
var rows = rows(data);
|
||||
assertEquals(1, rows.size());
|
||||
var row = rows.getFirst();
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals(fixture.actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
assertEquals(target.core.id.toString(), values.get("target_player_id").getAsString());
|
||||
assertEquals(record.id().toString(), values.get("target_character_id").getAsString());
|
||||
assertEquals(active.session().toString(), values.get("target_session_id").getAsString());
|
||||
assertEquals(record.revision().toString(), values.get("target_revision").getAsString());
|
||||
assertEquals("completed", values.get("outcome").getAsString());
|
||||
assertEquals("native_open_acknowledged", values.get("reason").getAsString());
|
||||
assertEquals(0, values.get("requested_offset").getAsInt());
|
||||
assertEquals(((org.bukkit.inventory.meta.BookMeta) fixture.actor.books.getLast().getItemMeta()).getPageCount(),
|
||||
values.get("presented_pages").getAsInt());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var central = AdminActionEntryNativeTest.rows(data);
|
||||
var terminal = central.stream().filter(v -> v.getAsJsonObject("data").has("outcome")
|
||||
&& v.getAsJsonObject("data").get("outcome").getAsString().equals("completed")).toList();
|
||||
assertEquals(1, terminal.size());
|
||||
assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id"));
|
||||
assertEquals(row.get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id"));
|
||||
assertFalse(rows.toString().contains(record.name()));
|
||||
assertFalse(values.has("stats") || values.has("pages") || values.has("inventory") || values.has("record"));
|
||||
assertArrayEquals(saved, Files.readAllBytes(path));
|
||||
assertArrayEquals(before, target.core.snapshot());
|
||||
assertArrayEquals(adminBefore, fixture.actor.core.snapshot());
|
||||
assertTrue(target.books.isEmpty());
|
||||
entry.map.tabComplete(entry.sender, "fstickadmin character view T");
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(1, rows(data).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonceBoundNavigationGetsANewCorrelatedReadWithoutRenderingOrRepeatSuccess() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var target = actor(f, "LargeTarget");
|
||||
var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repo.load(target.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
String privateDigits = "1234567890".repeat(4_000);
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(privateDigits));
|
||||
repo.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
|
||||
false, null, null), old.revision());
|
||||
f.activate(target);
|
||||
var path = data.resolve("characters").resolve(old.id() + ".yml");
|
||||
var persisted = Files.readAllBytes(path);
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view LargeTarget");
|
||||
reads(f); awaitRows(f, 1);
|
||||
var link = next(fixture.actor);
|
||||
assertNotNull(link);
|
||||
int before = fixture.actor.books.size();
|
||||
PartyBookNativeTest.click(f, fixture.actor, link);
|
||||
reads(f); awaitRows(f, 2);
|
||||
assertEquals(before + 1, fixture.actor.books.size());
|
||||
var initial = rows(data).getFirst().getAsJsonObject("data");
|
||||
var navigation = rows(data).get(1).getAsJsonObject("data");
|
||||
assertNotEquals(initial.get("action_id"), navigation.get("action_id"));
|
||||
assertEquals("book_navigation", navigation.get("entry_point").getAsString());
|
||||
assertEquals(30, navigation.get("requested_offset").getAsInt());
|
||||
assertEquals("completed", navigation.get("outcome").getAsString());
|
||||
assertEquals(initial.get("target_character_id"), navigation.get("target_character_id"));
|
||||
PartyBookNativeTest.click(f, fixture.actor, link);
|
||||
reads(f); awaitRows(f, 3);
|
||||
assertEquals(before + 1, fixture.actor.books.size());
|
||||
assertEquals("refused", rows(data).getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertFalse(rows(data).getLast().getAsJsonObject("data").has("target_character_id"));
|
||||
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
|
||||
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(fixture.actor.player);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
|
||||
net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages"), connection,
|
||||
net.minecraft.nbt.StringTag.valueOf("\"" + java.util.UUID.randomUUID() + ":1\"")));
|
||||
awaitRows(f, 4);
|
||||
assertEquals(before + 1, fixture.actor.books.size());
|
||||
assertFalse(rows(data).getLast().getAsJsonObject("data").has("target_player_id"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var central = AdminActionEntryNativeTest.rows(data);
|
||||
for (var row : rows(data)) {
|
||||
var id = row.getAsJsonObject("data").get("action_id");
|
||||
assertEquals(1, central.stream().filter(v -> v.getAsJsonObject("data").has("outcome")
|
||||
&& !v.getAsJsonObject("data").get("outcome").getAsString().equals("pending")
|
||||
&& v.getAsJsonObject("data").get("action_id").equals(id)).count());
|
||||
}
|
||||
assertEquals(2, rows(data).stream().filter(v -> v.getAsJsonObject("data").get("outcome")
|
||||
.getAsString().equals("completed")).count());
|
||||
assertFalse(rows(data).toString().contains(privateDigits.substring(0, 100)));
|
||||
assertArrayEquals(persisted, Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"permission", "admin_disconnect", "admin_replacement", "target_disconnect", "target_exit", "replacement", "shutdown", "superseded"})
|
||||
void pendingReadsResolveOnceWithoutProtectedStaleIdentity(String race) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var target = actor(f, "RaceTarget"); f.activate(target);
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view RaceTarget");
|
||||
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
switch (race) {
|
||||
case "permission" -> fixture.actor.allowed = false;
|
||||
case "admin_disconnect" -> fixture.actor.core.online = false;
|
||||
case "admin_replacement" -> { var replacement = actor(f, "ReconnectedAdmin"); f.players.put(fixture.actor.core.id, replacement.player); }
|
||||
case "target_disconnect" -> target.core.online = false;
|
||||
case "target_exit" -> sessions(f).exit(target.player);
|
||||
case "replacement" -> { var replacement = actor(f, "Replacement"); f.players.put(target.core.id, replacement.player); }
|
||||
case "shutdown" -> f.plugin.setEnabled(false);
|
||||
case "superseded" -> entry.map.dispatch(entry.sender, "fstickadmin character view RaceTarget");
|
||||
default -> fail(race);
|
||||
}
|
||||
reads(f);
|
||||
awaitRows(f, race.equals("superseded") ? 2 : 1);
|
||||
var rows = rows(data);
|
||||
assertEquals(race.equals("superseded") ? 2 : 1, rows.size());
|
||||
assertEquals(race.equals("superseded") ? 1 : 0, fixture.actor.books.size());
|
||||
for (var row : rows) {
|
||||
var v = row.getAsJsonObject("data");
|
||||
if (v.get("outcome").getAsString().equals("completed")) continue;
|
||||
assertFalse(v.has("target_character_id") || v.has("target_session_id") || v.has("target_revision"));
|
||||
if (race.equals("permission")) {
|
||||
assertEquals("denied", v.get("authorization").getAsString());
|
||||
assertFalse(v.has("target_player_id"));
|
||||
}
|
||||
}
|
||||
assertEquals(rows.size(), rows.stream().map(v -> v.getAsJsonObject("data").get("action_id")).distinct().count());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"denied", "console", "command_block", "offline", "inactive", "malformed", "dead", "missing", "failed", "open_failed"})
|
||||
void refusalsAndFailedReadsAreSafeAndValidRetriesRemainIndependent(String failure) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture,
|
||||
failure.equals("console") || failure.equals("command_block") ? failure : "player", !failure.equals("denied"));
|
||||
var target = actor(f, "ProtectedTarget");
|
||||
if (!failure.equals("inactive")) f.activate(target);
|
||||
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
|
||||
var valid = Files.readAllBytes(path);
|
||||
switch (failure) {
|
||||
case "offline" -> target.core.online = false;
|
||||
case "dead" -> {
|
||||
var old = storage(f).find(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(
|
||||
new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
|
||||
true, java.util.UUID.randomUUID(), java.time.Instant.now()), old.revision());
|
||||
}
|
||||
case "missing" -> Files.delete(path);
|
||||
case "failed" -> Files.writeString(path, "schema-version: malformed\n");
|
||||
case "open_failed" -> org.mockito.Mockito.doThrow(new IllegalStateException("controlled native open failure"))
|
||||
.when(fixture.actor.player).openBook(org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class));
|
||||
default -> { }
|
||||
}
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view ProtectedTarget" + (failure.equals("malformed") ? " SECRET=opaque" : ""));
|
||||
reads(f); awaitRows(f, 1);
|
||||
var v = rows(data).getFirst().getAsJsonObject("data");
|
||||
assertEquals(failure.equals("failed") || failure.equals("open_failed") ? "failed" : "refused", v.get("outcome").getAsString());
|
||||
assertFalse(v.has("target_character_id") || v.has("target_revision") || v.has("pages") || v.has("record"));
|
||||
assertFalse(rows(data).toString().contains("opaque"));
|
||||
assertFalse(rows(data).toString().contains(target.core.record.name()));
|
||||
assertTrue(fixture.actor.books.isEmpty());
|
||||
if (failure.equals("denied")) assertFalse(v.has("target_player_id"));
|
||||
if (failure.equals("failed")) {
|
||||
Files.write(path, valid);
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view ProtectedTarget");
|
||||
reads(f); awaitRows(f, 2);
|
||||
assertEquals("completed", rows(data).getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertNotEquals(v.get("action_id"), rows(data).getLast().getAsJsonObject("data").get("action_id"));
|
||||
assertArrayEquals(valid, Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void selfAndDuplicateNamedCharactersKeepActingAndInspectedIdentitiesSeparate() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
fixture.actor.accountName = "Admin";
|
||||
var other = actor(f, "Other");
|
||||
for (var actor : List.of(fixture.actor, other)) {
|
||||
var renamed = storage(f).renameCharacter(actor.core.record.id(), "Same Fantasy Name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
|
||||
f.activate(actor);
|
||||
}
|
||||
for (String name : List.of("Admin", "Other")) {
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view " + name); reads(f);
|
||||
}
|
||||
awaitRows(f, 2);
|
||||
var actorSession = sessions(f).active(fixture.actor.core.id).orElseThrow();
|
||||
for (var row : rows(data)) {
|
||||
assertEquals(actorSession.character().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(fixture.actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
}
|
||||
assertEquals(actorSession.character().toString(), rows(data).getFirst().getAsJsonObject("data").get("target_character_id").getAsString());
|
||||
assertEquals(other.core.record.id().toString(), rows(data).getLast().getAsJsonObject("data").get("target_character_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"permission", "target_exit", "administrator_quit"})
|
||||
void acceptedNavigationRevalidatesTheCapturedReaderAndTargetBeforeOpening(String race) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var target = actor(f, "LargeTarget");
|
||||
var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var old = repo.load(target.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger("1234567890".repeat(4_000)));
|
||||
repo.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
|
||||
false, null, null), old.revision());
|
||||
f.activate(target);
|
||||
entry.map.dispatch(entry.sender, "fstickadmin character view LargeTarget");
|
||||
reads(f); awaitRows(f, 1);
|
||||
PartyBookNativeTest.click(f, fixture.actor, java.util.Objects.requireNonNull(next(fixture.actor)));
|
||||
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
if (race.equals("permission")) fixture.actor.allowed = false;
|
||||
else if (race.equals("target_exit")) sessions(f).exit(target.player);
|
||||
else {
|
||||
fixture.actor.core.online = false;
|
||||
// This reader never fished; supply only the native quit cleanup's absent-hook boundary.
|
||||
org.mockito.Mockito.doReturn(null).when(fixture.actor.player).getFishHook();
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(fixture.actor.player,
|
||||
net.kyori.adventure.text.Component.empty()));
|
||||
}
|
||||
reads(f); awaitRows(f, 2);
|
||||
assertEquals(1, fixture.actor.books.size());
|
||||
assertEquals(2, rows(data).size());
|
||||
var result = rows(data).getLast().getAsJsonObject("data");
|
||||
assertEquals("book_navigation", result.get("entry_point").getAsString());
|
||||
assertNotEquals("completed", result.get("outcome").getAsString());
|
||||
assertFalse(result.has("target_character_id") || result.has("target_session_id") || result.has("target_revision"));
|
||||
}
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.accountName = name;
|
||||
f.players.put(actor.core.id, actor.player); return actor;
|
||||
}
|
||||
private static void reads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
if (f.plugin.isEnabled()) storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
|
||||
}
|
||||
private static net.minecraft.network.chat.ClickEvent.Custom next(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
|
||||
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
for (var page : content.pages()) { var link = PartyBookNativeTest.find(page.raw(), "[Next pages]"); if (link != null) return link; }
|
||||
return null;
|
||||
}
|
||||
|
||||
static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
}
|
||||
static List<JsonObject> rows(Path data) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
|
||||
.equals("games.dmg.minecraft.fantasystick.character.active_inspection_resolved")).toList();
|
||||
}
|
||||
void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, int count) throws Exception {
|
||||
ReskillEventsNativeTest.await(f, () -> { try { return rows(data).size() >= count; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.party.PartyRegistrarDirectory;
|
||||
import games.dmg.fantasystick.shop.ArmorMarket;
|
||||
import games.dmg.fantasystick.shop.FantasyArmorerDirectory;
|
||||
import games.dmg.fantasystick.shop.RareMarket;
|
||||
import games.dmg.fantasystick.shop.RareMerchantDirectory;
|
||||
import games.dmg.fantasystick.training.TrainingDummyDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Real registered authoring routes: inspect durable authority and owner JSONL before central audit. */
|
||||
class AdminActionAuthoringNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
static Stream<String> registrations() {
|
||||
return Stream.of("adventure-site", "quest-giver", "shop", "rare-shop", "training-dummy", "party-registrar");
|
||||
}
|
||||
|
||||
@ParameterizedTest @MethodSource("registrations")
|
||||
void registeredPlayerPlacementAndBlockRemovalFollowSavedOwnerOperationNotCommandFeedback(String route) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
fixture.base.nativeSpawnSetup = entity -> {
|
||||
if (entity instanceof org.bukkit.entity.Zombie zombie) {
|
||||
when(zombie.getCollidableExemptions()).thenReturn(new java.util.HashSet<>());
|
||||
when(zombie.getActivePotionEffects()).thenReturn(java.util.Set.of());
|
||||
}
|
||||
};
|
||||
var player = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
int placementStart = actions().size();
|
||||
var name = "https://secret.invalid/t"; // A valid free-form name, intentionally NOT audit data.
|
||||
String command = route.equals("adventure-site") ? "adventure-site place camp " + name : route + " place " + name;
|
||||
assertTrue(player.map.dispatch(player.sender, "fantasystick:fstickadmin " + command));
|
||||
ReskillEventsNativeTest.await(f, () -> savedId(fixture, route) != null);
|
||||
UUID id = savedId(fixture, route);
|
||||
assertNotNull(id, "authoritative saved registration, not a feedback string");
|
||||
assertEquals(id, persistedId(route), "registration must exist on disk before audit success");
|
||||
String placedType = placedType(route);
|
||||
awaitDomain(f, placedType, 1);
|
||||
assertEquals(id.toString(), value(domain(placedType).getFirst(), subjectKey(route)));
|
||||
assertEquals(1, domain(placedType).size(), "the owning bridge remains the sole domain publisher");
|
||||
awaitTerminal(f, placementStart);
|
||||
settle(f);
|
||||
var placement = actions();
|
||||
assertTerminal(placement, "player", route, "completed", domainOperation(domain(placedType).getFirst(), route));
|
||||
assertEquals(id.toString(), value(placement.getLast(), "subject_id"), "subject must be the persisted UUID");
|
||||
assertOpaque(placement);
|
||||
|
||||
// A real block sender may remove a saved registration without becoming a fake player.
|
||||
var block = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
|
||||
int removalStart = actions().size();
|
||||
assertTrue(block.map.dispatch(block.sender, "fstickadmin " + route + " remove " + id));
|
||||
ReskillEventsNativeTest.await(f, () -> savedId(fixture, route) == null);
|
||||
assertNull(persistedId(route), "removal must be saved, not merely reported");
|
||||
awaitDomain(f, removedType(route), 1);
|
||||
assertEquals(id.toString(), value(domain(removedType(route)).getFirst(), subjectKey(route)));
|
||||
assertEquals(1, domain(removedType(route)).size());
|
||||
awaitTerminal(f, removalStart);
|
||||
settle(f);
|
||||
var removal = actions().subList(placement.size(), actions().size());
|
||||
assertTerminal(removal, "command_block", route, "completed", domainOperation(domain(removedType(route)).getFirst(), route));
|
||||
assertOpaque(removal);
|
||||
// A second removal is not a second domain mutation, even if a command returns true.
|
||||
int before = actions().size();
|
||||
assertTrue(block.map.dispatch(block.sender, "fstickadmin " + route + " remove " + id));
|
||||
awaitTerminal(f, before);
|
||||
settle(f);
|
||||
assertNull(savedId(fixture, route));
|
||||
assertNull(persistedId(route));
|
||||
assertEquals(1, domain(removedType(route)).size());
|
||||
assertNonSuccess(actions().subList(before, actions().size()), "command_block", route);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @MethodSource("registrations")
|
||||
void consoleAndBlockCannotPlacePlayerOnlyRegistrations(String route) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
for (var kind : List.of("console", "command_block")) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
int before = actions().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + route + " place https://secret.invalid/token"));
|
||||
settle(f);
|
||||
assertNull(savedId(fixture, route), "sender-limited placement must not create domain state");
|
||||
assertNull(persistedId(route));
|
||||
assertTrue(domain(placedType(route)).isEmpty());
|
||||
assertNonSuccess(actions().subList(before, actions().size()), kind, route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Stream<Arguments> restocks() {
|
||||
return Stream.of("shop", "rare-shop").flatMap(route -> Stream.of("player", "console", "command_block")
|
||||
.map(kind -> Arguments.of(route, kind)));
|
||||
}
|
||||
|
||||
@ParameterizedTest @MethodSource("restocks")
|
||||
void actualRegisteredRestockChangesPersistedCatalogAndLinksExactlyOneOwnerRefresh(String route, String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
UUID before = catalogRevision(f, route);
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin " + route + " restock"));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
UUID now = catalogRevision(f, route);
|
||||
return now != null && !now.equals(before);
|
||||
});
|
||||
UUID revision = catalogRevision(f, route);
|
||||
UUID persisted = route.equals("shop")
|
||||
? new games.dmg.fantasystick.persistence.YamlArmorCatalogRepository(data).load().orElseThrow().revision()
|
||||
: new games.dmg.fantasystick.persistence.YamlRareCatalogRepository(data).load().orElseThrow().revision();
|
||||
assertEquals(revision, persisted, "restock must be saved before central completion");
|
||||
String type = route.equals("shop") ? "armor.catalog_refreshed" : "rare_catalog.refreshed";
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return domain(type).stream().anyMatch(row -> revision.toString().equals(value(row, "catalog_revision"))); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
var refresh = domain(type).stream().filter(row -> revision.toString().equals(value(row, "catalog_revision"))).toList();
|
||||
assertEquals(1, refresh.size());
|
||||
assertEquals("administrative", value(refresh.getFirst(), "refresh_reason"));
|
||||
awaitTerminal(f, 0);
|
||||
settle(f);
|
||||
assertTerminal(actions(), kind, route, "completed", revision.toString());
|
||||
assertOpaque(actions());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"shop", "rare-shop"})
|
||||
void heldRealMarketWorkerKeepsRestockPendingUntilDurableAcknowledgment(String route) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var market = route.equals("shop")
|
||||
? (ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket") : null;
|
||||
var rare = route.equals("rare-shop")
|
||||
? (RareMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareMarket") : null;
|
||||
if (market != null) market.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
else rare.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
UUID prior = catalogRevision(f, route);
|
||||
assertNotNull(prior);
|
||||
Object owner = market == null ? rare : market;
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(owner, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + route + " restock"));
|
||||
settle(f);
|
||||
assertEquals(prior, catalogRevision(f, route));
|
||||
assertEquals(List.of("request", "authorization", "result"), actions().stream()
|
||||
.map(row -> value(row, "stage")).toList());
|
||||
assertEquals("pending", value(actions().getLast(), "outcome"));
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
UUID now = catalogRevision(f, route);
|
||||
return now != null && !now.equals(prior);
|
||||
});
|
||||
UUID saved = catalogRevision(f, route);
|
||||
String type = route.equals("shop") ? "armor.catalog_refreshed" : "rare_catalog.refreshed";
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return domain(type).stream().anyMatch(row -> saved.toString().equals(value(row, "catalog_revision"))); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
awaitTerminal(f, 3);
|
||||
settle(f);
|
||||
assertEquals(1, domain(type).stream().filter(row -> saved.toString().equals(value(row, "catalog_revision"))).count());
|
||||
assertTerminal(actions(), "console", route, "completed", saved.toString());
|
||||
assertOpaque(actions());
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingRemovalForActualConsoleAndBlockRoutesDoesNotClaimCompletedMutation() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
for (var route : List.of("adventure-site", "quest-giver", "shop", "rare-shop", "training-dummy", "party-registrar")) {
|
||||
for (var kind : List.of("console", "command_block")) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
int prior = actions().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + route + " remove 12345678-1234-1234-1234-123456789abc"));
|
||||
awaitTerminal(fixture.f, prior);
|
||||
settle(fixture.f);
|
||||
assertNull(savedId(fixture, route));
|
||||
assertNull(persistedId(route));
|
||||
assertTrue(domain(removedType(route)).isEmpty());
|
||||
assertNonSuccess(actions().subList(prior, actions().size()), kind, route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static UUID savedId(QuestTrackingAuditNativeFixture fixture, String route) {
|
||||
var plugin = fixture.f.plugin;
|
||||
return switch (route) {
|
||||
case "adventure-site" -> fixture.directory.snapshot().sites().stream()
|
||||
.filter(site -> site.type() == AdventureQuestState.SiteType.CAMP).map(AdventureQuestState.Site::id).findFirst().orElse(null);
|
||||
case "quest-giver" -> fixture.directory.snapshot().givers().stream().map(AdventureQuestState.Giver::id).findFirst().orElse(null);
|
||||
case "shop" -> ((FantasyArmorerDirectory) FantasyStickLifecycleNativeTest.Fixture.field(plugin, "armorers"))
|
||||
.all().stream().map(shop -> shop.id()).findFirst().orElse(null);
|
||||
case "rare-shop" -> ((RareMerchantDirectory) FantasyStickLifecycleNativeTest.Fixture.field(plugin, "rareMerchants"))
|
||||
.all().stream().map(shop -> shop.id()).findFirst().orElse(null);
|
||||
case "training-dummy" -> ((TrainingDummyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(plugin, "trainingDummies"))
|
||||
.all().stream().map(dummy -> dummy.id()).findFirst().orElse(null);
|
||||
case "party-registrar" -> ((PartyRegistrarDirectory) FantasyStickLifecycleNativeTest.Fixture.field(plugin, "partyRegistrars"))
|
||||
.all().stream().map(registrar -> registrar.id()).findFirst().orElse(null);
|
||||
default -> throw new AssertionError(route);
|
||||
};
|
||||
}
|
||||
|
||||
private UUID persistedId(String route) throws Exception {
|
||||
return switch (route) {
|
||||
case "adventure-site" -> new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().sites()
|
||||
.stream().filter(site -> site.type() == AdventureQuestState.SiteType.CAMP)
|
||||
.map(AdventureQuestState.Site::id).findFirst().orElse(null);
|
||||
case "quest-giver" -> new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(data).load().givers()
|
||||
.stream().map(AdventureQuestState.Giver::id).findFirst().orElse(null);
|
||||
case "shop" -> new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(data).load()
|
||||
.stream().map(shop -> shop.id()).findFirst().orElse(null);
|
||||
case "rare-shop" -> new games.dmg.fantasystick.persistence.YamlRareMerchantRepository(data).load()
|
||||
.stream().map(shop -> shop.id()).findFirst().orElse(null);
|
||||
case "training-dummy" -> new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(data).load()
|
||||
.stream().map(dummy -> dummy.id()).findFirst().orElse(null);
|
||||
case "party-registrar" -> new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(data).load()
|
||||
.stream().map(registrar -> registrar.id()).findFirst().orElse(null);
|
||||
default -> throw new AssertionError(route);
|
||||
};
|
||||
}
|
||||
|
||||
private static UUID catalogRevision(FantasyStickLifecycleNativeTest.Fixture f, String route) {
|
||||
return route.equals("shop")
|
||||
? ((ArmorMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "armorMarket")).snapshot().map(c -> c.revision()).orElse(null)
|
||||
: ((RareMarket) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareMarket")).snapshot().map(c -> c.revision()).orElse(null);
|
||||
}
|
||||
|
||||
private static String placedType(String route) {
|
||||
return switch (route) {
|
||||
case "adventure-site" -> "adventure.site.registered";
|
||||
case "quest-giver" -> "adventure.quest_giver.placed";
|
||||
case "shop" -> "armorer.placed";
|
||||
case "rare-shop" -> "rare_shop.placed";
|
||||
case "training-dummy" -> "training_dummy.placed";
|
||||
case "party-registrar" -> "party_registrar.placed";
|
||||
default -> throw new AssertionError(route);
|
||||
};
|
||||
}
|
||||
private static String removedType(String route) {
|
||||
return switch (route) {
|
||||
case "adventure-site" -> "adventure.site.removed";
|
||||
case "quest-giver" -> "adventure.quest_giver.removed";
|
||||
case "shop" -> "armorer.removed";
|
||||
case "rare-shop" -> "rare_shop.removed";
|
||||
case "training-dummy" -> "training_dummy.removed";
|
||||
case "party-registrar" -> "party_registrar.removed";
|
||||
default -> throw new AssertionError(route);
|
||||
};
|
||||
}
|
||||
private static String subjectKey(String route) {
|
||||
return switch (route) {
|
||||
case "adventure-site" -> "site_id";
|
||||
case "quest-giver" -> "giver_id";
|
||||
case "shop" -> "armorer_id";
|
||||
case "rare-shop" -> "shop_id";
|
||||
case "training-dummy" -> "dummy_id";
|
||||
case "party-registrar" -> "registrar_id";
|
||||
default -> throw new AssertionError(route);
|
||||
};
|
||||
}
|
||||
private static String domainOperation(JsonObject owner, String route) {
|
||||
// Rare-shop events carry their operation as the envelope action ID, not a data.operation_id.
|
||||
return route.equals("rare-shop") ? value(owner, "action_id") : value(owner, "operation_id");
|
||||
}
|
||||
private List<JsonObject> actions() throws Exception { return AdminActionEntryNativeTest.rows(data); }
|
||||
private List<JsonObject> domain(String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
|
||||
}
|
||||
private void awaitDomain(FantasyStickLifecycleNativeTest.Fixture f, String type, int count) throws Exception {
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return domain(type).size() >= count; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
private static String value(JsonObject row, String key) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
return values.has(key) ? values.get(key).getAsString() : null;
|
||||
}
|
||||
private void awaitTerminal(FantasyStickLifecycleNativeTest.Fixture f, int previousRows) throws Exception {
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try {
|
||||
var rows = actions();
|
||||
return rows.size() > previousRows && "result".equals(value(rows.getLast(), "stage"))
|
||||
&& !"pending".equals(value(rows.getLast(), "outcome"));
|
||||
} catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
}
|
||||
private static void assertTerminal(List<JsonObject> rows, String kind, String route, String outcome, String operation) {
|
||||
assertTrue(rows.size() >= 4, "an async owner needs request, authorization, pending and terminal evidence");
|
||||
assertEquals("request", value(rows.getFirst(), "stage"));
|
||||
assertEquals("authorization", value(rows.get(1), "stage"));
|
||||
assertEquals("authorized", value(rows.get(1), "authorization"));
|
||||
assertEquals("pending", value(rows.get(2), "outcome"));
|
||||
var terminal = rows.getLast();
|
||||
assertEquals("result", value(terminal, "stage"));
|
||||
assertEquals(outcome, value(terminal, "outcome"));
|
||||
assertNotNull(operation, "domain operation is mandatory for a successful mutation");
|
||||
assertEquals(operation, value(terminal, "domain_operation_id"));
|
||||
assertEquals(1, rows.stream().map(row -> value(row, "action_id")).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
for (var row : rows) {
|
||||
assertEquals(route, value(row, "route"));
|
||||
assertEquals(kind, value(row, "actor_kind"));
|
||||
assertEquals("command", value(row, "entry_point"));
|
||||
assertEquals(kind.equals("player"), row.has("playerid"));
|
||||
if (!kind.equals("player")) assertFalse(row.getAsJsonObject("data").has("player_id"));
|
||||
if (kind.equals("command_block")) assertTrue(row.getAsJsonObject("data").has("location"));
|
||||
}
|
||||
}
|
||||
private static void assertNonSuccess(List<JsonObject> rows, String kind, String route) {
|
||||
assertTrue(rows.size() >= 3, "native refusal still needs request, authorization and terminal resolution");
|
||||
assertEquals("request", value(rows.getFirst(), "stage"));
|
||||
assertEquals("authorization", value(rows.get(1), "stage"));
|
||||
assertEquals("authorized", value(rows.get(1), "authorization"));
|
||||
assertEquals("result", value(rows.getLast(), "stage"));
|
||||
assertTrue(List.of("refused", "failed", "uncertain").contains(value(rows.getLast(), "outcome")),
|
||||
"an unchanged domain is neither completed nor permanently pending");
|
||||
assertEquals(1, rows.stream().map(row -> value(row, "action_id")).distinct().count());
|
||||
for (var row : rows) {
|
||||
assertEquals(kind, value(row, "actor_kind"));
|
||||
assertEquals(route, value(row, "route"));
|
||||
assertEquals(kind.equals("player"), row.has("playerid"));
|
||||
if (!kind.equals("player")) assertFalse(row.getAsJsonObject("data").has("player_id"));
|
||||
if (kind.equals("command_block")) assertTrue(row.getAsJsonObject("data").has("location"));
|
||||
}
|
||||
assertOpaque(rows);
|
||||
}
|
||||
private static void assertOpaque(List<JsonObject> rows) {
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
for (var key : List.of("raw_command", "command_line", "config_dump", "item", "pdc", "password", "token", "name"))
|
||||
assertFalse(values.has(key), "central audit must not serialize unbounded/secret-bearing data: " + key);
|
||||
assertFalse(row.toString().contains("secret.invalid"), "free-form URL must remain only in authoritative owner data");
|
||||
assertFalse(row.toString().contains("SECRET_PASSWORD"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.persistence.FrostShardSettings;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.purpurmc.purpur.event.ExecuteCommandEvent;
|
||||
|
||||
/** Command-map authority and the native thread/lifetime boundary of the central admin audit. */
|
||||
class AdminActionBoundaryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@org.junit.jupiter.api.BeforeEach void seed() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "retained: untouched\nfrost-shard-range-blocks: 48\n");
|
||||
}
|
||||
|
||||
private static JsonObject details(JsonObject row) { return row.getAsJsonObject("data"); }
|
||||
private static List<JsonObject> actions(Path folder) throws Exception { return AdminActionEntryNativeTest.rows(folder); }
|
||||
private static AdminActionEventBridge bridge(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (AdminActionEventBridge) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminActionEvents");
|
||||
}
|
||||
|
||||
@Test void upstreamNativeCancellationAndRedirectNeverBecomeCommandSuccessOrUnknownAdminExecution() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var before = fixture.actor.core.snapshot();
|
||||
var config = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var other = entry.map.getCommand("fstick");
|
||||
var gate = new Listener() {
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void intercept(ExecuteCommandEvent event) {
|
||||
if (event.getCommand() != entry.map.getCommand("fstickadmin")) return;
|
||||
if (event.getArgs()[0].equals("inspect")) event.setCancelled(true);
|
||||
else if (event.getArgs()[0].equals("recover")) event.setCommand(other);
|
||||
}
|
||||
};
|
||||
fixture.f.dispatcher.register(gate, fixture.f.plugin);
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin inspect 12345678-1234-1234-1234-123456789abc"));
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin recover 12345678-1234-1234-1234-123456789abc"));
|
||||
assertArrayEquals(before, fixture.actor.core.snapshot());
|
||||
assertArrayEquals(config, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row ->
|
||||
row.get("type").getAsString().endsWith("equipment.recovered")));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = actions(data);
|
||||
assertEquals(3, rows.size(), "only the cancelled original admin object is observed as an admin refusal");
|
||||
AdminActionEntryNativeTest.assertStages(rows, "player", "inspect", "authorized", "refused");
|
||||
assertEquals("native_entry_cancelled_at_observation", details(rows.getLast()).get("reason").getAsString());
|
||||
assertTrue(rows.stream().noneMatch(row -> List.of("completed", "unknown_command", "command_success")
|
||||
.contains(details(row).has("outcome") ? details(row).get("outcome").getAsString() : "")));
|
||||
} finally { org.bukkit.event.HandlerList.unregisterAll(gate); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nestedSameActorNativeDispatchRestoresEnclosingCapturedActionNotTheNestedResult() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var audit = bridge(fixture.f);
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
try (var outer = audit.open(entry.sender, entry.map.getCommand("fstickadmin"), "fstickadmin",
|
||||
new String[]{"config", FrostShardSettings.RANGE})) {
|
||||
assertSame(outer.action(), audit.current());
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + FrostShardSettings.RANGE));
|
||||
assertTrue(entry.messages().stream().anyMatch(message -> message.contains(FrostShardSettings.RANGE + "=")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
assertSame(outer.action(), audit.current(), "native nested executor must restore its caller's scope");
|
||||
audit.result(outer.action(), AdminActionEventBridge.Outcome.COMPLETED, "read_completed", java.util.Map.of());
|
||||
}
|
||||
assertNull(audit.current());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = actions(data);
|
||||
assertEquals(6, rows.size());
|
||||
assertEquals(2, rows.stream().map(row -> details(row).get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(2, rows.stream().filter(row -> details(row).get("outcome") != null
|
||||
&& details(row).get("outcome").getAsString().equals("completed")).count());
|
||||
assertFalse(rows.stream().anyMatch(row -> details(row).has("outcome")
|
||||
&& details(row).get("outcome").getAsString().equals("pending")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void realActingCharacterAndLocationAreSnapshotsAcrossDeferredNativeCallback() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "retained: untouched\nfrost-shard-range-blocks: 48\n");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
fixture.f.activate(fixture.actor);
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var f = fixture.f;
|
||||
var settings = (FrostShardSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("owned write gate timed out"); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var session = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = session.active(fixture.actor.core.id).orElseThrow();
|
||||
var record = session.currentRecord(active).orElseThrow();
|
||||
var before = settings.value(FrostShardSettings.RANGE);
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config frost-shard-range-blocks 49.5"));
|
||||
assertEquals(before, settings.value(FrostShardSettings.RANGE));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var waiting = actions(data);
|
||||
assertEquals("pending", details(waiting.getLast()).get("outcome").getAsString());
|
||||
var request = waiting.getFirst();
|
||||
assertEquals(fixture.actor.core.id.toString(), request.get("playerid").getAsString());
|
||||
assertEquals(record.id().toString(), request.get("characterid").getAsString());
|
||||
assertEquals(active.session().toString(), details(request).get("session_id").getAsString());
|
||||
var captured = details(request).getAsJsonObject("location").deepCopy();
|
||||
fixture.actor.core.location.setX(fixture.actor.core.location.getX() + 50);
|
||||
fixture.actor.accountName = "Renamed after command entry";
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS); worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
assertEquals("49.5", settings.value(FrostShardSettings.RANGE));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var rows = actions(data);
|
||||
assertEquals(4, rows.size());
|
||||
assertEquals(1, rows.stream().map(row -> details(row).get("action_id").getAsString()).distinct().count());
|
||||
for (var row : rows) {
|
||||
assertEquals(record.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(active.session().toString(), details(row).get("session_id").getAsString());
|
||||
assertEquals(captured, details(row).getAsJsonObject("location"));
|
||||
assertFalse(row.toString().contains("Renamed after command entry"));
|
||||
}
|
||||
assertEquals("completed", details(rows.getLast()).get("outcome").getAsString());
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void optionalPlayerLocationUnavailableRetainsRealActorAndReadInsteadOfDroppingAudit() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
fixture.f.activate(fixture.actor);
|
||||
doThrow(new IllegalStateException("optional location unavailable")).when(fixture.actor.player).getLocation();
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + FrostShardSettings.RANGE));
|
||||
assertTrue(entry.messages().stream().anyMatch(message -> message.contains(FrostShardSettings.RANGE + "=")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
} finally {
|
||||
org.mockito.Mockito.doAnswer(call -> fixture.actor.core.location.clone()).when(fixture.actor.player).getLocation();
|
||||
}
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = actions(data);
|
||||
AdminActionEntryNativeTest.assertStages(rows, "player", "config", "authorized", "completed");
|
||||
assertEquals(fixture.actor.core.id.toString(), rows.getFirst().get("playerid").getAsString());
|
||||
assertEquals(fixture.actor.core.record.id().toString(), rows.getFirst().get("characterid").getAsString());
|
||||
assertFalse(details(rows.getFirst()).has("location"));
|
||||
assertEquals("unavailable", details(rows.getFirst()).get("location_status").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableOptionalSessionLookupKeepsPlayerIdentityWithoutInventingCharacter() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var audit = bridge(fixture.f);
|
||||
var sessionsField = AdminActionEventBridge.class.getDeclaredField("sessions");
|
||||
sessionsField.setAccessible(true);
|
||||
var actual = (games.dmg.fantasystick.session.EquipmentSessions) sessionsField.get(audit);
|
||||
var missing = fixture.f.own(org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class,
|
||||
org.mockito.AdditionalAnswers.delegatesTo(actual)));
|
||||
doThrow(new IllegalStateException("optional session lookup unavailable"))
|
||||
.when(missing).active(fixture.actor.core.id);
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
try {
|
||||
sessionsField.set(audit, missing);
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + FrostShardSettings.RANGE));
|
||||
assertTrue(entry.messages().stream().anyMatch(message -> message.contains(FrostShardSettings.RANGE + "=")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
} finally { sessionsField.set(audit, actual); }
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = actions(data);
|
||||
AdminActionEntryNativeTest.assertStages(rows, "player", "config", "authorized", "completed");
|
||||
for (var row : rows) {
|
||||
assertEquals(fixture.actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
assertFalse(row.has("characterid"));
|
||||
assertFalse(details(row).has("session_id"));
|
||||
assertFalse(details(row).has("character_id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void boundedAdminAuditAdmissionCannotChangeRealAuthorizedRead() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var outbox = FantasyStickLifecycleNativeTest.Fixture.field(bridge(fixture.f), "outbox");
|
||||
var field = outbox.getClass().getDeclaredField("HANDOFFS"); field.setAccessible(true);
|
||||
var handoffs = (Semaphore) field.get(null);
|
||||
int reserved = handoffs.availablePermits();
|
||||
assertEquals(1024, reserved, "do not take another owner's admission");
|
||||
assertTrue(handoffs.tryAcquire(reserved));
|
||||
try {
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
var config = Files.readAllBytes(data.resolve("config.yml"));
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + FrostShardSettings.RANGE));
|
||||
assertTrue(entry.messages().stream().anyMatch(message -> message.contains(FrostShardSettings.RANGE + "=")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
assertArrayEquals(config, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertTrue(actions(data).isEmpty(), "rejected audit delivery must not claim success");
|
||||
} finally { handoffs.release(reserved); }
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + FrostShardSettings.RANGE));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
AdminActionEntryNativeTest.assertStages(actions(data), "player", "config", "authorized", "completed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void disableReturnsOnlyAfterAcceptedAdminShutdownAuditIsExportedAndWriterLockIsReleased() throws Exception {
|
||||
// Hold this fixture's central intent worker, never the JSONL writer or shared replay maintenance.
|
||||
java.util.UUID originalBoot;
|
||||
var writerClosedAtReturn = new AtomicBoolean();
|
||||
var lockReleasedAtReturn = new AtomicBoolean();
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var writer = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
((games.dmg.fantasystick.events.LocalEventLog) writer).ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
originalBoot = ((games.dmg.fantasystick.events.LocalEventLog) writer).bootId();
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var audit = bridge(f);
|
||||
var outbox = FantasyStickLifecycleNativeTest.Fixture.field(audit, "outbox");
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(outbox, "worker");
|
||||
var entered = new CountDownLatch(1);
|
||||
var release = new CountDownLatch(1);
|
||||
var disableReturned = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("central intent gate timed out");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); throw new AssertionError(interrupted);
|
||||
}
|
||||
});
|
||||
var observer = java.util.concurrent.Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "owned-admin-disable-observer"));
|
||||
java.util.concurrent.Future<?> observation = null;
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config frost-shard-range-blocks 49.5"));
|
||||
// No scheduler tick acknowledges the deferred write: disable must audit its pending action as uncertain.
|
||||
assertFalse(((AtomicBoolean) FantasyStickLifecycleNativeTest.Fixture.field(outbox, "closed")).get());
|
||||
var owner = Thread.currentThread();
|
||||
observation = observer.submit(() -> {
|
||||
var closed = (AtomicBoolean) FantasyStickLifecycleNativeTest.Fixture.field(outbox, "closed");
|
||||
for (int attempt = 0; attempt < 100; attempt++) {
|
||||
if (disableReturned.getCount() == 0) return;
|
||||
if (closed.get() && owner.getState() == Thread.State.TIMED_WAITING) {
|
||||
release.countDown();
|
||||
return;
|
||||
}
|
||||
try { Thread.sleep(50); }
|
||||
catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); throw new AssertionError(interrupted);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("owner neither returned nor waited for its closed central audit");
|
||||
});
|
||||
f.plugin.setEnabled(false); // Real native owner thread; no Bukkit calls on the observer.
|
||||
// Sample BEFORE signalling the helper: a later asynchronous close cannot pass this contract.
|
||||
writerClosedAtReturn.set((boolean) FantasyStickLifecycleNativeTest.Fixture.field(writer, "closing"));
|
||||
lockReleasedAtReturn.set(!((java.nio.channels.FileLock)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(writer, "writerLock")).isValid());
|
||||
} finally {
|
||||
disableReturned.countDown();
|
||||
release.countDown();
|
||||
try {
|
||||
try { if (observation != null) observation.get(5, TimeUnit.SECONDS); }
|
||||
finally { hold.get(5, TimeUnit.SECONDS); }
|
||||
} finally {
|
||||
observer.shutdownNow();
|
||||
assertTrue(observer.awaitTermination(5, TimeUnit.SECONDS), "owned observer did not exit");
|
||||
}
|
||||
}
|
||||
assertTrue(writerClosedAtReturn.get(), "disable returned before the accepted central audit closed the writer");
|
||||
assertTrue(lockReleasedAtReturn.get(), "disable returned with the old boot's writer lock still held");
|
||||
var rows = actions(data);
|
||||
assertEquals(4, rows.size(), "request, authorization, pending and shutdown uncertainty must be exported");
|
||||
assertEquals(List.of("request", "authorization", "result", "result"),
|
||||
rows.stream().map(row -> details(row).get("stage").getAsString()).toList());
|
||||
assertEquals("uncertain", details(rows.getLast()).get("outcome").getAsString());
|
||||
assertTrue(rows.stream().allMatch(row -> originalBoot.toString().equals(details(row).get("boot_id").getAsString())));
|
||||
assertEquals(1, rows.stream().map(row -> details(row).get("action_id").getAsString()).distinct().count());
|
||||
assertTrue(rows.stream().noneMatch(row -> details(row).has("outcome")
|
||||
&& details(row).get("outcome").getAsString().equals("completed")));
|
||||
}
|
||||
var prior = actions(data);
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
var nextWriter = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "eventLog");
|
||||
assertEquals(games.dmg.fantasystick.events.LocalEventLog.Delivery.EXPORTED,
|
||||
nextWriter.ready().toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(nextWriter, "priorBoot"),
|
||||
"cleanly closed original boot must not be mistaken for an interrupted lifetime");
|
||||
assertEquals(prior, actions(data), "restart may recover audit intents, never replay admin authority");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void shutdownWhileAuthorizedWriteIsQueuedRecordsUncertainBeforeAuditClosesWithoutReplayingAuthority() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "retained: untouched\nfrost-shard-range-blocks: 48\n");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var f = fixture.f;
|
||||
var settings = (FrostShardSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(10, TimeUnit.SECONDS)) throw new AssertionError("owned shutdown gate timed out"); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var before = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config frost-shard-range-blocks 49.5"));
|
||||
assertEquals("48", settings.value(FrostShardSettings.RANGE));
|
||||
assertArrayEquals(before, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals("pending", details(actions(data).getLast()).get("outcome").getAsString());
|
||||
f.plugin.setEnabled(false);
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminActionDrain"))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var rows = actions(data);
|
||||
assertEquals(4, rows.size(), "shutdown must resolve the accepted pending admin request before closing its audit");
|
||||
assertEquals("uncertain", details(rows.getLast()).get("outcome").getAsString());
|
||||
assertEquals(1, rows.stream().map(row -> details(row).get("action_id").getAsString()).distinct().count());
|
||||
assertTrue(rows.stream().noneMatch(row -> details(row).has("outcome")
|
||||
&& details(row).get("outcome").getAsString().equals("completed")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
var persisted = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var durable = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile())
|
||||
.getString(FrostShardSettings.RANGE);
|
||||
var priorActions = actions(data);
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "eventLog"))
|
||||
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertArrayEquals(persisted, Files.readAllBytes(data.resolve("config.yml")),
|
||||
"audit replay must not apply an admin mutation");
|
||||
var restartedSettings = (FrostShardSettings) FantasyStickLifecycleNativeTest.Fixture.field(restarted.plugin, "frostShardSettings");
|
||||
assertEquals(durable, restartedSettings.value(FrostShardSettings.RANGE));
|
||||
assertEquals(priorActions, actions(data), "replay must not invent a second authority outcome");
|
||||
}
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered command-map catalog, not a hand-built surrogate for dispatch or completion. */
|
||||
class AdminActionConfigurationCatalogNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static final List<List<String>> FAMILIES = List.of(
|
||||
List.of("adventure-mode"), List.of(AdminReviveSettings.KEY), List.of(SwitchDelaySettings.KEY),
|
||||
List.of(RoleplaySettings.KEY), PathSpeedSettings.keys(), List.of(PartySettings.KEY), PartyEconomySettings.keys(),
|
||||
GearSettings.keys(), RareSettings.keys(), DownedSettings.keys(), CoinSettings.keys(),
|
||||
List.of(HardMobXpSettings.KEY), AdventureQuestSettings.keys(),
|
||||
List.of(SheetResourcePackSettings.URL_KEY, SheetResourcePackSettings.SHA1_KEY),
|
||||
OutOfCombatRecoverySettings.keys(), List.of(QuickerRegenerationSettings.KEY),
|
||||
List.of(QuickerRegenerationIISettings.KEY), List.of(HealthStealSettings.KEY),
|
||||
FireballSettings.keys(), GreatSwordSettings.keys(), FrostShardSettings.keys(),
|
||||
LongbowSettings.keys(), QuickstepSettings.keys(), SecondWindSettings.keys(),
|
||||
StoneSkinSettings.keys(), ShieldBashSettings.keys(), HexSettings.keys(),
|
||||
InspireSettings.keys(), MendingLightSettings.keys(), PurifySettings.keys(),
|
||||
GuardianBondSettings.keys(), RallyingCrySettings.keys(), FrostNovaSettings.keys(),
|
||||
ChainLightningSettings.keys(), SanctuarySettings.keys(), WarBannerSettings.keys(), MeteorSettings.keys());
|
||||
|
||||
private void seed() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"),
|
||||
"retained: untouched\nfrost-shard-range-blocks: 48\nadventure-mode: true\nroleplay-disabled: false\npath-speed-enabled: true\npath-speed-lease-ticks: 20\n");
|
||||
}
|
||||
|
||||
private static JsonObject values(JsonObject row) { return row.getAsJsonObject("data"); }
|
||||
private static String outcome(JsonObject row) { return values(row).get("outcome").getAsString(); }
|
||||
private List<JsonObject> actions(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
return NativeTestAwait.readAudit(f, () -> AdminActionEntryNativeTest.rows(data));
|
||||
}
|
||||
private List<JsonObject> awaitAction(FantasyStickLifecycleNativeTest.Fixture f, int before) throws Exception {
|
||||
return NativeTestAwait.auditUntil(f, () -> since(AdminActionEntryNativeTest.rows(data), before),
|
||||
rows -> rows.size() >= 3 && "result".equals(values(rows.getLast()).get("stage").getAsString())
|
||||
&& !"pending".equals(outcome(rows.getLast())), "terminal configuration action export");
|
||||
}
|
||||
private List<JsonObject> domain(String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
|
||||
}
|
||||
private static void sequence(List<JsonObject> rows, String actor, String outcome) {
|
||||
assertTrue(rows.size() == 3 || rows.size() == 4, "one entry, optionally pending then resolved");
|
||||
AdminActionEntryNativeTest.assertStages(rows.subList(0, 3), actor, "config", "authorized",
|
||||
rows.size() == 4 ? "pending" : outcome);
|
||||
assertEquals(outcome, outcome(rows.getLast()));
|
||||
assertEquals(1, rows.stream().map(row -> values(row).get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
assertEquals("result", values(rows.getLast()).get("stage").getAsString());
|
||||
}
|
||||
private static List<JsonObject> since(List<JsonObject> rows, int offset) { return rows.subList(offset, rows.size()); }
|
||||
private static String readValue(Path data, String key) {
|
||||
var raw = YamlConfiguration.loadConfiguration(data.resolve("config.yml").toFile()).get(key);
|
||||
return raw == null ? null : raw.toString();
|
||||
}
|
||||
|
||||
@Test void everyActuallySuggestedKeyReadsThroughRegisteredCommandWithoutMutationOrPermanentPending() throws Exception {
|
||||
seed();
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var player = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var suggested = player.map.tabComplete(player.sender, "fstickadmin config ");
|
||||
assertNotNull(suggested);
|
||||
var expected = new LinkedHashSet<String>();
|
||||
FAMILIES.forEach(expected::addAll);
|
||||
assertEquals(expected, new LinkedHashSet<>(suggested), "coverage must track the real active command completion catalog");
|
||||
assertEquals(suggested.size(), new LinkedHashSet<>(suggested).size(), "completion must not repeat keys");
|
||||
var console = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
|
||||
var block = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
|
||||
assertEquals(suggested, console.map.tabComplete(console.sender, "fantasystick:fstickadmin config "));
|
||||
assertEquals(suggested, block.map.tabComplete(block.sender, "fstickadmin config "));
|
||||
assertTrue(actions(f).isEmpty(), "catalog and value completion must never be audited");
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
var configuration = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var actors = List.of(player, console, block);
|
||||
var checks = new java.util.ArrayList<org.junit.jupiter.api.function.Executable>();
|
||||
var allRows = new java.util.ArrayList<JsonObject>();
|
||||
for (int family = 0; family < FAMILIES.size(); family++) {
|
||||
for (var key : FAMILIES.get(family)) {
|
||||
// Every key is exercised; each family also has a non-player sender, without tripling all reads.
|
||||
var entry = actors.get(key.equals(FAMILIES.get(family).getFirst()) ? 1 + family % 2 : 0);
|
||||
var kind = entry == player ? "player" : entry == console ? "console" : "command_block";
|
||||
assertTrue(suggested.contains(key), key);
|
||||
var valueSuggestions = entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ");
|
||||
assertNotNull(valueSuggestions, "real current/default completion for " + key);
|
||||
var current = readValue(data, key);
|
||||
if (current == null && !valueSuggestions.isEmpty()) current = valueSuggestions.getFirst();
|
||||
var before = actions(f).size();
|
||||
var domainBefore = NativeTestAwait.readAudit(f, () -> domain("config.read")).size();
|
||||
var messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config " + key), key);
|
||||
assertTrue(entry.messages().size() > messages, "real owner must respond to " + key);
|
||||
assertTrue(entry.messages().subList(messages, entry.messages().size()).stream()
|
||||
.anyMatch(message -> message.contains(key + "=")), "key-specific read, not generic usage: " + key);
|
||||
var rows = awaitAction(f, before);
|
||||
allRows.addAll(rows);
|
||||
var expectedCurrent = current;
|
||||
var capturedReads = key.equals(FrostShardSettings.RANGE)
|
||||
? NativeTestAwait.auditUntil(f, () -> since(domain("config.read"), domainBefore),
|
||||
reads -> !reads.isEmpty(), "authoritative Frost configuration read")
|
||||
: NativeTestAwait.readAudit(f, () -> since(domain("config.read"), domainBefore));
|
||||
var stableCount = actions(f).size();
|
||||
checks.add(() -> {
|
||||
sequence(rows, kind, "completed");
|
||||
assertEquals("read_completed", values(rows.getLast()).get("reason").getAsString(), key);
|
||||
var args = values(rows.getFirst()).getAsJsonObject("arguments");
|
||||
assertNotNull(args, "authorized read must identify the actual setting: " + key);
|
||||
assertEquals(key, args.get("key").getAsString());
|
||||
assertFalse(args.has("value"), "read has no requested write value");
|
||||
var completed = values(rows.getLast());
|
||||
if (key.equals(FrostShardSettings.RANGE)) {
|
||||
var reads = capturedReads;
|
||||
assertEquals(1, reads.size(), "Frost owner remains the authoritative read");
|
||||
assertEquals(values(reads.getFirst()).get("action_id").getAsString(),
|
||||
completed.get("domain_operation_id").getAsString());
|
||||
}
|
||||
if (expectedCurrent != null && expectedCurrent.matches("-?[0-9]+(?:\\.[0-9]+)?|true|false")) {
|
||||
assertEquals(expectedCurrent, completed.get("after").getAsString(), key);
|
||||
} else {
|
||||
assertFalse(completed.has("after"), "URL/hash/free-form value must not enter central audit: " + key);
|
||||
}
|
||||
if (expectedCurrent != null && !key.equals(SheetResourcePackSettings.URL_KEY)
|
||||
&& !key.equals(SheetResourcePackSettings.SHA1_KEY)) assertTrue(valueSuggestions.contains(expectedCurrent), key);
|
||||
assertEquals(before + rows.size(), stableCount, "completion cannot create an admin action");
|
||||
assertArrayEquals(configuration, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
});
|
||||
}
|
||||
}
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(allRows, actions(f), "no delayed duplicate or extra action after the complete catalog");
|
||||
assertAll("actual completed configuration key reads", checks);
|
||||
}
|
||||
}
|
||||
|
||||
// One valid acknowledged value per existing owner family. No secret URL/hash or fabricated owner mutation.
|
||||
private static final List<String[]> WRITES = List.of(
|
||||
new String[]{"adventure-mode", "false"}, new String[]{AdminReviveSettings.KEY, "61"},
|
||||
new String[]{SwitchDelaySettings.KEY, "901"}, new String[]{RoleplaySettings.KEY, "true"},
|
||||
new String[]{PathSpeedSettings.ENABLED, "false"},
|
||||
new String[]{PartySettings.KEY, "7"}, new String[]{"party-registration-price", "11"},
|
||||
new String[]{"gear-price-leather", "3"}, new String[]{"rare-price-reskill", "2001"},
|
||||
new String[]{"downed-timeout-seconds", "301"}, new String[]{"starting-coins-min", "2"},
|
||||
new String[]{HardMobXpSettings.KEY, "21"}, new String[]{"adventure-site-radius", "17"},
|
||||
new String[]{"out-of-combat-healing-amount", "2"},
|
||||
new String[]{QuickerRegenerationSettings.KEY, "2"},
|
||||
new String[]{QuickerRegenerationIISettings.KEY, "3"},
|
||||
new String[]{HealthStealSettings.KEY, "2"},
|
||||
new String[]{"fireball-base-damage", "7"}, new String[]{"great-sword-base-damage", "8"},
|
||||
new String[]{FrostShardSettings.RANGE, "49.5"}, new String[]{"longbow-base-damage", "6"},
|
||||
new String[]{"quickstep-distance-blocks", "5"}, new String[]{"second-wind-base-healing", "7"},
|
||||
new String[]{"stone-skin-duration-seconds", "5"}, new String[]{"shield-bash-base-damage", "5"},
|
||||
new String[]{"hex-duration-seconds", "5"}, new String[]{"inspire-base-temporary-health", "5"},
|
||||
new String[]{"mending-light-base-healing", "7"}, new String[]{"purify-range-blocks", "13"},
|
||||
new String[]{"guardian-bond-range-blocks", "11"}, new String[]{"rallying-cry-radius-blocks", "9"},
|
||||
new String[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-base-damage", "9"},
|
||||
new String[]{"sanctuary-radius-blocks", "7"}, new String[]{"war-banner-radius-blocks", "9"},
|
||||
new String[]{"meteor-base-damage", "15"});
|
||||
|
||||
@Test void acknowledgedBooleanAndNumericWritesResolveTheirOwnNativeSequenceAndPersist() throws Exception {
|
||||
seed();
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var player = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var console = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
|
||||
var block = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
|
||||
var suggestions = player.map.tabComplete(player.sender, "fstickadmin config ");
|
||||
var actors = List.of(player, console, block);
|
||||
var checks = new java.util.ArrayList<org.junit.jupiter.api.function.Executable>();
|
||||
var allRows = new java.util.ArrayList<JsonObject>();
|
||||
for (int i = 0; i < WRITES.size(); i++) {
|
||||
var pair = WRITES.get(i); var key = pair[0]; var requested = pair[1];
|
||||
assertTrue(suggestions.contains(key), "write must use an exposed active key: " + key);
|
||||
var entry = actors.get(i % actors.size());
|
||||
var kind = i % 3 == 0 ? "player" : i % 3 == 1 ? "console" : "command_block";
|
||||
var values = entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ");
|
||||
var old = readValue(data, key);
|
||||
if (old == null) { assertFalse(values.isEmpty(), key); old = values.getFirst(); }
|
||||
assertNotEquals(old, requested, "write must actually change effective value: " + key);
|
||||
var before = actions(f).size(); var messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin config " + key + " " + requested));
|
||||
awaitWrite(f, entry, key, requested, messages);
|
||||
assertEquals(requested, readValue(data, key), "actual durable save: " + key);
|
||||
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested),
|
||||
"native effective setting must change, not just the file: " + key);
|
||||
assertTrue(entry.messages().size() > messages, "native owner feedback: " + key);
|
||||
var rows = awaitAction(f, before);
|
||||
allRows.addAll(rows);
|
||||
if (key.equals(FrostShardSettings.RANGE))
|
||||
NativeTestAwait.auditUntil(f, () -> domain("config.changed"),
|
||||
changes -> changes.stream().anyMatch(row -> values(row).get("key").getAsString().equals(key)),
|
||||
"authoritative Frost configuration write");
|
||||
var expectedBefore = old;
|
||||
checks.add(() -> {
|
||||
sequence(rows, kind, "completed");
|
||||
var request = values(rows.getFirst()).getAsJsonObject("arguments");
|
||||
assertNotNull(request, key);
|
||||
assertEquals(key, request.get("key").getAsString());
|
||||
assertEquals(requested, request.get("value").getAsString(), key);
|
||||
var result = values(rows.getLast());
|
||||
assertEquals("acknowledged", result.get("reason").getAsString(), key);
|
||||
assertEquals(expectedBefore, result.get("before").getAsString(), key);
|
||||
assertEquals(requested, result.get("after").getAsString(), key);
|
||||
assertEquals("untouched", readValue(data, "retained"));
|
||||
if (key.equals(FrostShardSettings.RANGE)) {
|
||||
var changed = NativeTestAwait.readAudit(f, () -> domain("config.changed")).stream()
|
||||
.filter(row -> values(row).get("key").getAsString().equals(key)).toList();
|
||||
assertEquals(1, changed.size(), "no replacement or duplicate authoritative Frost event");
|
||||
assertEquals(values(changed.getFirst()).get("action_id").getAsString(),
|
||||
result.get("domain_operation_id").getAsString());
|
||||
}
|
||||
});
|
||||
}
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(allRows, actions(f), "no delayed duplicate or extra action after all writes");
|
||||
assertAll("actual acknowledged configuration owner writes", checks);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitWrite(FantasyStickLifecycleNativeTest.Fixture f, AdminActionEntryNativeTest.Entry entry,
|
||||
String key, String requested, int messages) throws Exception {
|
||||
long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5);
|
||||
while (System.nanoTime() < deadline) {
|
||||
Runnable callback;
|
||||
while ((callback = f.tasks.poll()) != null) callback.run();
|
||||
if (requested.equals(readValue(data, key))
|
||||
&& entry.map.tabComplete(entry.sender, "fstickadmin config " + key + " ").contains(requested)
|
||||
&& entry.messages().subList(messages, entry.messages().size()).stream()
|
||||
.anyMatch(message -> message.contains(key + "=" + requested))) break;
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertTrue(entry.messages().subList(messages, entry.messages().size()).stream()
|
||||
.anyMatch(message -> message.contains(key + "=" + requested)), "matching native owner feedback: " + key);
|
||||
}
|
||||
|
||||
@Test void savedAndEffectiveConfigurationMustWaitForItsHeldNativeOwnerFeedback() throws Exception {
|
||||
seed();
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var port = games.dmg.fantasystick.session.EquipmentAdminCommand.class.getDeclaredField("nextTick");
|
||||
var admin = assertInstanceOf(games.dmg.fantasystick.session.EquipmentAdminCommand.class,
|
||||
f.adminCommand.getExecutor());
|
||||
port.setAccessible(true); var original = port.get(admin);
|
||||
var held = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
var queued = new java.util.concurrent.CountDownLatch(1);
|
||||
var returned = new java.util.concurrent.CountDownLatch(1);
|
||||
var premature = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
java.util.concurrent.CompletableFuture<Void> observer = null;
|
||||
int messages = entry.messages().size();
|
||||
try {
|
||||
port.set(admin, (java.util.function.Consumer<Runnable>) task -> {
|
||||
held.add(task); queued.countDown();
|
||||
});
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin config adventure-mode false"));
|
||||
assertTrue(queued.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
assertEquals("false", readValue(data, "adventure-mode"));
|
||||
assertTrue(entry.map.tabComplete(entry.sender, "fstickadmin config adventure-mode ").contains("false"));
|
||||
assertEquals(messages, entry.messages().size(), "real owner feedback is still held");
|
||||
observer = java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
try { premature.set(returned.await(200, java.util.concurrent.TimeUnit.MILLISECONDS)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
finally { Runnable task; while ((task = held.poll()) != null) f.tasks.add(task); }
|
||||
});
|
||||
awaitWrite(f, entry, "adventure-mode", "false", messages);
|
||||
returned.countDown(); observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertFalse(premature.get(), "saved/effective state is not the native feedback acknowledgment");
|
||||
assertTrue(entry.messages().size() > messages);
|
||||
} finally {
|
||||
returned.countDown();
|
||||
if (observer != null) observer.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
Runnable task; while ((task = held.poll()) != null) f.tasks.add(task);
|
||||
while ((task = f.tasks.poll()) != null) task.run();
|
||||
port.set(admin, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownKeyAndMalformedTypedValuesRefuseWithoutSavingOrLeakingFreeformInput() throws Exception {
|
||||
seed();
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
for (var command : List.of("config unregistered-password SECRET_VALUE",
|
||||
"config " + FrostShardSettings.RANGE + " https://secret.invalid/token",
|
||||
"config " + RoleplaySettings.KEY + " SECRET_VALUE")) {
|
||||
var config = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var inventory = fixture.actor.core.snapshot(); var before = actions(f).size();
|
||||
var messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
|
||||
assertTrue(entry.messages().size() > messages, "native validation must run");
|
||||
var rows = awaitAction(f, before);
|
||||
sequence(rows, "player", "refused");
|
||||
assertFalse(rows.toString().contains("SECRET_VALUE"));
|
||||
assertFalse(rows.toString().contains("secret.invalid"));
|
||||
assertFalse(rows.toString().contains("unregistered-password"));
|
||||
assertFalse(rows.toString().contains("token"));
|
||||
assertArrayEquals(config, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Native command map -> owned inventory decision -> durable outcome; an open is never a success. */
|
||||
class AdminActionConfirmationNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void selectedTailHasOneConfirmedActionAndOneLinkedAuthoritativeRestoration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var map = commands(f);
|
||||
var admin = actor(f, data, "Administrator");
|
||||
var target = actor(f, data, "Target");
|
||||
f.activate(target);
|
||||
var dead = AdminRevivalNativeTest.dead(data, Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));
|
||||
target.core.inventory.setItem(10, new TailBooks().create(dead));
|
||||
assertTrue(map.dispatch(admin.player, "fantasystick:fstickadmin revive Target"));
|
||||
var storage = storage(f);
|
||||
storage.find(dead.id()).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
assertEquals(1, admin.inventories.size());
|
||||
settle(f);
|
||||
var initial = actions(data);
|
||||
assertEquals(3, initial.size());
|
||||
assertEquals("request", value(initial.getFirst(), "stage"));
|
||||
assertEquals("pending", value(initial.getLast(), "outcome"), "Opening Tail selection cannot imply restoration");
|
||||
assertTrue(domain(data, "character.revived").isEmpty());
|
||||
assertTrue(storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead());
|
||||
|
||||
var click = click(f, admin, admin.inventories.getFirst(), 0);
|
||||
assertTrue(click.isCancelled());
|
||||
awaitRevival(f, dead.id());
|
||||
var restored = storage.find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertFalse(restored.dead());
|
||||
assertEquals(dead.id(), new CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());
|
||||
awaitRows(f, data, "character.revived", 1);
|
||||
var owner = domain(data, "character.revived");
|
||||
assertEquals(1, owner.size());
|
||||
assertEquals("admin_tail", value(owner.getFirst(), "revival_kind"));
|
||||
assertEquals(dead.deathId().toString(), value(owner.getFirst(), "death_id"));
|
||||
var rows = actions(data);
|
||||
assertConfirmation(rows, initial.getFirst());
|
||||
assertLinkedCompletion(rows, initial.getFirst(), value(owner.getFirst(), "revival_operation_id"));
|
||||
assertEquals(1, rows.stream().filter(row -> "completed".equals(value(row, "outcome"))).count());
|
||||
f.dispatcher.accept(click);
|
||||
settle(f);
|
||||
assertEquals(1, domain(data, "character.revived").size(), "A repeated click cannot repeat the durable restoration");
|
||||
assertEquals(1, actions(data).stream().filter(row -> "completed".equals(value(row, "outcome"))).count());
|
||||
assertNoOpaqueSecrets(rows);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"closed", "invalid-slot", "stale-tail", "permission-revoked"})
|
||||
void tailMenuRefusalsNeverConsumeDeathOrClaimCompletion(String mode) throws Exception {
|
||||
var root = data.resolve(mode);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(root)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var map = commands(f);
|
||||
var admin = actor(f, root, "Administrator");
|
||||
var target = actor(f, root, "Target");
|
||||
f.activate(target);
|
||||
var dead = AdminRevivalNativeTest.dead(root, Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));
|
||||
var tail = new TailBooks().create(dead);
|
||||
target.core.inventory.setItem(10, tail);
|
||||
assertTrue(map.dispatch(admin.player, "fstickadmin revive Target"));
|
||||
storage(f).find(dead.id()).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
var popup = admin.inventories.getFirst();
|
||||
settle(f);
|
||||
var request = actions(root).getFirst();
|
||||
if (mode.equals("closed")) {
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view(f, admin, popup)));
|
||||
// A closed view cannot turn a late click into a confirmation.
|
||||
click(f, admin, popup, 0);
|
||||
} else {
|
||||
if (mode.equals("stale-tail")) target.core.inventory.setItem(10, new org.bukkit.inventory.ItemStack(Material.DIAMOND));
|
||||
if (mode.equals("permission-revoked")) admin.allowed = false;
|
||||
click(f, admin, popup, mode.equals("invalid-slot") ? 8 : 0);
|
||||
}
|
||||
PartyBookNativeTest.drain(f);
|
||||
settle(f);
|
||||
assertTrue(storage(f).find(dead.id()).get(5, TimeUnit.SECONDS).orElseThrow().dead());
|
||||
assertEquals(mode.equals("stale-tail") ? Material.DIAMOND : Material.WRITTEN_BOOK,
|
||||
target.core.inventory.getItem(10).getType());
|
||||
assertTrue(((RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"))
|
||||
.find(dead.id()).get(5, TimeUnit.SECONDS).isEmpty());
|
||||
assertTrue(domain(root, "character.revived").isEmpty());
|
||||
var rows = actions(root);
|
||||
assertEquals("request", value(request, "stage"));
|
||||
assertTrue(rows.stream().noneMatch(row -> "completed".equals(value(row, "outcome"))));
|
||||
if (mode.equals("stale-tail") || mode.equals("permission-revoked")) {
|
||||
assertConfirmation(rows, request);
|
||||
assertTrue(rows.stream().anyMatch(row -> "refused".equals(value(row, "outcome"))
|
||||
|| "failed".equals(value(row, "outcome"))),
|
||||
"A confirmed stale/unauthorized selection needs its own safe resolution");
|
||||
} else {
|
||||
assertTrue(rows.stream().noneMatch(row -> "confirmation".equals(value(row, "stage"))),
|
||||
"Close and inert slots are not a valid confirmation");
|
||||
}
|
||||
assertNoOpaqueSecrets(rows);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void savedCharacterPreviewAndConfirmationDeliverOneGenerationWhileClosedPreviewCannot() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var map = commands(f);
|
||||
var admin = actor(f, data, "Administrator");
|
||||
var recipient = actor(f, data, "Recipient");
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var sourceLease = source.retainNativeMocks();
|
||||
try {
|
||||
var original = storage(f).inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var oldSheet = new CharacterSheets().create(original);
|
||||
int slot = recipient.core.inventory.firstEmpty();
|
||||
String command = "fstickadmin character recover " + original.id() + " Recipient";
|
||||
assertTrue(map.dispatch(admin.player, command));
|
||||
drainRecovery(f, recipient.core.id);
|
||||
var preview = admin.inventories.getLast();
|
||||
assertEquals(Material.PAPER, preview.getItem(2).getType());
|
||||
settle(f);
|
||||
assertEquals(original.sheetGeneration(), storage(f).inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
|
||||
assertNull(recipient.core.inventory.getItem(slot));
|
||||
assertTrue(actions(data).stream().noneMatch(row -> "completed".equals(value(row, "outcome"))));
|
||||
f.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view(f, admin, preview)));
|
||||
assertTrue(map.dispatch(admin.player, command));
|
||||
drainRecovery(f, recipient.core.id);
|
||||
var live = admin.inventories.getLast();
|
||||
assertNotSame(preview, live);
|
||||
var confirmation = click(f, admin, live, 2);
|
||||
assertTrue(confirmation.isCancelled());
|
||||
drainRecovery(f, recipient.core.id);
|
||||
var saved = storage(f).inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(original.sheetGeneration().add(java.math.BigInteger.ONE), saved.sheetGeneration());
|
||||
assertEquals(original.id(), saved.id());
|
||||
assertTrue(new CharacterSheets().authorizes(recipient.core.inventory.getItem(slot), saved));
|
||||
assertFalse(new CharacterSheets().authorizes(oldSheet, saved));
|
||||
assertTrue(map.dispatch(admin.player, command));
|
||||
drainRecovery(f, recipient.core.id);
|
||||
var stale = admin.inventories.getLast();
|
||||
admin.allowed = false;
|
||||
click(f, admin, stale, 2);
|
||||
drainRecovery(f, recipient.core.id);
|
||||
settle(f);
|
||||
assertEquals(saved.sheetGeneration(), storage(f).inspect(original.id()).get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
|
||||
assertTrue(new CharacterSheets().authorizes(recipient.core.inventory.getItem(slot), saved));
|
||||
var rows = actions(data);
|
||||
assertEquals(3, rows.stream().filter(row -> "request".equals(value(row, "stage"))).count());
|
||||
assertTrue(rows.stream().anyMatch(row -> "confirmation".equals(value(row, "stage"))));
|
||||
assertEquals(1, rows.stream().filter(row -> "completed".equals(value(row, "outcome"))).count(),
|
||||
"Only delivered, durably completed recovery may be a success");
|
||||
assertNoOpaqueSecrets(rows);
|
||||
} finally { sourceLease.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitRerollConfirmLinksOriginalRequestToOneAcknowledgedResetNotPreviewOrStaleRetry() throws Exception {
|
||||
try (var fixture = new CampAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
fixture.camp("individual-only"); fixture.giver(); fixture.resetCatalog();
|
||||
var map = commands(f);
|
||||
var actor = fixture.actor;
|
||||
actor.allowed = true;
|
||||
var previous = fixture.directory.snapshot().catalog().revision();
|
||||
settle(f);
|
||||
int priorActions = actions(data).size();
|
||||
int priorResets = domain(data, "adventure.day.reset").size();
|
||||
assertTrue(map.dispatch(actor.player, "fstickadmin adventure reroll"));
|
||||
settle(f);
|
||||
var previewRows = actions(data).subList(priorActions, actions(data).size());
|
||||
var request = previewRows.getFirst();
|
||||
assertEquals("request", value(request, "stage"));
|
||||
assertEquals(previous, fixture.directory.snapshot().catalog().revision());
|
||||
assertEquals(priorResets, domain(data, "adventure.day.reset").size());
|
||||
assertTrue(previewRows.stream().noneMatch(row -> "completed".equals(value(row, "outcome"))));
|
||||
assertTrue(map.dispatch(actor.player, "fantasystick:fstickadmin adventure reroll confirm"));
|
||||
ReskillEventsNativeTest.await(f, () -> !previous.equals(fixture.directory.snapshot().catalog().revision())
|
||||
&& fixture.directory.snapshot().reset() == null);
|
||||
awaitRows(f, data, "adventure.day.reset", priorResets + 1);
|
||||
settle(f);
|
||||
var owner = domain(data, "adventure.day.reset");
|
||||
assertEquals(priorResets + 1, owner.size());
|
||||
var reset = owner.getLast();
|
||||
assertEquals("manual", value(reset, "reason"));
|
||||
var rows = actions(data).subList(priorActions, actions(data).size());
|
||||
assertConfirmation(rows, request);
|
||||
assertLinkedCompletion(rows, request, value(reset, "operation_id"));
|
||||
assertTrue(map.dispatch(actor.player, "fstickadmin adventure reroll"));
|
||||
settle(f);
|
||||
assertEquals(priorResets + 1, domain(data, "adventure.day.reset").size());
|
||||
assertNoOpaqueSecrets(actions(data));
|
||||
}
|
||||
}
|
||||
|
||||
/** Port the inspected Entry manager lookup for fixtures without the QuestTracking wrapper. */
|
||||
private static SimpleCommandMap commands(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
var dispatcherManager = f.server.getPluginManager();
|
||||
var timingManager = f.own(mock(org.bukkit.plugin.PluginManager.class, call -> {
|
||||
if (call.getMethod().getName().equals("getPlugin"))
|
||||
return "fantasystick".equalsIgnoreCase(call.getArgument(0)) ? f.plugin : null;
|
||||
try { return call.getMethod().invoke(dispatcherManager, call.getArguments()); }
|
||||
catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); }
|
||||
}));
|
||||
when(Bukkit.getServer().getPluginManager()).thenReturn(timingManager);
|
||||
f.platform.when(Bukkit::getPluginManager).thenReturn(timingManager);
|
||||
var map = new SimpleCommandMap(f.server, new java.util.HashMap<>());
|
||||
assertTrue(map.register("fantasystick", f.adminCommand));
|
||||
assertTrue(map.register("fantasystick", f.playerCommand));
|
||||
return map;
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, Path root, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
actor.accountName = name;
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
return actor;
|
||||
}
|
||||
|
||||
private static InventoryView view(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Inventory inventory) {
|
||||
var view = f.own(mock(InventoryView.class));
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
when(view.getBottomInventory()).thenReturn(actor.core.inventory);
|
||||
when(view.getPlayer()).thenReturn(actor.player);
|
||||
when(view.getType()).thenReturn(inventory.getType());
|
||||
return view;
|
||||
}
|
||||
|
||||
private static InventoryClickEvent click(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Inventory inventory, int slot) {
|
||||
var event = new InventoryClickEvent(view(f, actor, inventory), InventoryType.SlotType.CONTAINER,
|
||||
slot, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
|
||||
private static void awaitRevival(FantasyStickLifecycleNativeTest.Fixture f, UUID id) throws Exception {
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return !storage(f).find(id).get(5, TimeUnit.SECONDS).orElseThrow().dead(); }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
((RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"))
|
||||
.find(id).get(5, TimeUnit.SECONDS);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private static void drainRecovery(FantasyStickLifecycleNativeTest.Fixture f, UUID recipient) throws Exception {
|
||||
for (int pass = 0; pass < 40; pass++) {
|
||||
storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
f.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
((RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"))
|
||||
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
Runnable callback;
|
||||
boolean ran = false;
|
||||
while ((callback = f.tasks.poll()) != null) { callback.run(); ran = true; }
|
||||
if (!ran) Thread.sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
}
|
||||
|
||||
private static void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, Path root, String type, int count) throws Exception {
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return domain(root, type).size() >= count; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
|
||||
private static List<JsonObject> actions(Path root) throws Exception { return AdminActionEntryNativeTest.rows(root); }
|
||||
private static List<JsonObject> domain(Path root, String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(root).stream().filter(row -> row.get("type").getAsString()
|
||||
.equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
|
||||
}
|
||||
private static String value(JsonObject row, String field) {
|
||||
var data = row.getAsJsonObject("data");
|
||||
return data.has(field) ? data.get(field).getAsString() : null;
|
||||
}
|
||||
private static void assertConfirmation(List<JsonObject> rows, JsonObject request) {
|
||||
var confirmations = rows.stream().filter(row -> "confirmation".equals(value(row, "stage"))).toList();
|
||||
assertEquals(1, confirmations.size(), "Only an intentional selection/explicit confirm is a confirmation action");
|
||||
var confirmation = confirmations.getFirst();
|
||||
assertEquals(value(request, "route"), value(confirmation, "route"));
|
||||
assertEquals("player", value(confirmation, "actor_kind"));
|
||||
assertTrue(value(request, "action_id").equals(value(confirmation, "action_id"))
|
||||
|| value(request, "action_id").equals(value(confirmation, "causation_id")),
|
||||
"A new confirmation action must point back to the original command request");
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count(),
|
||||
"Each audit stage/attempt must have a unique event ID");
|
||||
}
|
||||
private static void assertLinkedCompletion(List<JsonObject> rows, JsonObject request, String operation) {
|
||||
var completed = rows.stream().filter(row -> "completed".equals(value(row, "outcome"))).toList();
|
||||
assertEquals(1, completed.size(), "An opening/pending observation is not a domain success");
|
||||
var result = completed.getFirst();
|
||||
assertEquals("result", value(result, "stage"));
|
||||
assertEquals(operation, value(result, "domain_operation_id"));
|
||||
assertTrue(value(request, "action_id").equals(value(result, "action_id"))
|
||||
|| value(request, "action_id").equals(value(result, "causation_id")),
|
||||
"The authoritative outcome must retain the original request's identity or explicit causation");
|
||||
}
|
||||
private static void assertNoOpaqueSecrets(List<JsonObject> rows) {
|
||||
for (var row : rows) {
|
||||
var data = row.getAsJsonObject("data");
|
||||
assertFalse(data.has("raw_command")); assertFalse(data.has("command_line"));
|
||||
assertFalse(data.has("config_dump")); assertFalse(data.has("item")); assertFalse(data.has("pdc"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.party.CharacterPartyDirectory;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** US-080: real registered sheet-boss authority, native inventory and independent domain audit. */
|
||||
class AdminActionDungeonBossNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static final String BOSS = "games.dmg.minecraft.fantasystick.dungeon.character_boss.";
|
||||
private static JsonObject fields(JsonObject row) { return row.getAsJsonObject("data"); }
|
||||
private List<JsonObject> actions() throws Exception { return AdminActionEntryNativeTest.rows(data); }
|
||||
private List<JsonObject> owner(String transition) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals(BOSS + transition)).toList();
|
||||
}
|
||||
private static ItemStack[] inventory(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
return Arrays.stream(actor.core.inventory.getContents()).map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new);
|
||||
}
|
||||
|
||||
/** Only native command-map dispatch may create the action. Never manufacture a domain row. */
|
||||
private void dispatch(QuestTrackingAuditNativeFixture fixture, AdminActionEntryNativeTest.Entry entry, String command) throws Exception {
|
||||
int messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin dungeon " + command));
|
||||
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > messages);
|
||||
}
|
||||
private void quiescent(QuestTrackingAuditNativeFixture fixture) throws Exception {
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
}
|
||||
private static void stages(List<JsonObject> rows, String outcome) {
|
||||
assertTrue(rows.size() == 3 || rows.size() == 4, "one request and one terminal, optionally a pending result");
|
||||
assertEquals(List.of("request", "authorization"), rows.subList(0, 2).stream()
|
||||
.map(row -> fields(row).get("stage").getAsString()).toList());
|
||||
for (int i = 2; i < rows.size(); i++) assertEquals("result", fields(rows.get(i)).get("stage").getAsString());
|
||||
if (rows.size() == 4) assertEquals("pending", fields(rows.get(2)).get("outcome").getAsString());
|
||||
assertEquals(outcome, fields(rows.getLast()).get("outcome").getAsString());
|
||||
assertEquals(1, rows.stream().map(row -> fields(row).get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
for (var row : rows) {
|
||||
var facts = fields(row);
|
||||
assertEquals("dungeon", facts.get("route").getAsString());
|
||||
assertEquals("player", facts.get("actor_kind").getAsString());
|
||||
assertEquals("authorized", facts.get("authorization").getAsString());
|
||||
assertEquals("command", facts.get("entry_point").getAsString());
|
||||
}
|
||||
}
|
||||
private static void refused(List<JsonObject> rows) {
|
||||
stages(rows, "refused");
|
||||
var terminal = fields(rows.getLast());
|
||||
assertEquals("owner_refused", terminal.get("reason").getAsString());
|
||||
assertFalse(terminal.has("domain_operation_id"));
|
||||
assertFalse(terminal.has("domain_event_id"));
|
||||
}
|
||||
/** Owner operation_id is a separate business request ID; correlate against durable custody/recovery instead. */
|
||||
private static void linked(List<JsonObject> rows, JsonObject domain, String operationField, UUID operation) {
|
||||
stages(rows, "completed");
|
||||
assertEquals(operation.toString(), fields(domain).get(operationField).getAsString());
|
||||
assertEquals(operation.toString(), fields(rows.getLast()).get("domain_operation_id").getAsString());
|
||||
assertEquals(domain.get("id").getAsString(), fields(rows.getLast()).get("domain_event_id").getAsString());
|
||||
assertNotEquals(fields(domain).get("operation_id").getAsString(), operation.toString(),
|
||||
"the domain request ID is not the persisted custody/recovery transition ID");
|
||||
}
|
||||
|
||||
private final class Scene implements AutoCloseable {
|
||||
final QuestTrackingAuditNativeFixture fixture;
|
||||
final AdminActionEntryNativeTest.Entry entry;
|
||||
final FantasyStickLifecycleNativeTest.Fixture f;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final DungeonDirectory directory;
|
||||
final CharacterStorage storage;
|
||||
final CharacterSheets sheets = new CharacterSheets();
|
||||
final CharacterRecord character;
|
||||
final UUID dungeon;
|
||||
final UUID room;
|
||||
Scene() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n");
|
||||
fixture = new QuestTrackingAuditNativeFixture(data);
|
||||
f = fixture.f; actor = fixture.actor;
|
||||
entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
character = storage.find(actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
actor.core.inventory.setItemInOffHand(null);
|
||||
actor.core.inventory.setItemInMainHand(sheets.create(character));
|
||||
dispatch(fixture, entry, "create Audited Sheet Vault");
|
||||
dungeon = directory.snapshot().dungeons().getFirst().id();
|
||||
dispatch(fixture, entry, "edit start " + dungeon);
|
||||
dispatch(fixture, entry, "room create corner 4 4 3 Audited Boss Room");
|
||||
room = directory.find(dungeon).orElseThrow().rooms().getFirst().id();
|
||||
dispatch(fixture, entry, "room boss mob husk Original Guard");
|
||||
assertTrue(directory.custody(character.id()).isEmpty());
|
||||
assertNotNull(directory.room(dungeon, room).orElseThrow().boss());
|
||||
quiescent(fixture);
|
||||
}
|
||||
void assign() throws Exception {
|
||||
int start = actions().size();
|
||||
dispatch(fixture, entry, "room boss assign-sheet 1.125");
|
||||
ReskillEventsNativeTest.await(f, () -> directory.custody(character.id()).isPresent());
|
||||
assertTrue(actor.core.inventory.getItemInMainHand().isEmpty());
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return owner("assigned").size() == 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
quiescent(fixture);
|
||||
var custody = directory.custody(character.id()).orElseThrow();
|
||||
assertEquals(character, custody.character());
|
||||
assertEquals(1, owner("assigned").size());
|
||||
linked(List.copyOf(actions().subList(start, actions().size())), owner("assigned").getFirst(),
|
||||
"custody_operation_id", custody.operation());
|
||||
}
|
||||
void leave() throws Exception {
|
||||
dispatch(fixture, entry, "edit done");
|
||||
quiescent(fixture);
|
||||
actor.core.location.setX(100);
|
||||
}
|
||||
@Override public void close() throws java.io.IOException { fixture.close(); }
|
||||
}
|
||||
|
||||
/** Snapshot source and native custody before a refused request; check native authority BEFORE central audit. */
|
||||
private final class Unchanged {
|
||||
final ItemStack[] items;
|
||||
final CharacterRecord saved;
|
||||
final byte[] characterYaml, dungeonYaml;
|
||||
final Object definition, custody;
|
||||
final int assigned, recovered;
|
||||
Unchanged(Scene scene) throws Exception {
|
||||
items = inventory(scene.actor);
|
||||
saved = scene.storage.find(scene.character.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
characterYaml = Files.readAllBytes(data.resolve("characters").resolve(scene.character.id() + ".yml"));
|
||||
dungeonYaml = Files.readAllBytes(data.resolve("dungeons.yml"));
|
||||
definition = scene.directory.find(scene.dungeon).orElseThrow();
|
||||
custody = scene.directory.custody(scene.character.id());
|
||||
assigned = owner("assigned").size(); recovered = owner("recovered").size();
|
||||
}
|
||||
void verify(Scene scene) throws Exception {
|
||||
assertArrayEquals(items, inventory(scene.actor));
|
||||
assertEquals(saved, scene.storage.find(scene.character.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertArrayEquals(characterYaml, Files.readAllBytes(data.resolve("characters").resolve(scene.character.id() + ".yml")));
|
||||
assertArrayEquals(dungeonYaml, Files.readAllBytes(data.resolve("dungeons.yml")));
|
||||
assertEquals(definition, scene.directory.find(scene.dungeon).orElseThrow());
|
||||
assertEquals(custody, scene.directory.custody(scene.character.id()));
|
||||
assertEquals(assigned, owner("assigned").size());
|
||||
assertEquals(recovered, owner("recovered").size());
|
||||
}
|
||||
}
|
||||
private void refusedAfterNativeProof(Scene scene, Unchanged before, int actionStart) throws Exception {
|
||||
before.verify(scene);
|
||||
quiescent(scene.fixture);
|
||||
refused(List.copyOf(actions().subList(actionStart, actions().size())));
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"not-a-sheet", "held-quantity", "duplicate", "party"})
|
||||
void registeredAssignmentRefusesInauthenticOrNonuniqueOrPartySheetWithoutCustody(String variant) throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
switch (variant) {
|
||||
case "not-a-sheet" -> scene.actor.core.inventory.setItemInMainHand(new ItemStack(Material.PAPER));
|
||||
case "held-quantity" -> scene.actor.core.inventory.getItemInMainHand().setAmount(2);
|
||||
case "duplicate" -> scene.actor.core.inventory.setItem(5, scene.actor.core.inventory.getItemInMainHand().clone());
|
||||
case "party" -> {
|
||||
var parties = (CharacterPartyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(scene.f.plugin, "characterParties");
|
||||
assertTrue(parties.create(scene.character.id(), "Before Custody").get(5, TimeUnit.SECONDS).party().isPresent());
|
||||
}
|
||||
default -> throw new AssertionError(variant);
|
||||
}
|
||||
var before = new Unchanged(scene);
|
||||
int action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, "room boss assign-sheet 1.125");
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void foreignGenerationLoadedAsynchronouslyCannotConsumeObsoletePhysicalSheet() throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
var old = scene.character;
|
||||
var current = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(),
|
||||
old.rewardReceipts(), old.pvpCoinEscrow(), old.gear(), old.sheetGeneration().add(BigInteger.ONE));
|
||||
new YamlCharacterRepository(data).replace(current, old.revision());
|
||||
assertEquals(current, scene.storage.find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertFalse(scene.sheets.authorizes(scene.actor.core.inventory.getItemInMainHand(), current));
|
||||
var before = new Unchanged(scene);
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(scene.storage, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
int action = actions().size(), messages = scene.entry.messages().size();
|
||||
try {
|
||||
assertTrue(scene.entry.map.dispatch(scene.entry.sender,
|
||||
"fantasystick:fstickadmin dungeon room boss assign-sheet 1.125"));
|
||||
quiescent(scene.fixture);
|
||||
var pending = List.copyOf(actions().subList(action, actions().size()));
|
||||
assertEquals("pending", fields(pending.getLast()).get("outcome").getAsString());
|
||||
assertEquals(messages, scene.entry.messages().size());
|
||||
// Do not enqueue a source read behind the worker deliberately held by this test.
|
||||
assertArrayEquals(before.items, inventory(scene.actor));
|
||||
assertArrayEquals(before.characterYaml, Files.readAllBytes(data.resolve("characters").resolve(scene.character.id() + ".yml")));
|
||||
assertArrayEquals(before.dungeonYaml, Files.readAllBytes(data.resolve("dungeons.yml")));
|
||||
assertEquals(before.definition, scene.directory.find(scene.dungeon).orElseThrow());
|
||||
assertEquals(before.custody, scene.directory.custody(scene.character.id()));
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
ReskillEventsNativeTest.await(scene.f, () -> scene.entry.messages().size() > messages);
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"mob-type", "boss-type", "entrance-world", "room-geometry"})
|
||||
void registeredOrdinaryDungeonValidatorsRefuseWithoutChangingDefinitionOrInventory(String variant) throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
String command;
|
||||
if (variant.equals("entrance-world")) {
|
||||
var foreign = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
scene.f.players.put(foreign.core.id, foreign.player);
|
||||
org.mockito.Mockito.when(foreign.world.getUID()).thenReturn(UUID.randomUUID());
|
||||
scene.actor.core.location.setWorld(foreign.world);
|
||||
command = "entrance set";
|
||||
} else command = switch (variant) {
|
||||
case "mob-type" -> "room mob add player";
|
||||
case "room-geometry" -> "room create radius 0 3 Impossible";
|
||||
default -> "room boss mob player";
|
||||
};
|
||||
var before = new Unchanged(scene);
|
||||
int action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, command);
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void recoveryWithoutCustodyRefusesWithoutCreatingDomainSuccess() throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
scene.leave();
|
||||
var before = new Unchanged(scene);
|
||||
int action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, "boss recover-sheet " + scene.dungeon);
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void fullInventoryRefusesRecoveryThenRealEmptySlotCompletesExactlyOnce() throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
scene.assign(); scene.leave();
|
||||
var custody = scene.directory.custody(scene.character.id()).orElseThrow();
|
||||
for (int slot = 0; slot < 36; slot++) scene.actor.core.inventory.setItem(slot, new ItemStack(Material.DIRT));
|
||||
assertEquals(-1, scene.actor.core.inventory.firstEmpty());
|
||||
var before = new Unchanged(scene);
|
||||
int action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, "boss recover-sheet " + scene.dungeon);
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
|
||||
var prepared = scene.directory.prepareRecovery(scene.dungeon, scene.actor.core.id).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(custody.operation(), prepared.operation());
|
||||
assertEquals(prepared, scene.directory.custody(scene.character.id()).orElseThrow());
|
||||
var recoveryOperation = prepared.recovery().operation();
|
||||
scene.actor.core.inventory.setItem(1, null);
|
||||
action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, "boss recover-sheet " + scene.dungeon);
|
||||
ReskillEventsNativeTest.await(scene.f, () -> scene.directory.custody(scene.character.id()).isEmpty());
|
||||
ReskillEventsNativeTest.await(scene.f, () -> {
|
||||
try { return owner("recovered").size() == 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
quiescent(scene.fixture);
|
||||
assertTrue(scene.directory.custody(scene.character.id()).isEmpty());
|
||||
assertNull(scene.directory.room(scene.dungeon, scene.room).orElseThrow().boss());
|
||||
assertFalse(scene.directory.find(scene.dungeon).orElseThrow().enabled());
|
||||
var returned = scene.actor.core.inventory.getItem(1);
|
||||
assertNotNull(returned);
|
||||
assertEquals(1, returned.getAmount());
|
||||
assertTrue(scene.sheets.authorizes(returned, scene.character));
|
||||
assertEquals(1, Arrays.stream(scene.actor.core.inventory.getContents()).filter(item -> item != null &&
|
||||
scene.sheets.identify(item).filter(scene.character.id()::equals).isPresent()).mapToInt(ItemStack::getAmount).sum());
|
||||
for (int slot = 0; slot < 36; slot++) if (slot != 1) assertEquals(new ItemStack(Material.DIRT), scene.actor.core.inventory.getItem(slot));
|
||||
assertEquals(before.saved, scene.storage.find(scene.character.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertArrayEquals(before.characterYaml, Files.readAllBytes(data.resolve("characters").resolve(scene.character.id() + ".yml")));
|
||||
assertEquals(1, owner("assigned").size());
|
||||
assertEquals(1, owner("recovered").size());
|
||||
var recovered = owner("recovered").getFirst();
|
||||
assertEquals(recoveryOperation.toString(), fields(recovered).get("recovery_operation_id").getAsString());
|
||||
assertEquals(custody.operation().toString(), fields(recovered).get("custody_operation_id").getAsString());
|
||||
assertEquals(scene.character.sheetGeneration().toString(), fields(recovered).get("subject_sheet_generation").getAsString());
|
||||
assertEquals("exact_authorized_sheet_delivered", fields(recovered).get("sheet_status").getAsString());
|
||||
linked(List.copyOf(actions().subList(action, actions().size())), recovered, "recovery_operation_id", recoveryOperation);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void twoAuthenticPhysicalCopiesRefuseRecoveryWithoutAlteringCustody() throws Exception {
|
||||
try (var scene = new Scene()) {
|
||||
scene.assign(); scene.leave();
|
||||
scene.actor.core.inventory.setItem(2, scene.sheets.create(scene.character));
|
||||
scene.actor.core.inventory.setItem(3, scene.sheets.create(scene.character));
|
||||
var before = new Unchanged(scene);
|
||||
int action = actions().size();
|
||||
dispatch(scene.fixture, scene.entry, "boss recover-sheet " + scene.dungeon);
|
||||
refusedAfterNativeProof(scene, before, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.dungeon.DungeonController;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import games.dmg.fantasystick.dungeon.DungeonState;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.api.function.Executable;
|
||||
|
||||
/** US-080: registered dungeon command-map audit, not a direct DungeonCommands or fabricated state test. */
|
||||
class AdminActionDungeonNestedNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static final String PREFIX = "games.dmg.minecraft.fantasystick.";
|
||||
private final List<Executable> auditChecks = new ArrayList<>();
|
||||
private void verifyAudits() { assertAll("native dungeon audit checkpoints", auditChecks); }
|
||||
private List<JsonObject> owner(String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals(PREFIX + suffix)).toList();
|
||||
}
|
||||
private static JsonObject fields(JsonObject row) { return row.getAsJsonObject("data"); }
|
||||
|
||||
/** Save-backed commands must resolve the captured request, not merely print success or leave pending. */
|
||||
private JsonObject saved(QuestTrackingAuditNativeFixture fixture, AdminActionEntryNativeTest.Entry entry,
|
||||
String ownerType, String... args) throws Exception {
|
||||
var f = fixture.f;
|
||||
var before = AdminActionEntryNativeTest.rows(data).size();
|
||||
var previous = owner(ownerType).size();
|
||||
int messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin " + String.join(" ", args)));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > messages);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return owner(ownerType).size() == previous + 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var domain = owner(ownerType);
|
||||
assertEquals(previous + 1, domain.size(), "only the real owner publishes the acknowledged operation");
|
||||
var action = new ArrayList<>(AdminActionEntryNativeTest.rows(data).subList(before, AdminActionEntryNativeTest.rows(data).size()));
|
||||
var captured = List.copyOf(action);
|
||||
var ownerRow = domain.getLast().deepCopy();
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(captured, "player", "dungeon", "completed", args[args.length - 1].equals("confirm"));
|
||||
var result = fields(captured.getLast());
|
||||
assertEquals(fields(ownerRow).get("operation_id").getAsString(),
|
||||
result.get("domain_operation_id").getAsString(), "same original domain operation, not a newly invented ID");
|
||||
assertEquals(ownerRow.get("id").getAsString(), result.get("domain_event_id").getAsString());
|
||||
});
|
||||
return fields(captured.getLast());
|
||||
}
|
||||
|
||||
private static void assertResolved(List<JsonObject> rows, String actor, String route, String outcome) {
|
||||
assertResolved(rows, actor, route, outcome, false);
|
||||
}
|
||||
private static void assertResolved(List<JsonObject> rows, String actor, String route, String outcome, boolean confirmed) {
|
||||
var stages = rows.stream().map(row -> fields(row).get("stage").getAsString()).toList();
|
||||
var expected = new ArrayList<>(List.of("request", "authorization"));
|
||||
if (confirmed) expected.add("confirmation");
|
||||
// A fast worker can acknowledge before command return; pending is required only while held.
|
||||
if (rows.size() == expected.size() + 2) expected.add("result");
|
||||
expected.add("result");
|
||||
assertEquals(expected, stages);
|
||||
assertEquals(1, rows.stream().map(row -> fields(row).get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
if (confirmed) {
|
||||
assertTrue(fields(rows.get(2)).get("confirmation").getAsBoolean());
|
||||
assertEquals("command_confirmation", fields(rows.get(2)).get("entry_point").getAsString());
|
||||
}
|
||||
if (rows.size() == (confirmed ? 5 : 4))
|
||||
assertEquals("pending", fields(rows.get(confirmed ? 3 : 2)).get("outcome").getAsString());
|
||||
assertEquals(outcome, fields(rows.getLast()).get("outcome").getAsString());
|
||||
for (var row : rows) {
|
||||
assertEquals(actor, fields(row).get("actor_kind").getAsString());
|
||||
assertEquals(route, fields(row).get("route").getAsString());
|
||||
assertEquals("authorized", fields(row).get("authorization").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualNestedEditorOperationsLinkTheirOwnAcknowledgedYamlAndDomainExportsExactlyOnce() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
var controller = (DungeonController) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeonController");
|
||||
saved(fixture, entry, "dungeon.created", "dungeon", "create", "--mode", "individual-only", "Audited", "Depths");
|
||||
var dungeon = directory.snapshot().dungeons().getFirst();
|
||||
assertEquals("Audited Depths", dungeon.name()); assertFalse(dungeon.enabled());
|
||||
saved(fixture, entry, "dungeon.edit.started", "dungeon", "edit", "start", dungeon.id().toString());
|
||||
assertEquals(dungeon.id(), controller.editing(fixture.actor.core.id).orElseThrow());
|
||||
saved(fixture, entry, "dungeon.room.created", "dungeon", "room", "create", "radius", "2", "3", "Entry");
|
||||
var first = directory.find(dungeon.id()).orElseThrow().rooms().getFirst();
|
||||
fixture.actor.core.location.setX(17.25);
|
||||
saved(fixture, entry, "dungeon.room.created", "dungeon", "room", "create", "radius", "2", "3", "Annex");
|
||||
var second = directory.find(dungeon.id()).orElseThrow().rooms().getLast();
|
||||
assertNotEquals(first.id(), second.id());
|
||||
saved(fixture, entry, "dungeon.mob_slot.added", "dungeon", "room", "mob", "add", "husk", "Annex", "Guard");
|
||||
var slot = directory.room(dungeon.id(), second.id()).orElseThrow().mobs().getFirst();
|
||||
// This native read is deliberately in the editor, not a fake list or an event owner mutation.
|
||||
read(fixture, entry, "dungeon room mob list");
|
||||
saved(fixture, entry, "dungeon.mob_slot.removed", "dungeon", "room", "mob", "remove", slot.id().toString());
|
||||
assertTrue(directory.room(dungeon.id(), second.id()).orElseThrow().mobs().isEmpty());
|
||||
saved(fixture, entry, "dungeon.effect.added", "dungeon", "room", "effect", "add", "players", "speed", "2", "false");
|
||||
var effect = directory.room(dungeon.id(), second.id()).orElseThrow().effects().getFirst();
|
||||
saved(fixture, entry, "dungeon.effect.removed", "dungeon", "room", "effect", "remove", effect.id().toString());
|
||||
assertTrue(directory.room(dungeon.id(), second.id()).orElseThrow().effects().isEmpty());
|
||||
saved(fixture, entry, "dungeon.room.removed", "dungeon", "room", "remove", second.id().toString());
|
||||
assertEquals(List.of(first.id()), directory.find(dungeon.id()).orElseThrow().rooms().stream().map(DungeonState.Room::id).toList());
|
||||
fixture.actor.core.location.setX(12.5);
|
||||
saved(fixture, entry, "dungeon.boss.configured", "dungeon", "room", "boss", "mob", "vindicator", "Keeper");
|
||||
assertNotNull(directory.room(dungeon.id(), first.id()).orElseThrow().boss());
|
||||
// Completion-pool history belongs only to the existing reward owner, not to the definition observer.
|
||||
var rewardBefore = owner("dungeon.reward_changed").size();
|
||||
var auditBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
int messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon reward " + dungeon.id() + " 25 7"));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > messages);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return owner("dungeon.reward_changed").size() == rewardBefore + 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(rewardBefore + 1, owner("dungeon.reward_changed").size());
|
||||
var reward = new ArrayList<>(AdminActionEntryNativeTest.rows(data).subList(auditBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
var capturedReward = List.copyOf(reward);
|
||||
var rewardOwner = owner("dungeon.reward_changed").getLast().deepCopy();
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(capturedReward, "player", "dungeon", "completed");
|
||||
assertEquals(rewardOwner.get("id").getAsString(), fields(capturedReward.getLast()).get("domain_event_id").getAsString());
|
||||
});
|
||||
assertEquals("25", directory.find(dungeon.id()).orElseThrow().completionReward().xp().toString());
|
||||
read(fixture, entry, "dungeon reward " + dungeon.id());
|
||||
var doneBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon edit done"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(1, owner("dungeon.edit.ended").size());
|
||||
var doneRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(doneBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals("completed", fields(doneRows.getLast()).get("outcome").getAsString());
|
||||
assertEquals(3, doneRows.size(), "synchronous native lease release needs one terminal outcome");
|
||||
});
|
||||
saved(fixture, entry, "dungeon.enabled", "dungeon", "enable", dungeon.id().toString());
|
||||
assertTrue(directory.find(dungeon.id()).orElseThrow().enabled());
|
||||
var adventures = fixture.directory;
|
||||
var openBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
int openOwnerBefore = owner("dungeon.offer.opened").size();
|
||||
int openFeedback = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin dungeon open " + dungeon.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > openFeedback);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return owner("dungeon.offer.opened").size() == openOwnerBefore + 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var openedCatalog = adventures.snapshot().catalog();
|
||||
assertTrue(adventures.offerOpen(dungeon.id()), "the registered open must publish a real offer");
|
||||
var openedOwner = owner("dungeon.offer.opened").getLast().deepCopy();
|
||||
var openRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(openBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(openRows, "player", "dungeon", "completed");
|
||||
assertEquals(fields(openedOwner).get("operation_id").getAsString(), fields(openRows.getLast()).get("domain_operation_id").getAsString());
|
||||
assertEquals(openedOwner.get("id").getAsString(), fields(openRows.getLast()).get("domain_event_id").getAsString());
|
||||
assertEquals(openedCatalog.revision().toString(), fields(openedOwner).get("generation_id").getAsString());
|
||||
assertEquals("administrator_open", fields(openedOwner).get("reason").getAsString());
|
||||
});
|
||||
|
||||
var repeatedBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
int repeatedFeedback = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon open " + dungeon.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > repeatedFeedback);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var repeatedRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(repeatedBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(repeatedRows, "player", "dungeon", "refused");
|
||||
assertEquals("no_change", fields(repeatedRows.getLast()).get("reason").getAsString());
|
||||
assertFalse(fields(repeatedRows.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
assertEquals(openOwnerBefore + 1, owner("dungeon.offer.opened").size(), "a repeat open has no new owner transition");
|
||||
assertEquals(openedCatalog, adventures.snapshot().catalog());
|
||||
|
||||
// The real adventure persistence worker can hold offer closure after the definition save.
|
||||
// Audit quiescence alone is not a fence for this controller's full disable future.
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(adventures, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(15, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var disableBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
int disabledOwnerBefore = owner("dungeon.disabled").size();
|
||||
int disableFeedback = entry.messages().size();
|
||||
List<JsonObject> heldRows;
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon disable " + dungeon.id() + " confirm"));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return owner("dungeon.disabled").size() == disabledOwnerBefore + 1; }
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
heldRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(disableBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals(List.of("request", "authorization", "confirmation", "result"),
|
||||
heldRows.stream().map(row -> fields(row).get("stage").getAsString()).toList());
|
||||
assertEquals("pending", fields(heldRows.getLast()).get("outcome").getAsString(),
|
||||
"saved definition is not the completed offer-close/site-removal/cleanup operation");
|
||||
});
|
||||
boolean savedDefinitionDisabled = !directory.find(dungeon.id()).orElseThrow().enabled();
|
||||
boolean stillOffered = adventures.offerOpen(dungeon.id());
|
||||
int heldFeedback = entry.messages().size();
|
||||
auditChecks.add(() -> {
|
||||
assertTrue(savedDefinitionDisabled, "the original definition save has completed");
|
||||
assertTrue(stillOffered, "offer closure is still held behind the worker barrier");
|
||||
assertEquals(disableFeedback, heldFeedback, "full disable future has not resolved");
|
||||
});
|
||||
} finally {
|
||||
release.countDown();
|
||||
hold.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
// The actual catalog closure is visible before main-thread cleanup; do not pump
|
||||
// f.tasks until this intermediate audit is captured. A dynamic dungeon site lookup
|
||||
// becomes empty on definition disable, so it is not a site-removal completion fence.
|
||||
for (int attempt = 0; attempt < 100 && adventures.offerOpen(dungeon.id()); attempt++)
|
||||
Thread.sleep(50);
|
||||
assertFalse(adventures.offerOpen(dungeon.id()), "the original catalog worker must close the offer");
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var beforeCleanup = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(disableBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
int feedbackBeforeCleanup = entry.messages().size();
|
||||
auditChecks.add(() -> {
|
||||
assertEquals("pending", fields(beforeCleanup.getLast()).get("outcome").getAsString(),
|
||||
"offer closure cannot complete the remaining site-removal/cleanup future");
|
||||
assertEquals(disableFeedback, feedbackBeforeCleanup);
|
||||
});
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > disableFeedback);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertFalse(adventures.offerOpen(dungeon.id()));
|
||||
assertTrue(adventures.findSite(dungeon.id()).isEmpty());
|
||||
var disabledOwner = owner("dungeon.disabled").getLast().deepCopy();
|
||||
var disableRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(disableBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(disableRows, "player", "dungeon", "completed", true);
|
||||
assertEquals(fields(disabledOwner).get("operation_id").getAsString(), fields(disableRows.getLast()).get("domain_operation_id").getAsString());
|
||||
assertEquals(disabledOwner.get("id").getAsString(), fields(disableRows.getLast()).get("domain_event_id").getAsString());
|
||||
});
|
||||
|
||||
var closedCatalog = adventures.snapshot().catalog();
|
||||
var closedDefinition = directory.snapshot();
|
||||
var refusedBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
int refusedFeedback = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon open " + dungeon.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() > refusedFeedback);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var refusedRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(refusedBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(refusedRows, "player", "dungeon", "refused");
|
||||
assertEquals("owner_refused", fields(refusedRows.getLast()).get("reason").getAsString());
|
||||
assertFalse(fields(refusedRows.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
// Invalid syntax and pre-executor denial cannot reopen an offer or mint an owner event.
|
||||
var invalidBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon open not-a-uuid"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var invalidRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(invalidBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(invalidRows, "player", "dungeon", "refused");
|
||||
assertEquals("invalid_arguments", fields(invalidRows.getLast()).get("reason").getAsString());
|
||||
assertFalse(fields(invalidRows.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
var missingBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon open " + UUID.randomUUID()));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var missingRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(missingBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertResolved(missingRows, "player", "dungeon", "refused");
|
||||
assertEquals("not_found", fields(missingRows.getLast()).get("reason").getAsString());
|
||||
assertFalse(fields(missingRows.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
var denied = new AdminActionEntryNativeTest.Entry(fixture, "player", false);
|
||||
var deniedBefore = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(denied.map.dispatch(denied.sender, "fantasystick:fstickadmin dungeon open " + dungeon.id()));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var deniedRows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(deniedBefore, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
AdminActionEntryNativeTest.assertStages(deniedRows, "player", "dungeon", "denied", "refused");
|
||||
assertFalse(fields(deniedRows.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
assertEquals(openOwnerBefore + 1, owner("dungeon.offer.opened").size());
|
||||
assertEquals(closedCatalog, adventures.snapshot().catalog(), "refusals must not change the offer");
|
||||
assertEquals(closedDefinition, directory.snapshot(), "refusals must not change the definition");
|
||||
assertFalse(adventures.offerOpen(dungeon.id()));
|
||||
assertTrue(adventures.findSite(dungeon.id()).isEmpty());
|
||||
fixture.actor.allowed = true;
|
||||
saved(fixture, entry, "dungeon.removed", "dungeon", "remove", dungeon.id().toString(), "confirm");
|
||||
assertTrue(directory.find(dungeon.id()).isEmpty());
|
||||
assertEquals(1, owner("dungeon.reward_changed").size());
|
||||
assertTrue(owner("combat.reward_resolved").isEmpty());
|
||||
verifyAudits();
|
||||
}
|
||||
}
|
||||
|
||||
private void read(QuestTrackingAuditNativeFixture fixture, AdminActionEntryNativeTest.Entry entry, String route) throws Exception {
|
||||
var before = AdminActionEntryNativeTest.rows(data).size();
|
||||
int messages = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + route));
|
||||
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > messages);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(before, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals(List.of("request", "authorization", "result"), rows.stream().map(row -> fields(row).get("stage").getAsString()).toList());
|
||||
assertEquals("completed", fields(rows.getLast()).get("outcome").getAsString(), "owner read, not presentation, resolves request");
|
||||
assertEquals(1, rows.stream().map(row -> fields(row).get("action_id").getAsString()).distinct().count());
|
||||
});
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"console", "command_block"})
|
||||
void nonPlayerNativeReadButNoPlayerOnlyEditorAuthority(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin, "dungeons");
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
read(fixture, entry, "dungeon list");
|
||||
var before = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon create ConsoleCanNotPlace"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(before, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals("refused", fields(rows.getLast()).get("outcome").getAsString());
|
||||
assertEquals("sender_unsupported", fields(rows.getLast()).get("reason").getAsString());
|
||||
assertEquals(kind, fields(rows.getFirst()).get("actor_kind").getAsString());
|
||||
if (kind.equals("command_block")) assertTrue(fields(rows.getFirst()).has("location"));
|
||||
});
|
||||
var notFound = UUID.randomUUID();
|
||||
var current = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon reward " + notFound));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var refusal = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(current, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals("refused", fields(refusal.getLast()).get("outcome").getAsString());
|
||||
assertEquals("not_found", fields(refusal.getLast()).get("reason").getAsString());
|
||||
assertFalse(fields(refusal.getLast()).has("domain_operation_id"));
|
||||
});
|
||||
assertTrue(directory.snapshot().dungeons().isEmpty()); assertTrue(owner("dungeon.created").isEmpty());
|
||||
verifyAudits();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedUuidUnknownAndMissingConfirmationCannotManufactureDomainSuccess() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin, "dungeons");
|
||||
var missing = UUID.randomUUID();
|
||||
for (var command : List.of("dungeon edit start not-a-uuid", "dungeon reward " + missing,
|
||||
"dungeon remove " + missing, "dungeon disable " + missing + " confirm",
|
||||
"dungeon room effect add players NOT_AN_EFFECT 2 false")) {
|
||||
var before = AdminActionEntryNativeTest.rows(data).size();
|
||||
int feedback = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
|
||||
// The missing UUID's disable decision is asynchronous; audit quiescence isn't a domain fence.
|
||||
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > feedback);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = List.copyOf(AdminActionEntryNativeTest.rows(data).subList(before, AdminActionEntryNativeTest.rows(data).size()));
|
||||
auditChecks.add(() -> {
|
||||
assertEquals("refused", fields(rows.getLast()).get("outcome").getAsString(), command);
|
||||
assertFalse(fields(rows.getLast()).has("domain_operation_id"));
|
||||
assertFalse(rows.toString().contains("NOT_AN_EFFECT"), "free-form invalid values stay opaque");
|
||||
});
|
||||
}
|
||||
assertTrue(directory.snapshot().dungeons().isEmpty());
|
||||
assertTrue(owner("dungeon.removed").isEmpty()); assertTrue(owner("dungeon.disabled").isEmpty());
|
||||
verifyAudits();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void heldActualDungeonPersistenceAndRevokedFeedbackStillResolveFromSourceAcknowledgment() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(directory, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon create Deferred Vault"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var pending = AdminActionEntryNativeTest.rows(data);
|
||||
assertEquals(3, pending.size());
|
||||
assertEquals("pending", fields(pending.getLast()).get("outcome").getAsString());
|
||||
assertTrue(directory.snapshot().dungeons().isEmpty()); assertTrue(owner("dungeon.created").isEmpty());
|
||||
int messages = entry.messages().size();
|
||||
fixture.actor.allowed = false; // response may be suppressed; captured authorization cannot be retroactively revoked.
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS); worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> directory.snapshot().dungeons().size() == 1);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(messages, entry.messages().size(), "async feedback was revoked, not the original durable save");
|
||||
assertResolved(AdminActionEntryNativeTest.rows(data), "player", "dungeon", "completed");
|
||||
assertEquals(fields(owner("dungeon.created").getFirst()).get("operation_id").getAsString(),
|
||||
fields(AdminActionEntryNativeTest.rows(data).getLast()).get("domain_operation_id").getAsString());
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void twoQueuedModeRequestsDoNotTurnStaleSecondPublicationIntoAnotherSuccess() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
saved(fixture, entry, "dungeon.created", "dungeon", "create", "Revision Vault");
|
||||
var original = directory.snapshot().dungeons().getFirst();
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(directory, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
try {
|
||||
var before = AdminActionEntryNativeTest.rows(data).size();
|
||||
int messages = entry.messages().size();
|
||||
for (int i = 0; i < 2; i++)
|
||||
assertTrue(entry.map.dispatch(entry.sender,
|
||||
"fstickadmin dungeon mode " + original.id() + " individual-only"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertEquals(original, directory.find(original.id()).orElseThrow());
|
||||
assertTrue(owner("dungeon.mode_changed").isEmpty());
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS); worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().size() >= messages + 2);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var after = directory.find(original.id()).orElseThrow();
|
||||
assertNotEquals(original.revision(), after.revision());
|
||||
assertEquals(1, owner("dungeon.mode_changed").size(), "second stale save cannot publish another domain change");
|
||||
var rows = AdminActionEntryNativeTest.rows(data).subList(before, AdminActionEntryNativeTest.rows(data).size());
|
||||
assertEquals(2, rows.stream().filter(row -> fields(row).get("stage").getAsString().equals("request")).count());
|
||||
var terminal = rows.stream().filter(row -> fields(row).get("stage").getAsString().equals("result"))
|
||||
.filter(row -> !fields(row).get("outcome").getAsString().equals("pending")).toList();
|
||||
assertEquals(2, terminal.size(), "both source decisions resolve independently");
|
||||
assertEquals(1, terminal.stream().filter(row -> fields(row).get("outcome").getAsString().equals("completed")).count());
|
||||
assertEquals(1, terminal.stream().filter(row -> fields(row).get("outcome").getAsString().equals("refused")).count());
|
||||
assertEquals(fields(owner("dungeon.mode_changed").getFirst()).get("operation_id").getAsString(),
|
||||
fields(terminal.stream().filter(row -> fields(row).get("outcome").getAsString().equals("completed")).findFirst().orElseThrow())
|
||||
.get("domain_operation_id").getAsString());
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedActualYamlSaveMustNotBeReportedAsSuccessfulAuthoring() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var directory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin, "dungeons");
|
||||
saved(fixture, entry, "dungeon.created", "dungeon", "create", "Original Vault");
|
||||
var before = directory.snapshot().dungeons().getFirst();
|
||||
var file = data.resolve("dungeons.yml");
|
||||
var original = Files.readAllBytes(file);
|
||||
Files.delete(file); Files.createDirectory(file); Files.writeString(file.resolve("held"), "test-only failed source save");
|
||||
try {
|
||||
int messages = entry.messages().size();
|
||||
int audit = AdminActionEntryNativeTest.rows(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin dungeon mode " + before.id() + " party-required"));
|
||||
ReskillEventsNativeTest.await(fixture.f, () -> entry.messages().size() > messages);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = AdminActionEntryNativeTest.rows(data).subList(audit, AdminActionEntryNativeTest.rows(data).size());
|
||||
assertResolved(rows, "player", "dungeon", "failed");
|
||||
assertFalse(fields(rows.getLast()).has("domain_operation_id"));
|
||||
assertEquals(before, directory.find(before.id()).orElseThrow());
|
||||
assertEquals(1, owner("dungeon.created").size());
|
||||
assertTrue(owner("dungeon.mode_changed").isEmpty());
|
||||
} finally { Files.delete(file.resolve("held")); Files.delete(file); Files.write(file, original); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.BlockCommandSender;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.ConsoleCommandSender;
|
||||
import org.bukkit.command.SimpleCommandMap;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Actual pinned command-map/metadata boundary, not direct executor calls or synthetic admin events. */
|
||||
class AdminActionEntryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
static Stream<Arguments> malformedRoutes() {
|
||||
return Stream.of("adventure", "adventure-site", "quest-giver", "character", "dungeon", "shop",
|
||||
"rare-shop", "training-dummy", "party-registrar", "config", "give", "revive", "experience", "inspect", "recover")
|
||||
.flatMap(route -> Stream.of("player", "console", "command_block").map(kind -> Arguments.of(route, kind)));
|
||||
}
|
||||
|
||||
@ParameterizedTest @MethodSource("malformedRoutes")
|
||||
void realRegisteredEveryRouteFamilyRefusesMalformedArgumentsWithCorrelatedTypedAudit(String route, String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var entry = new Entry(fixture, kind, true);
|
||||
var before = fixture.actor.core.snapshot();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin " + route + " __unsupported extra SECRET_PASSWORD=https://secret.invalid/token"));
|
||||
assertFalse(entry.messages().isEmpty(), "actual command must reach its existing refusal path");
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var rows = rows(data);
|
||||
assertEquals(3, rows.size(), "real registered route must emit request, authorization and terminal result");
|
||||
assertStages(rows, kind, route, "authorized", "refused");
|
||||
assertFalse(rows.toString().contains("SECRET_PASSWORD"));
|
||||
assertFalse(rows.toString().contains("secret.invalid"));
|
||||
assertArrayEquals(before, fixture.actor.core.snapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
|
||||
void namespacedMetadataPermissionDenialBeforeExecutorOmitsAllProtectedArguments(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new Entry(fixture, kind, false);
|
||||
assertTrue(entry.map.dispatch(entry.sender,
|
||||
"fantasystick:fstickadmin character show 12345678-1234-1234-1234-123456789abc SECRET_PASSWORD=do-not-export"));
|
||||
assertFalse(entry.messages().isEmpty(), "packaged permission gate must actually deny before executor");
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = rows(data);
|
||||
assertEquals(3, rows.size(), "pre-executor denial still needs correlated request/authorization/refusal");
|
||||
assertStages(rows, kind, "character", "denied", "refused");
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertFalse(values.has("arguments")); assertFalse(values.has("subject"));
|
||||
assertFalse(row.toString().contains("12345678-1234-1234-1234-123456789abc"));
|
||||
assertFalse(row.toString().contains("SECRET_PASSWORD"));
|
||||
assertFalse(row.toString().contains("do-not-export"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unknownArgumentsStayOpaqueAndPersonalCompletionLegacyAndUnrelatedCommandsAreNotAdminActions() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new Entry(fixture, "player", true);
|
||||
for (var root : List.of("fantasystick", "fantasyfeedback", "fantasyparticles")) {
|
||||
assertNull(entry.map.getCommand(root)); assertNull(entry.map.getCommand("fantasystick:" + root));
|
||||
}
|
||||
assertNotNull(entry.map.getCommand("fstickadmin"));
|
||||
assertEquals(entry.map.getCommand("fstickadmin"), entry.map.getCommand("fantasystick:fstickadmin"));
|
||||
entry.map.tabComplete(entry.sender, "fstickadmin config frost-shard-range-blocks ");
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstick feedback"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
assertTrue(rows(data).isEmpty(), "personal reads and tab completion are not administration");
|
||||
assertFalse(entry.map.dispatch(entry.sender, "unrelated-secret-command PASSWORD=opaque"));
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin unknown-secret-route PASSWORD=opaque https://secret.invalid/token"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = rows(data);
|
||||
assertEquals(3, rows.size());
|
||||
assertStages(rows, "player", "unknown", "authorized", "refused");
|
||||
assertFalse(rows.toString().contains("PASSWORD")); assertFalse(rows.toString().contains("opaque"));
|
||||
assertFalse(rows.toString().contains("secret.invalid")); assertFalse(rows.toString().contains("unknown-secret-route"));
|
||||
}
|
||||
}
|
||||
|
||||
static List<JsonObject> rows(Path data) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick.admin.action")).toList();
|
||||
}
|
||||
static void assertStages(List<JsonObject> rows, String kind, String route, String authorization, String outcome) {
|
||||
assertEquals(List.of("request", "authorization", "result"), rows.stream().map(row -> row.getAsJsonObject("data").get("stage").getAsString()).toList());
|
||||
assertEquals(1, rows.stream().map(row -> row.getAsJsonObject("data").get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(3, rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals(kind, values.get("actor_kind").getAsString());
|
||||
assertEquals(route, values.get("route").getAsString());
|
||||
assertEquals(authorization, values.get("authorization").getAsString());
|
||||
assertEquals("command", values.get("entry_point").getAsString());
|
||||
assertEquals(kind.equals("player"), row.has("playerid"));
|
||||
if (!kind.equals("player")) assertFalse(values.has("player_id"));
|
||||
if (kind.equals("command_block")) assertTrue(values.has("location"));
|
||||
}
|
||||
assertEquals(outcome, rows.getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
static final class Entry {
|
||||
final QuestTrackingAuditNativeFixture fixture;
|
||||
final SimpleCommandMap map;
|
||||
final CommandSender sender;
|
||||
final List<String> otherMessages = new ArrayList<>();
|
||||
Entry(QuestTrackingAuditNativeFixture fixture, String kind, boolean allowed) {
|
||||
this.fixture = fixture; var f = fixture.f;
|
||||
var dispatcherManager = f.server.getPluginManager();
|
||||
var timingManager = f.own(org.mockito.Mockito.mock(org.bukkit.plugin.PluginManager.class, call -> {
|
||||
if (call.getMethod().getName().equals("getPlugin"))
|
||||
return "fantasystick".equalsIgnoreCase(call.getArgument(0)) ? f.plugin : null;
|
||||
try { return call.getMethod().invoke(dispatcherManager, call.getArguments()); }
|
||||
catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); }
|
||||
}));
|
||||
// Native command timings use getServer().getPluginManager(), event dispatch uses the static accessor.
|
||||
var nativeServer = Bukkit.getServer();
|
||||
org.mockito.Mockito.when(nativeServer.getPluginManager()).thenReturn(timingManager);
|
||||
f.platform.when(Bukkit::getPluginManager).thenReturn(timingManager);
|
||||
map = new SimpleCommandMap(f.server, new java.util.HashMap<>());
|
||||
assertTrue(map.register("fantasystick", f.adminCommand));
|
||||
assertTrue(map.register("fantasystick", f.playerCommand));
|
||||
fixture.actor.allowed = allowed;
|
||||
if (kind.equals("player")) sender = fixture.actor.player;
|
||||
else {
|
||||
var block = f.own(org.mockito.Mockito.mock(org.bukkit.block.Block.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getLocation" -> fixture.actor.player.getLocation();
|
||||
case "getWorld" -> fixture.actor.world;
|
||||
default -> throw new AssertionError("Unexpected command block call " + call.getMethod().getName());
|
||||
}));
|
||||
var type = kind.equals("command_block") ? BlockCommandSender.class : ConsoleCommandSender.class;
|
||||
sender = FantasyStickLifecycleNativeTest.port(type, (name, args) -> {
|
||||
assertSame(f.main, Thread.currentThread(), "worker accessed native command sender");
|
||||
return switch (name) {
|
||||
case "hasPermission" -> allowed;
|
||||
case "getName" -> kind.equals("command_block") ? "@" : "CONSOLE";
|
||||
case "getBlock" -> block;
|
||||
case "getServer" -> f.server;
|
||||
case "sendMessage" -> { otherMessages.add(String.valueOf(args[args.length - 1])); yield null; }
|
||||
default -> throw new AssertionError("Unexpected native admin sender call " + name);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
List<String> messages() { return sender == fixture.actor.player ? fixture.actor.messages : otherMessages; }
|
||||
}
|
||||
}
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Actual registered roots -> authoritative native identity/equipment decisions -> central audit.
|
||||
* No artificial grant/issue/recovery port or success inferred from command feedback.
|
||||
*/
|
||||
class AdminActionIdentityAndEquipmentNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
|
||||
void giveAllocatesSavesAndInstallsExactlyOneFreshSheetBeforeLinkingIssue(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var recipient = actor(f, data, "Recipient");
|
||||
var storage = storage(f);
|
||||
var old = recipient.core.snapshot();
|
||||
int slot = recipient.core.inventory.firstEmpty();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin give Recipient"));
|
||||
assertNull(recipient.core.inventory.getItem(slot), "queued allocation is not delivery");
|
||||
// The worker must finish its real craft batch; the native callback then installs the saved sheet.
|
||||
storage.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> recipient.core.inventory.getItem(slot) != null);
|
||||
var sheets = new CharacterSheets();
|
||||
var item = recipient.core.inventory.getItem(slot);
|
||||
UUID issued = sheets.identify(item).orElseThrow();
|
||||
assertNotEquals(recipient.core.record.id(), issued);
|
||||
var saved = storage.find(issued).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertTrue(sheets.authorizes(item, saved));
|
||||
assertEquals(BigInteger.ZERO, saved.experience());
|
||||
assertEquals(1, saved.level());
|
||||
assertEquals(recipient.core.record.id(), sheets.identify(recipient.core.inventory.getItemInOffHand()).orElseThrow());
|
||||
assertEquals(1, java.util.stream.IntStream.range(0, 36).mapToObj(recipient.core.inventory::getItem)
|
||||
.filter(stack -> sheets.identify(stack).filter(issued::equals).isPresent()).count());
|
||||
assertFalse(java.util.Arrays.equals(old, recipient.core.snapshot()));
|
||||
awaitDomain(f, data, "character.issued", 1);
|
||||
var issue = domain(data, "character.issued");
|
||||
assertEquals(1, issue.size());
|
||||
assertEquals(issued.toString(), value(issue.getFirst(), "character_id"));
|
||||
assertEquals(recipient.core.id.toString(), value(issue.getFirst(), "target_player_id"));
|
||||
assertEquals(slot, issue.getFirst().getAsJsonObject("data").get("delivered_slot").getAsInt());
|
||||
assertEquals("native_inventory_acknowledged", value(issue.getFirst(), "delivery_outcome"));
|
||||
var rows = actions(data);
|
||||
sequence(rows, kind, "give", "completed");
|
||||
linked(rows, value(issue.getFirst(), "issue_operation_id"));
|
||||
assertEquals(issued.toString(), value(rows.getLast(), "subject_id"));
|
||||
assertEquals(recipient.core.id.toString(), value(rows.getLast(), "target_id"));
|
||||
privateFieldsOnly(rows, "Recipient");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
|
||||
void exactActiveCharacterExperienceGrantLinksSavedRevisionNotFreeformName(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var target = actor(f, data, "XPHolder");
|
||||
var storage = storage(f);
|
||||
storage.renameCharacter(target.core.record.id(), "Private 雪").get(5, TimeUnit.SECONDS);
|
||||
f.activate(target);
|
||||
var active = sessions(f).active(target.core.id).orElseThrow();
|
||||
var before = storage.find(active.character()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals("Private 雪", before.name());
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin experience give Private 雪 250"));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return storage.find(active.character()).get(5, TimeUnit.SECONDS).orElseThrow().experience()
|
||||
.equals(before.experience().add(BigInteger.valueOf(250))); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
var after = storage.find(active.character()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(before.experience().add(BigInteger.valueOf(250)), after.experience());
|
||||
assertNotEquals(before.revision(), after.revision());
|
||||
assertEquals(active.character(), after.id());
|
||||
awaitDomain(f, data, "experience.granted", 1);
|
||||
var grants = domain(data, "experience.granted");
|
||||
assertEquals(1, grants.size());
|
||||
var grant = grants.getFirst();
|
||||
assertEquals(active.session().toString(), value(grant, "target_session_id"));
|
||||
assertEquals(after.id().toString(), value(grant, "target_character_id"));
|
||||
assertEquals(after.experience().toString(), value(grant, "after_xp"));
|
||||
assertEquals("250", value(grant, "requested_xp"));
|
||||
var rows = actions(data);
|
||||
sequence(rows, kind, "experience", "completed");
|
||||
linked(rows, value(grant, "operation_id"));
|
||||
assertEquals(after.id().toString(), value(rows.getLast(), "subject_id"));
|
||||
assertEquals("250", value(rows.getLast(), "applied"));
|
||||
privateFieldsOnly(rows, "Private 雪");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
|
||||
void inspectionReadsRealPackedReceiptAndNativePhaseWithoutExposingEquipment(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var target = actor(f, data, "EquipmentHolder");
|
||||
f.activate(target);
|
||||
var pending = f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS)
|
||||
.orElseThrow().pending();
|
||||
assertNotNull(pending);
|
||||
var nativeBefore = target.core.snapshot();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin inspect " + target.core.id));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().stream().anyMatch(m -> m.contains("pending session=")));
|
||||
assertArrayEquals(nativeBefore, target.core.snapshot());
|
||||
assertEquals(pending, f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS)
|
||||
.orElseThrow().pending());
|
||||
awaitDomain(f, data, "recovery.inspected", 1);
|
||||
var inspected = domain(data, "recovery.inspected");
|
||||
assertEquals(1, inspected.size());
|
||||
assertEquals("pending", value(inspected.getFirst(), "receipt_phase"));
|
||||
assertEquals(pending.sessionId().toString(), value(inspected.getFirst(), "session_id"));
|
||||
assertEquals("packed", value(inspected.getFirst(), "native_phase"));
|
||||
var rows = actions(data);
|
||||
sequence(rows, kind, "inspect", "completed");
|
||||
assertEquals(target.core.id.toString(), rows.getFirst().getAsJsonObject("data")
|
||||
.getAsJsonObject("arguments").get("player_id").getAsString());
|
||||
linked(rows, value(inspected.getFirst(), "action_id"));
|
||||
privateFieldsOnly(rows, "EquipmentHolder");
|
||||
assertFalse(rows.toString().contains("DIAMOND_SWORD"));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"})
|
||||
void recoveryUsesRealBlockedExitReceiptThenAcknowledgedNativeRestoration(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var target = actor(f, data, "RecoveryHolder");
|
||||
f.activate(target);
|
||||
var session = sessions(f).active(target.core.id).orElseThrow().session();
|
||||
target.core.inventory.setItem(0, new ItemStack(Material.STONE));
|
||||
assertEquals(EquipmentStorage.RestoreResult.OCCUPIED_SLOT, sessions(f).exit(target.player));
|
||||
awaitRefusedExit(f, target);
|
||||
var retained = f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNotNull(retained.pending());
|
||||
assertEquals(session, retained.pending().sessionId());
|
||||
target.core.inventory.setItem(0, null);
|
||||
int prior = actions(f, data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin recover " + target.core.id));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS)
|
||||
.map(state -> state.pending() == null && state.lastExit() != null).orElse(false); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
});
|
||||
var restored = f.receipts().find(target.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(restored.pending());
|
||||
assertEquals(session, restored.lastExit().sessionId());
|
||||
assertTrue(sessions(f).active(target.core.id).isEmpty());
|
||||
assertEquals(Material.DIAMOND_SWORD, target.core.inventory.getItem(0).getType(),
|
||||
"native restoration must return the original sword, not the temporary obstruction");
|
||||
awaitDomain(f, data, "recovery.resolved", 1);
|
||||
var resolved = domain(data, "recovery.resolved");
|
||||
assertEquals(1, resolved.size());
|
||||
assertEquals("restored", value(resolved.getFirst(), "outcome"));
|
||||
assertEquals("verified_box_and_marker", value(resolved.getFirst(), "custody_quality"));
|
||||
assertEquals(session.toString(), value(resolved.getFirst(), "session_id"));
|
||||
assertNotNull(value(resolved.getFirst(), "equipment_recovery_operation_id"));
|
||||
var rows = actions(data).subList(prior, actions(data).size());
|
||||
sequence(rows, kind, "recover", "completed");
|
||||
linked(rows, value(resolved.getFirst(), "recovery_operation_id"));
|
||||
assertEquals(target.core.id.toString(), rows.getFirst().getAsJsonObject("data")
|
||||
.getAsJsonObject("arguments").get("player_id").getAsString());
|
||||
privateFieldsOnly(rows, "RecoveryHolder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void currentAndSavedCharacterReadsAreRealBooksAndCatalogNotFutureFeatureStubs() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var target = actor(f, data, "CurrentHolder");
|
||||
f.activate(target);
|
||||
var source = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var sourceLease = source.retainNativeMocks();
|
||||
try {
|
||||
var stored = storage(f).inspect(source.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var current = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var checks = new ArrayList<org.junit.jupiter.api.function.Executable>();
|
||||
int prior = actions(f, data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character view CurrentHolder"));
|
||||
ReskillEventsNativeTest.await(f, () -> !fixture.actor.books.isEmpty());
|
||||
assertEquals(Material.WRITTEN_BOOK, fixture.actor.books.getLast().getType());
|
||||
assertEquals(current, storage(f).inspect(current.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
settle(f);
|
||||
var viewRows = actions(data).subList(prior, actions(data).size());
|
||||
checks.add(() -> { sequence(viewRows, "player", "character", "completed"); privateFieldsOnly(viewRows, "CurrentHolder"); });
|
||||
|
||||
prior = actions(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character find " + stored.name()));
|
||||
ReskillEventsNativeTest.await(f, () -> fixture.actor.messages.stream()
|
||||
.anyMatch(m -> m.contains("Saved character results")));
|
||||
assertTrue(fixture.actor.messages.stream().anyMatch(m -> m.contains(stored.id().toString())));
|
||||
assertEquals(stored, storage(f).inspect(stored.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
settle(f);
|
||||
var findRows = actions(data).subList(prior, actions(data).size());
|
||||
checks.add(() -> { sequence(findRows, "player", "character", "completed"); privateFieldsOnly(findRows, stored.name()); });
|
||||
|
||||
prior = actions(data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character show " + stored.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> fixture.actor.books.size() == 2);
|
||||
assertEquals(Material.WRITTEN_BOOK, fixture.actor.books.getLast().getType());
|
||||
assertEquals(stored, storage(f).inspect(stored.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
settle(f);
|
||||
var showRows = actions(data).subList(prior, actions(data).size());
|
||||
checks.add(() -> { sequence(showRows, "player", "character", "completed"); privateFieldsOnly(showRows, stored.name()); });
|
||||
assertAll("native books/catalog first, central authorization and terminal classification second", checks);
|
||||
} finally { sourceLease.close(); }
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"console", "command_block"})
|
||||
void nonPlayerCanSearchButCannotOpenPlayerOnlyCharacterBooks(String kind) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var target = actor(f, data, "ViewTarget");
|
||||
f.activate(target);
|
||||
var saved = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var checks = new ArrayList<org.junit.jupiter.api.function.Executable>();
|
||||
int before = actions(f, data).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character find " + saved.name()));
|
||||
ReskillEventsNativeTest.await(f, () -> entry.messages().stream().anyMatch(m -> m.contains("Saved character results")));
|
||||
assertTrue(entry.messages().stream().anyMatch(m -> m.contains(saved.id().toString())));
|
||||
settle(f);
|
||||
var find = actions(data).subList(before, actions(data).size());
|
||||
checks.add(() -> sequence(find, kind, "character", "completed"));
|
||||
for (var command : List.of("character view ViewTarget", "character show " + saved.id())) {
|
||||
before = actions(data).size();
|
||||
int messageCount = entry.messages().size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
|
||||
assertTrue(entry.messages().size() > messageCount);
|
||||
assertTrue(entry.messages().getLast().contains("in-game administrator"));
|
||||
assertTrue(fixture.actor.books.isEmpty());
|
||||
settle(f);
|
||||
var refused = actions(data).subList(before, actions(data).size());
|
||||
checks.add(() -> sequence(refused, kind, "character", "refused"));
|
||||
}
|
||||
assertEquals(saved, storage(f).inspect(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
assertAll("authorized search and native sender-limited books", checks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inspectionRevokedBeforeDeferredReadCannotClaimOrExposeAnAuthorizedSnapshot() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var target = actor(f, data, "ProtectedHolder");
|
||||
f.activate(target);
|
||||
var nativeBefore = target.core.snapshot();
|
||||
int messages = fixture.actor.messages.size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin inspect " + target.core.id));
|
||||
fixture.actor.allowed = false;
|
||||
awaitExport(f, () -> domain(data, "action.resolved").stream()
|
||||
.anyMatch(row -> "permission_changed".equals(value(row, "reason"))));
|
||||
assertEquals(messages, fixture.actor.messages.size());
|
||||
assertArrayEquals(nativeBefore, target.core.snapshot());
|
||||
settle(f);
|
||||
assertTrue(domain(data, "recovery.inspected").isEmpty());
|
||||
assertEquals("permission_changed", value(domain(data, "action.resolved").getLast(), "reason"));
|
||||
var rows = actions(data);
|
||||
sequence(rows, "player", "inspect", "refused");
|
||||
assertFalse(rows.getLast().getAsJsonObject("data").has("subject_id"));
|
||||
assertFalse(rows.getLast().getAsJsonObject("data").has("target_id"));
|
||||
privateFieldsOnly(rows, "ProtectedHolder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedNamesFullUuidAndMissingTargetsCannotCreateSuccessOrLeakOpaqueValues() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var saved = storage(f).inspect(fixture.actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var beforeInventory = fixture.actor.core.snapshot();
|
||||
var checks = new ArrayList<org.junit.jupiter.api.function.Executable>();
|
||||
for (var command : List.of("give NotExact SECRET_PASSWORD=https://secret.invalid/token",
|
||||
"experience give NotExact 42", "inspect " + fixture.actor.core.id.toString().substring(0, 8),
|
||||
"recover bad-uuid", "character view NotExact", "character show " + saved.id().toString().substring(0, 8),
|
||||
"character show " + UUID.randomUUID())) {
|
||||
int offset = actions(f, data).size();
|
||||
int feedback = fixture.actor.messages.size();
|
||||
int books = fixture.actor.books.size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
|
||||
// The missing full UUID is a valid shape but an asynchronous absent saved-character read.
|
||||
if (command.startsWith("character show ") && command.length() > "character show ".length() + 8)
|
||||
ReskillEventsNativeTest.await(f, () -> fixture.actor.messages.size() > feedback);
|
||||
assertTrue(fixture.actor.messages.size() > feedback, "owner must refuse: " + command);
|
||||
assertEquals(books, fixture.actor.books.size());
|
||||
assertArrayEquals(beforeInventory, fixture.actor.core.snapshot());
|
||||
assertEquals(saved, storage(f).inspect(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
settle(f);
|
||||
var rows = actions(data).subList(offset, actions(data).size());
|
||||
checks.add(() -> { sequence(rows, "player", command.split(" ")[0], "refused");
|
||||
privateFieldsOnly(rows, "SECRET_PASSWORD", "secret.invalid", "NotExact", "bad-uuid"); });
|
||||
}
|
||||
assertTrue(domain(data, "character.issued").isEmpty());
|
||||
assertTrue(domain(data, "experience.granted").isEmpty());
|
||||
assertTrue(domain(data, "recovery.inspected").isEmpty());
|
||||
assertTrue(domain(data, "recovery.resolved").isEmpty());
|
||||
assertAll("native strict names, canonical full UUID and no fabricated owner success", checks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void issuedAuditObservationPumpsPendingNativeDeliveryBeforeExport() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
|
||||
var recipient = actor(f, data, "Recipient");
|
||||
int slot = recipient.core.inventory.firstEmpty();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin give Recipient"));
|
||||
storage(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
assertNull(recipient.core.inventory.getItem(slot), "Worker allocation still needs its native delivery callback");
|
||||
assertTrue(observeExport(f, () -> domain(data, "character.issued").isEmpty(), TimeUnit.SECONDS.toNanos(5)),
|
||||
"No success export exists before native delivery");
|
||||
awaitDomain(f, data, "character.issued", 1);
|
||||
var sheets = new CharacterSheets();
|
||||
var item = recipient.core.inventory.getItem(slot);
|
||||
var issued = sheets.identify(item).orElseThrow();
|
||||
assertTrue(sheets.authorizes(item, storage(f).find(issued).get(5, TimeUnit.SECONDS).orElseThrow()));
|
||||
var issue = domain(data, "character.issued");
|
||||
assertEquals(1, issue.size());
|
||||
assertEquals(issued.toString(), value(issue.getFirst(), "character_id"));
|
||||
assertEquals(recipient.core.id.toString(), value(issue.getFirst(), "target_player_id"));
|
||||
sequence(actions(data), "command_block", "give", "completed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void issuedAuditObservationWaitsForOwnedInFlightAppendBeforeStrictParsing() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var segment = issueAndSettle(fixture);
|
||||
var complete = java.nio.file.Files.readAllBytes(segment);
|
||||
var writer = (java.util.concurrent.ThreadPoolExecutor) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "worker");
|
||||
var partialVisible = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var returned = new java.util.concurrent.CountDownLatch(1);
|
||||
var premature = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
// Replay visibility of the same genuine exported bytes, not a fabricated issue/result.
|
||||
// Only the fixture-owned writer is active; completed bytes/counts must be unchanged.
|
||||
var append = writer.submit(() -> {
|
||||
try {
|
||||
java.nio.file.Files.write(segment, java.util.Arrays.copyOf(complete, complete.length - 2));
|
||||
partialVisible.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
} catch (Exception error) { throw new AssertionError(error); }
|
||||
finally {
|
||||
try { java.nio.file.Files.write(segment, complete); }
|
||||
catch (Exception error) { throw new AssertionError(error); }
|
||||
}
|
||||
});
|
||||
java.util.concurrent.CompletableFuture<Void> observer = null;
|
||||
try {
|
||||
assertTrue(partialVisible.await(5, TimeUnit.SECONDS));
|
||||
observer = java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
try { premature.set(returned.await(200, TimeUnit.MILLISECONDS)); }
|
||||
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new AssertionError(error); }
|
||||
finally { release.countDown(); }
|
||||
});
|
||||
awaitDomain(f, data, "character.issued", 1);
|
||||
returned.countDown(); observer.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(premature.get(), "Audit observation must wait for its owned append, not a visible prefix");
|
||||
} finally {
|
||||
release.countDown();
|
||||
try { if (observer != null) observer.get(5, TimeUnit.SECONDS); }
|
||||
finally { try { append.get(5, TimeUnit.SECONDS); } finally { settle(f); } }
|
||||
}
|
||||
assertArrayEquals(complete, java.nio.file.Files.readAllBytes(segment));
|
||||
assertEquals(1, domain(data, "character.issued").size());
|
||||
sequence(actions(data), "command_block", "give", "completed");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void completedMalformedIssuedAuditStillFailsStrictVerification() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var segment = issueAndSettle(fixture);
|
||||
var complete = java.nio.file.Files.readAllBytes(segment);
|
||||
try {
|
||||
java.nio.file.Files.write(segment, java.util.Arrays.copyOf(complete, complete.length - 2));
|
||||
assertTrue(FixtureAuditAcknowledgmentNativeTest.snapshot(f).idle());
|
||||
var failure = assertThrows(AssertionError.class, () -> awaitDomain(f, data, "character.issued", 1));
|
||||
assertInstanceOf(com.google.gson.JsonSyntaxException.class, failure.getCause(),
|
||||
"Completed malformed export must not be skipped, swallowed or accepted as success");
|
||||
} finally { java.nio.file.Files.write(segment, complete); }
|
||||
assertEquals(1, domain(data, "character.issued").size());
|
||||
sequence(actions(data), "command_block", "give", "completed");
|
||||
}
|
||||
}
|
||||
|
||||
private Path issueAndSettle(QuestTrackingAuditNativeFixture fixture) throws Exception {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "command_block", true);
|
||||
var recipient = actor(f, data, "Recipient");
|
||||
int slot = recipient.core.inventory.firstEmpty();
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin give Recipient"));
|
||||
storage(f).find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
ReskillEventsNativeTest.await(f, () -> recipient.core.inventory.getItem(slot) != null);
|
||||
assertTrue(new CharacterSheets().authorizes(recipient.core.inventory.getItem(slot),
|
||||
storage(f).find(new CharacterSheets().identify(recipient.core.inventory.getItem(slot)).orElseThrow())
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow()));
|
||||
settle(f);
|
||||
assertEquals(1, domain(data, "character.issued").size());
|
||||
sequence(actions(data), "command_block", "give", "completed");
|
||||
return (Path) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"), "segment");
|
||||
}
|
||||
|
||||
private static FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, Path root, String name) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
actor.accountName = name;
|
||||
f.players.put(actor.core.id, actor.player); // Fixture leases every actor-owned native mock.
|
||||
return actor;
|
||||
}
|
||||
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
}
|
||||
private static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
}
|
||||
private static List<JsonObject> actions(Path root) throws Exception { return AdminActionEntryNativeTest.rows(root); }
|
||||
private static List<JsonObject> actions(FantasyStickLifecycleNativeTest.Fixture f, Path root) throws Exception {
|
||||
return observeExport(f, () -> actions(root), TimeUnit.SECONDS.toNanos(5));
|
||||
}
|
||||
private static List<JsonObject> domain(Path root, String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(root).stream().filter(row -> row.get("type").getAsString()
|
||||
.equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
|
||||
}
|
||||
private static void awaitDomain(FantasyStickLifecycleNativeTest.Fixture f, Path root, String suffix, int count) throws Exception {
|
||||
awaitExport(f, () -> domain(root, suffix).size() >= count);
|
||||
}
|
||||
private static <T> T observeExport(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
java.util.concurrent.Callable<T> read, long remaining) throws Exception {
|
||||
return NativeTestAwait.readAudit(f, read, remaining);
|
||||
}
|
||||
private static void awaitExport(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
java.util.concurrent.Callable<Boolean> observed) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (true) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
assertTrue(remaining > 0, "Expected audit export did not arrive within its existing five-second bound");
|
||||
// Strict file I/O shares the actual owned serial writer: never parse mid-append.
|
||||
// Missing rows may await native callbacks; malformed completed rows still fail.
|
||||
if (observeExport(f, observed, remaining)) {
|
||||
settle(f); // Relocated existing acknowledgment fence before subsequent strict assertions.
|
||||
return;
|
||||
}
|
||||
remaining = deadline - System.nanoTime();
|
||||
assertTrue(remaining > 0, "Expected audit export did not arrive within its existing five-second bound");
|
||||
var task = f.tasks.poll(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(50)), TimeUnit.NANOSECONDS);
|
||||
if (task != null) task.run();
|
||||
}
|
||||
}
|
||||
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
}
|
||||
private static String value(JsonObject row, String key) {
|
||||
var data = row.getAsJsonObject("data");
|
||||
return data.has(key) ? data.get(key).getAsString() : null;
|
||||
}
|
||||
private static void sequence(List<JsonObject> rows, String actor, String route, String outcome) {
|
||||
assertTrue(rows.size() == 3 || rows.size() == 4, "one request, authorization, pending/final resolution: " + rows);
|
||||
AdminActionEntryNativeTest.assertStages(rows.subList(0, 3), actor, route, "authorized",
|
||||
rows.size() == 4 ? "pending" : outcome);
|
||||
assertEquals("result", value(rows.getLast(), "stage"));
|
||||
assertEquals(outcome, value(rows.getLast(), "outcome"));
|
||||
assertEquals(1, rows.stream().map(row -> value(row, "action_id")).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
private static void linked(List<JsonObject> rows, String operation) {
|
||||
assertNotNull(operation, "native domain owner must supply its own exact operation identity");
|
||||
assertEquals(UUID.fromString(operation).toString(), value(rows.getLast(), "domain_operation_id"),
|
||||
"link must come from the captured owner operation, not value/time/subject scans");
|
||||
}
|
||||
private static void privateFieldsOnly(List<JsonObject> rows, String... forbidden) {
|
||||
for (var row : rows) {
|
||||
var fields = row.getAsJsonObject("data");
|
||||
for (var key : List.of("raw_command", "command_line", "config_dump", "item", "nbt", "pdc", "password"))
|
||||
assertFalse(fields.has(key), key);
|
||||
for (var secret : forbidden) assertFalse(row.toString().contains(secret), secret);
|
||||
if (fields.has("arguments")) for (var value : fields.getAsJsonObject("arguments").entrySet()) {
|
||||
assertTrue(List.of("player_id", "subject_id", "target_id", "character_id", "session_id", "amount", "requested", "value", "key", "setting", "confirmation")
|
||||
.contains(value.getKey()), "unknown argument key: " + value.getKey());
|
||||
if (value.getKey().endsWith("_id")) assertEquals(UUID.fromString(value.getValue().getAsString()).toString(), value.getValue().getAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void awaitRefusedExit(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor target) throws Exception {
|
||||
var owned = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions(f), "owned");
|
||||
var attempt = owned.get(target.core.id);
|
||||
assertNotNull(attempt);
|
||||
for (int pass = 0; pass < 8; pass++) {
|
||||
f.awaitCharacterAndReceipt(target);
|
||||
if (!(boolean) FantasyStickLifecycleNativeTest.Fixture.field(attempt, "saving")) return;
|
||||
f.tick();
|
||||
}
|
||||
fail("native refused-exit receipt/save did not settle before command recovery");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.persistence.FrostShardSettings;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
/** Audit outcomes follow directory reads and saved/publication decisions, not command feedback. */
|
||||
class AdminActionReadAndConfigNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
static Stream<Arguments> reads() {
|
||||
return Stream.of("adventure-site list", "quest-giver list", "dungeon list", "shop list", "rare-shop list",
|
||||
"training-dummy list", "party-registrar list", "config frost-shard-range-blocks")
|
||||
.flatMap(route -> Stream.of("player", "console", "command_block").map(kind -> Arguments.of(route, kind)));
|
||||
}
|
||||
|
||||
@ParameterizedTest @MethodSource("reads")
|
||||
void actualAuthorizedReadIsCompletedNotPermanentlyPendingAndNeverMutatesState(String command, String kind) throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "retained: untouched\nfrost-shard-range-blocks: 48\n");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
var inventory = fixture.actor.core.snapshot(); var configuration = Files.readAllBytes(data.resolve("config.yml"));
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin " + command));
|
||||
assertFalse(entry.messages().isEmpty());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
|
||||
var rows = AdminActionEntryNativeTest.rows(data);
|
||||
assertEquals("completed", rows.getLast().getAsJsonObject("data").get("outcome").getAsString(),
|
||||
"real authorized native read cannot remain an unclassified pending command");
|
||||
AdminActionEntryNativeTest.assertStages(rows, kind, command.split(" ")[0], "authorized", "completed");
|
||||
assertEquals("read_completed", rows.getLast().getAsJsonObject("data").get("reason").getAsString());
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
assertArrayEquals(configuration, Files.readAllBytes(data.resolve("config.yml")));
|
||||
if (command.startsWith("config")) {
|
||||
var domain = records("config.read"); assertEquals(1, domain.size());
|
||||
assertEquals(domain.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
|
||||
rows.getLast().getAsJsonObject("data").get("domain_operation_id").getAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"saved", "failed", "feedback-permission-revoked"})
|
||||
void capturedAsyncSettingOutcomeRequiresRealAcknowledgmentAndDomainLinkEvenWithoutFeedback(String mode) throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "retained: untouched\nfrost-shard-range-blocks: 48\n");
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var settings = (FrostShardSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
|
||||
var worker = (ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "worker");
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var hold = worker.submit(() -> {
|
||||
entered.countDown();
|
||||
try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
|
||||
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
|
||||
});
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var before = settings.value(FrostShardSettings.RANGE); var config = Files.readAllBytes(data.resolve("config.yml"));
|
||||
var messages = fixture.actor.messages.size();
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
|
||||
var lock = mode.equals("failed") ? channel.lock() : null;
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin config frost-shard-range-blocks 49.5"));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var waiting = AdminActionEntryNativeTest.rows(data);
|
||||
assertEquals(3, waiting.size());
|
||||
assertEquals("pending", waiting.getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals(before, settings.value(FrostShardSettings.RANGE));
|
||||
assertArrayEquals(config, Files.readAllBytes(data.resolve("config.yml")));
|
||||
assertTrue(records("config.changed").isEmpty());
|
||||
if (mode.equals("feedback-permission-revoked")) fixture.actor.allowed = false;
|
||||
release.countDown(); hold.get(5, TimeUnit.SECONDS); worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
f.tick();
|
||||
if (!mode.equals("failed")) assertEquals("49.5", settings.value(FrostShardSettings.RANGE));
|
||||
else { assertEquals(before, settings.value(FrostShardSettings.RANGE)); assertArrayEquals(config, Files.readAllBytes(data.resolve("config.yml"))); }
|
||||
if (mode.equals("feedback-permission-revoked")) assertEquals(messages, fixture.actor.messages.size());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var rows = AdminActionEntryNativeTest.rows(data);
|
||||
assertEquals(4, rows.size(), "actual saved/failed decision must resolve the original pending request");
|
||||
assertEquals(1, rows.stream().map(row -> row.getAsJsonObject("data").get("action_id").getAsString()).distinct().count());
|
||||
var result = rows.getLast().getAsJsonObject("data");
|
||||
assertEquals(mode.equals("failed") ? "failed" : "completed", result.get("outcome").getAsString());
|
||||
var domain = records("config.changed"); assertEquals(mode.equals("failed") ? 0 : 1, domain.size());
|
||||
if (!domain.isEmpty()) {
|
||||
assertEquals(domain.getFirst().getAsJsonObject("data").get("action_id").getAsString(), result.get("domain_operation_id").getAsString());
|
||||
assertEquals(before, result.get("before").getAsString()); assertEquals("49.5", result.get("after").getAsString());
|
||||
}
|
||||
assertEquals(FrostShardSettings.RANGE, rows.getFirst().getAsJsonObject("data").getAsJsonObject("arguments").get("key").getAsString());
|
||||
assertEquals("49.5", rows.getFirst().getAsJsonObject("data").getAsJsonObject("arguments").get("value").getAsString());
|
||||
} finally { release.countDown(); if (lock != null) lock.release(); }
|
||||
} finally { release.countDown(); hold.get(5, TimeUnit.SECONDS); }
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> records(String suffix) throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream()
|
||||
.filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList();
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.function.Executable;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** PRIVATE US-080 candidate; registered pinned-native entry only. Not verified or repo-owned. */
|
||||
class AdminActionValidationAndTeleportNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
private static final String SECRET = "SECRET_PASSWORD=https://secret.invalid/token";
|
||||
private static final String BAD = "not-a-full-uuid";
|
||||
|
||||
private record Refusal(String command, String route, String secret) { }
|
||||
private static List<Refusal> refusals() {
|
||||
return List.of(
|
||||
new Refusal("", "unknown", ""), // permitted primary root with no arguments
|
||||
new Refusal("adventure", "adventure", ""),
|
||||
new Refusal("adventure reroll incorrect", "adventure", "incorrect"),
|
||||
new Refusal("adventure-site teleport " + BAD, "adventure-site", BAD),
|
||||
new Refusal("adventure-site place camp -1 4 " + SECRET, "adventure-site", SECRET),
|
||||
new Refusal("quest-giver remove " + BAD, "quest-giver", BAD),
|
||||
new Refusal("character show " + BAD, "character", BAD),
|
||||
new Refusal("dungeon teleport " + BAD, "dungeon", BAD),
|
||||
new Refusal("dungeon room effect add players INVALID_EFFECT 2 false", "dungeon", "INVALID_EFFECT"),
|
||||
new Refusal("shop remove " + BAD, "shop", BAD),
|
||||
new Refusal("rare-shop remove " + BAD, "rare-shop", BAD),
|
||||
new Refusal("training-dummy remove " + BAD, "training-dummy", BAD),
|
||||
new Refusal("party-registrar remove " + BAD, "party-registrar", BAD),
|
||||
new Refusal("config frost-shard-range-blocks " + SECRET, "config", SECRET),
|
||||
new Refusal("give", "give", ""),
|
||||
new Refusal("revive", "revive", ""),
|
||||
new Refusal("experience give MissingCharacter not-a-count", "experience", "MissingCharacter"),
|
||||
new Refusal("inspect " + BAD, "inspect", BAD),
|
||||
new Refusal("recover " + BAD, "recover", BAD));
|
||||
}
|
||||
|
||||
@Test void nativeRefusalsAcrossAllFifteenFamiliesAndEmptyPrimaryRootAreTerminalNotPending() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
var sites = fixture.directory.snapshot();
|
||||
var dungeons = ((DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons")).snapshot();
|
||||
var checks = new ArrayList<Executable>();
|
||||
for (var case_ : refusals()) {
|
||||
int offset = AdminActionEntryNativeTest.rows(data).size();
|
||||
int feedback = entry.messages().size();
|
||||
String command = "fantasystick:fstickadmin" + (case_.command().isEmpty() ? "" : " " + case_.command());
|
||||
assertTrue(entry.map.dispatch(entry.sender, command), "packaged root must dispatch: " + command);
|
||||
assertTrue(entry.messages().size() > feedback, "native owner must refuse before audit assertions: " + command);
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot(), command);
|
||||
assertEquals(sites, fixture.directory.snapshot(), command);
|
||||
assertEquals(dungeons, ((DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons")).snapshot(), command);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var all = AdminActionEntryNativeTest.rows(data);
|
||||
var rows = List.copyOf(all.subList(offset, all.size()));
|
||||
checks.add(() -> assertAll(command,
|
||||
() -> AdminActionEntryNativeTest.assertStages(rows, "player", case_.route(), "authorized", "refused"),
|
||||
() -> assertTrue(rows.stream().noneMatch(row -> row.getAsJsonObject("data").has("domain_operation_id")), command),
|
||||
() -> assertFalse(rows.toString().contains("SECRET_PASSWORD"), command),
|
||||
() -> assertFalse(rows.toString().contains("secret.invalid"), command),
|
||||
() -> { if (!case_.secret().isEmpty()) assertFalse(rows.toString().contains(case_.secret()), command); }));
|
||||
}
|
||||
assertAll("actual owner refusals preceded all central audit checks", checks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualPlayerOnlyTeleportRefusalsForBothNonPlayerSenderKinds() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f;
|
||||
var inventory = fixture.actor.core.snapshot();
|
||||
var sites = fixture.directory.snapshot();
|
||||
var dungeons = ((DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons")).snapshot();
|
||||
var checks = new ArrayList<Executable>();
|
||||
for (String kind : List.of("console", "command_block")) {
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true);
|
||||
for (var limit : List.of(
|
||||
new Refusal("adventure-site teleport " + UUID.randomUUID(), "adventure-site", "requires a player"),
|
||||
new Refusal("dungeon teleport " + UUID.randomUUID(), "dungeon", "requires a player"),
|
||||
new Refusal("adventure-site place camp", "adventure-site", "Usage:"),
|
||||
new Refusal("quest-giver place NativeGiver", "quest-giver", "Usage:"),
|
||||
new Refusal("dungeon create NativeDungeon", "dungeon", "Usage:"),
|
||||
new Refusal("dungeon edit start " + UUID.randomUUID(), "dungeon", "Usage:"),
|
||||
new Refusal("shop place NativeShop", "shop", "Usage:"),
|
||||
new Refusal("rare-shop place NativeRareShop", "rare-shop", "Usage:"),
|
||||
new Refusal("training-dummy place NativeDummy", "training-dummy", "Usage:"),
|
||||
new Refusal("party-registrar place NativeRegistrar", "party-registrar", "Usage:"),
|
||||
new Refusal("character view NotAnOnlinePlayer", "character", "requires an in-game administrator"),
|
||||
new Refusal("revive NotAnOnlinePlayer", "revive", "Usage:"))) {
|
||||
int offset = AdminActionEntryNativeTest.rows(data).size();
|
||||
int feedback = entry.messages().size();
|
||||
String command = "fstickadmin " + limit.command();
|
||||
assertTrue(entry.map.dispatch(entry.sender, command));
|
||||
assertTrue(entry.messages().size() > feedback, "native sender limit: " + command);
|
||||
assertTrue(entry.messages().getLast().contains(limit.secret()), command);
|
||||
assertArrayEquals(inventory, fixture.actor.core.snapshot());
|
||||
assertEquals(sites, fixture.directory.snapshot());
|
||||
assertEquals(dungeons, ((DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons")).snapshot());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var all = AdminActionEntryNativeTest.rows(data);
|
||||
var rows = List.copyOf(all.subList(offset, all.size()));
|
||||
checks.add(() -> assertAll(kind + " " + limit.command(),
|
||||
() -> AdminActionEntryNativeTest.assertStages(rows, kind, limit.route(), "authorized", "refused"),
|
||||
() -> assertFalse(rows.toString().contains("SECRET_PASSWORD"))));
|
||||
}
|
||||
}
|
||||
assertAll("sender-limited native authority, not feedback-derived audit", checks);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredTeleportsResolveOnlyFromNativeFuturesAndRevalidatedOwner() throws Exception {
|
||||
// Per-case fixture isolates the registered command's current Action and the destination owner.
|
||||
var checks = new ArrayList<Executable>();
|
||||
for (String route : List.of("adventure-site", "dungeon")) {
|
||||
for (String scenario : List.of("success", "false", "future_exception", "teleport_throw", "chunk_failure", "chunk_throw", "stale", "permission_changed")) {
|
||||
Path root = data.resolve(route + "-" + scenario);
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(root)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var admin = fixture.actor;
|
||||
var destination = new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
UUID world = UUID.randomUUID();
|
||||
when(destination.world.getUID()).thenReturn(world);
|
||||
when(destination.world.getName()).thenReturn("Native Destination");
|
||||
f.players.put(destination.core.id, destination.player); // owns this native actor lease
|
||||
UUID id;
|
||||
if (route.equals("adventure-site")) {
|
||||
id = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP,
|
||||
"Native Camp", world, 32, 70, 48).get(5, TimeUnit.SECONDS).id();
|
||||
} else {
|
||||
id = ((DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons"))
|
||||
.create("Native Vault", world, 32, 70, 48, 0, 0).get(5, TimeUnit.SECONDS).id();
|
||||
}
|
||||
var siteBefore = fixture.directory.snapshot();
|
||||
var dungeonDirectory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
var dungeonBefore = dungeonDirectory.snapshot();
|
||||
var inventory = admin.core.snapshot();
|
||||
var originalLocation = admin.core.location.clone();
|
||||
var chunk = new CompletableFuture<Chunk>();
|
||||
var moving = new CompletableFuture<Boolean>();
|
||||
var destinations = new ArrayList<Location>();
|
||||
if (scenario.equals("chunk_throw"))
|
||||
doThrow(new IllegalStateException("secret-chunk-throw")).when(destination.world).getChunkAtAsync(2, 3);
|
||||
else doReturn(chunk).when(destination.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> {
|
||||
destinations.add(((Location) call.getArgument(0)).clone());
|
||||
if (scenario.equals("teleport_throw")) throw new IllegalStateException("secret-teleport-throw");
|
||||
return moving;
|
||||
}).when(admin.player).teleportAsync(any(Location.class));
|
||||
String command = "fantasystick:fstickadmin " + route + " teleport " + id;
|
||||
int offset = AdminActionEntryNativeTest.rows(root).size();
|
||||
assertTrue(entry.map.dispatch(entry.sender, command));
|
||||
assertTrue(destinations.isEmpty(), "must await actual chunk future");
|
||||
if (scenario.equals("chunk_failure")) chunk.completeExceptionally(new IllegalStateException("secret-chunk-failure"));
|
||||
else if (scenario.equals("chunk_throw")) { /* native synchronous chunk scheduling already refused */ }
|
||||
else {
|
||||
if (scenario.equals("stale")) {
|
||||
if (route.equals("adventure-site")) fixture.directory.removeSite(id).get(5, TimeUnit.SECONDS);
|
||||
else dungeonDirectory.remove(id).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
if (scenario.equals("permission_changed")) entry.fixture.actor.allowed = false;
|
||||
chunk.complete(f.own(mock(Chunk.class)));
|
||||
}
|
||||
String nativeFeedback = switch (scenario) {
|
||||
case "success" -> "Teleported to ";
|
||||
case "stale" -> "destination changed";
|
||||
case "permission_changed" -> "permission or player session changed";
|
||||
default -> "teleport failed";
|
||||
};
|
||||
if (List.of("success", "false", "future_exception", "teleport_throw").contains(scenario)) {
|
||||
ReskillEventsNativeTest.await(f, () -> destinations.size() == 1);
|
||||
assertEquals(1, destinations.size(), "native teleportAsync must decide: " + command);
|
||||
if (scenario.equals("success")) moving.complete(true);
|
||||
else if (scenario.equals("false")) moving.complete(false);
|
||||
else if (scenario.equals("future_exception")) moving.completeExceptionally(new IllegalStateException("secret-teleport-failure"));
|
||||
}
|
||||
ReskillEventsNativeTest.await(f, () -> !admin.messages.isEmpty() && admin.messages.getLast().contains(nativeFeedback));
|
||||
if (!List.of("success", "false", "future_exception", "teleport_throw").contains(scenario))
|
||||
assertTrue(destinations.isEmpty(), "native refusal precedes teleport: " + command);
|
||||
// Establish owner result and lack of unrelated mutation BEFORE central assertions.
|
||||
assertTrue(admin.messages.getLast().contains(nativeFeedback), admin.messages.toString());
|
||||
assertEquals(originalLocation, admin.core.location, "native fixture never moves on its own");
|
||||
assertArrayEquals(inventory, admin.core.snapshot());
|
||||
if (!scenario.equals("stale")) {
|
||||
assertEquals(siteBefore, fixture.directory.snapshot());
|
||||
assertEquals(dungeonBefore, dungeonDirectory.snapshot());
|
||||
}
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var all = AdminActionEntryNativeTest.rows(root);
|
||||
var rows = List.copyOf(all.subList(offset, all.size()));
|
||||
String outcome = switch (scenario) {
|
||||
case "success" -> "completed";
|
||||
case "false", "future_exception", "chunk_failure", "teleport_throw", "chunk_throw" -> "failed";
|
||||
default -> "refused";
|
||||
};
|
||||
checks.add(() -> assertAll(command + " / " + scenario,
|
||||
() -> assertTeleportStages(rows, route, outcome, !scenario.equals("chunk_throw")),
|
||||
() -> assertFalse(rows.toString().contains("secret-chunk-failure")),
|
||||
() -> assertFalse(rows.toString().contains("secret-teleport-failure")),
|
||||
() -> assertFalse(rows.toString().contains("secret-chunk-throw")),
|
||||
() -> assertFalse(rows.toString().contains("secret-teleport-throw")),
|
||||
() -> assertFalse(rows.toString().contains(SECRET))));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertAll("native future outcomes precede every independent central audit assertion", checks);
|
||||
}
|
||||
|
||||
@Test void registeredTeleportFutureResolutionPrecedesFeedbackAndRejectedMainTaskIsTerminal() throws Exception {
|
||||
var checks = new ArrayList<Executable>();
|
||||
for (String route : List.of("adventure-site", "dungeon")) {
|
||||
for (String scenario : List.of("true", "false", "exception", "true_rejected", "false_rejected", "exception_rejected", "prevalidation_rejected")) {
|
||||
Path root = data.resolve("completion-before-feedback-" + route + "-" + scenario);
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(root)) {
|
||||
var f = fixture.f;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
var admin = fixture.actor;
|
||||
var destination = new FantasyStickLifecycleNativeTest.Actor(root);
|
||||
UUID world = UUID.randomUUID();
|
||||
when(destination.world.getUID()).thenReturn(world);
|
||||
when(destination.world.getName()).thenReturn("Native Destination");
|
||||
f.players.put(destination.core.id, destination.player);
|
||||
var dungeonDirectory = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
UUID id = route.equals("adventure-site")
|
||||
? fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP,
|
||||
"Native Camp", world, 32, 70, 48).get(5, TimeUnit.SECONDS).id()
|
||||
: dungeonDirectory.create("Native Vault", world, 32, 70, 48, 0, 0)
|
||||
.get(5, TimeUnit.SECONDS).id();
|
||||
var sites = fixture.directory.snapshot();
|
||||
var dungeons = dungeonDirectory.snapshot();
|
||||
var inventory = admin.core.snapshot();
|
||||
var originalLocation = admin.core.location.clone();
|
||||
var chunk = new CompletableFuture<Chunk>();
|
||||
var moving = new CompletableFuture<Boolean>();
|
||||
var destinations = new ArrayList<Location>();
|
||||
doReturn(chunk).when(destination.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> {
|
||||
destinations.add(((Location) call.getArgument(0)).clone());
|
||||
return moving;
|
||||
}).when(admin.player).teleportAsync(any(Location.class));
|
||||
int offset = AdminActionEntryNativeTest.rows(root).size();
|
||||
int feedback = admin.messages.size();
|
||||
String command = "fantasystick:fstickadmin " + route + " teleport " + id;
|
||||
assertTrue(entry.map.dispatch(entry.sender, command));
|
||||
assertTrue(destinations.isEmpty(), "chunk future must precede native teleportAsync");
|
||||
assertEquals(feedback, admin.messages.size(), "no immediate result feedback");
|
||||
boolean prevalidation = scenario.equals("prevalidation_rejected");
|
||||
boolean postmove = !prevalidation && scenario.endsWith("_rejected");
|
||||
int scheduled = f.schedulerCalls.get();
|
||||
int queued = f.tasks.size();
|
||||
if (prevalidation) f.rejectNextMainTask.set(true);
|
||||
try {
|
||||
assertTrue(chunk.complete(f.own(mock(Chunk.class))));
|
||||
if (prevalidation) assertFalse(f.rejectNextMainTask.get(), "the chunk callback consumed the one-shot rejection");
|
||||
assertEquals(scheduled + 1, f.schedulerCalls.get(), "chunk completion must attempt exactly one native runTask");
|
||||
assertEquals(queued + (prevalidation ? 0 : 1), f.tasks.size(), "rejected chunk callback must not be queued");
|
||||
} finally {
|
||||
f.rejectNextMainTask.set(false);
|
||||
}
|
||||
if (!prevalidation) {
|
||||
ReskillEventsNativeTest.await(f, () -> destinations.size() == 1);
|
||||
assertEquals(1, destinations.size(), "native teleportAsync must be reached after chunk validation");
|
||||
assertEquals(world, destinations.getFirst().getWorld().getUID());
|
||||
scheduled = f.schedulerCalls.get();
|
||||
queued = f.tasks.size();
|
||||
if (postmove) f.rejectNextMainTask.set(true);
|
||||
try {
|
||||
boolean completed = switch (scenario) {
|
||||
case "true", "true_rejected" -> moving.complete(true);
|
||||
case "false", "false_rejected" -> moving.complete(false);
|
||||
case "exception", "exception_rejected" -> moving.completeExceptionally(new IllegalStateException("secret-native-move-failure"));
|
||||
default -> throw new AssertionError(scenario);
|
||||
};
|
||||
assertTrue(completed, "native teleportAsync future must resolve once");
|
||||
if (postmove) assertFalse(f.rejectNextMainTask.get(), "the move callback consumed the one-shot rejection");
|
||||
assertEquals(scheduled + 1, f.schedulerCalls.get(), "move completion must attempt exactly one native runTask");
|
||||
assertEquals(queued + (postmove ? 0 : 1), f.tasks.size(), "rejected feedback callback must not be queued");
|
||||
} finally {
|
||||
f.rejectNextMainTask.set(false);
|
||||
}
|
||||
} else {
|
||||
assertTrue(destinations.isEmpty(), "rejected prevalidation cannot start a native move");
|
||||
assertFalse(moving.isDone(), "native move future was never requested");
|
||||
}
|
||||
// No post-completion tick: audit the captured native result before any feedback callback runs.
|
||||
assertEquals(feedback, admin.messages.size(), "feedback has not run");
|
||||
assertEquals(originalLocation, admin.core.location, "fixture observes, never simulates player movement");
|
||||
assertArrayEquals(inventory, admin.core.snapshot());
|
||||
assertEquals(sites, fixture.directory.snapshot());
|
||||
assertEquals(dungeons, dungeonDirectory.snapshot());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var all = AdminActionEntryNativeTest.rows(root);
|
||||
var rows = List.copyOf(all.subList(offset, all.size()));
|
||||
String outcome = scenario.startsWith("true") ? "completed" : "failed";
|
||||
checks.add(() -> assertAll(command + " / " + scenario,
|
||||
() -> assertTeleportStages(rows, route, outcome, true),
|
||||
() -> assertEquals("owner_outcome_pending", rows.get(2).getAsJsonObject("data").get("reason").getAsString()),
|
||||
() -> assertEquals(outcome.equals("completed") ? "acknowledged" : "operation_failed",
|
||||
rows.getLast().getAsJsonObject("data").get("reason").getAsString()),
|
||||
() -> assertTrue(rows.stream().allMatch(row -> row.has("playerid")
|
||||
&& row.get("playerid").getAsString().equals(admin.core.id.toString()))),
|
||||
() -> assertFalse(rows.toString().contains("secret-native-move-failure")),
|
||||
() -> assertFalse(rows.toString().contains(SECRET))));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertAll("native results before feedback, including rejected scheduler handoffs", checks);
|
||||
}
|
||||
|
||||
private static void assertTeleportStages(List<JsonObject> rows, String route, String expected, boolean deferred) {
|
||||
var fields = rows.stream().map(row -> row.getAsJsonObject("data")).toList();
|
||||
assertEquals(deferred ? List.of("request", "authorization", "result", "result")
|
||||
: List.of("request", "authorization", "result"),
|
||||
fields.stream().map(row -> row.get("stage").getAsString()).toList(), route);
|
||||
if (deferred) assertEquals("pending", fields.get(2).get("outcome").getAsString());
|
||||
assertEquals(expected, fields.getLast().get("outcome").getAsString());
|
||||
assertEquals(1, fields.stream().map(row -> row.get("action_id").getAsString()).distinct().count());
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
for (var row : fields) {
|
||||
assertEquals(route, row.get("route").getAsString());
|
||||
assertEquals("player", row.get("actor_kind").getAsString());
|
||||
assertEquals("authorized", row.get("authorization").getAsString());
|
||||
assertEquals("command", row.get("entry_point").getAsString());
|
||||
assertFalse(row.has("domain_operation_id"), "teleport must not fabricate domain mutation");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ class AdminCharacterBookNativeTest {
|
||||
|
||||
assertEquals(java.util.List.of("character", "config"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"c"}));
|
||||
assertEquals(java.util.List.of("find", "recover", "show", "view"),
|
||||
assertEquals(java.util.List.of("coins", "find", "recover", "show", "view"),
|
||||
fixture.command.tabComplete(admin.player, "fstickadmin", new String[]{"character", ""}));
|
||||
assertEquals(java.util.List.of("ActiveTarget"),
|
||||
fixture.command.tabComplete(admin.player, "fantasystick", new String[]{"character", "view", ""}));
|
||||
@@ -293,7 +293,7 @@ class AdminCharacterBookNativeTest {
|
||||
admin.allowed = true;
|
||||
|
||||
var console = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("find"), fixture.command.tabComplete(console.port, "fstickadmin",
|
||||
assertEquals(java.util.List.of("coins", "find"), fixture.command.tabComplete(console.port, "fstickadmin",
|
||||
new String[]{"character", ""}));
|
||||
fixture.command.execute(console.port, "fantasystick",
|
||||
new String[]{"character", "view", "ActiveTarget"});
|
||||
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user