Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12d4cb5ca | ||
|
|
df6474fd55 | ||
|
|
edada37669 | ||
|
|
608a5fd2aa | ||
|
|
04e224ad95 | ||
|
|
b3473aff18 | ||
|
|
a2140bd8d7 | ||
|
|
7ea6e254eb | ||
|
|
09dd5ded57 | ||
|
|
9896194932 | ||
|
|
167573f953 | ||
|
|
22c46f6780 | ||
|
|
ea760815bd | ||
|
|
01e6ac6e5a | ||
|
|
0acf90909b | ||
|
|
3a184d5298 | ||
|
|
403dd4b78a |
No files matched your search
@@ -1,11 +1,13 @@
|
||||
# purpur-fantasy-stick agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and process documents live in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant `../somc-okf/user-stories/purpur-fantasy-stick/` stories. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
|
||||
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-fantasy-stick] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-fantasy-stick/index.md`, that project's `engineering.md`, and relevant Kaneo tickets with subject prefix `[purpur-fantasy-stick]`. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions.
|
||||
|
||||
For standalone checkouts, obtain access to the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md) and [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Do not create a parallel knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Follow the [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, meaningful failing tests, minimal passing implementation, immediate criterion evidence, full verification, and source/wiki publication. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
|
||||
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push.
|
||||
|
||||
Use Java 25 and the declared Purpur API. Run `./gradlew clean check jar`. Preserve runtime identity, persistent namespaces, and unrelated player/plugin state. Never expose release credentials in arguments, output, or Git.
|
||||
@@ -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).
|
||||
|
||||
@@ -81,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.
|
||||
|
||||
@@ -190,16 +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.
|
||||
|
||||
+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)
|
||||
|
||||
@@ -133,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;
|
||||
@@ -164,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;
|
||||
@@ -268,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(
|
||||
@@ -611,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());
|
||||
@@ -748,6 +759,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
admin.attachCharacterCoins(coinsService);
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPathSpeed(pathSpeedSettings, pathSpeed);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachPartyEconomySettings(partyEconomySettings);
|
||||
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
|
||||
@@ -918,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));
|
||||
@@ -1117,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);
|
||||
@@ -1198,6 +1215,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
@@ -1374,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;
|
||||
@@ -1456,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;
|
||||
@@ -1483,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;
|
||||
@@ -1523,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);
|
||||
@@ -1621,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)
|
||||
@@ -1657,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);
|
||||
@@ -1743,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();
|
||||
@@ -1944,9 +1977,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adminActionDrain = closingAdminActionEvents.drained();
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", () -> {
|
||||
// Preserve synchronous writer shutdown/lock release at disable return. Only the central
|
||||
// handoff is awaited here; no all-owner sink-acknowledgment guarantee is implied.
|
||||
var handoff = adminActionDrain;
|
||||
// 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;
|
||||
|
||||
@@ -255,7 +255,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
|
||||
boolean deferNativeCast = audit != null
|
||||
&& Set.of("quickstep", "chain_lightning").contains(definition.id());
|
||||
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
|
||||
@@ -6,6 +6,7 @@ import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.math.MathContext;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
@@ -30,33 +31,36 @@ import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Deterministic INT-scaled bounded lightning chain with exact decimal decay. */
|
||||
/** Deterministic bounded lightning chain sharing one exact INT-defined damage pool. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier, BigInteger targetIntStep, int maxTargets) {
|
||||
private static final BigInteger DAMAGE_FACTOR = BigInteger.valueOf(4);
|
||||
private static final MathContext SHARE_PRECISION = MathContext.DECIMAL128;
|
||||
|
||||
public record Settings(Duration cooldown, double initialRange, double jumpRange, int maxTargets) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier); Objects.requireNonNull(targetIntStep);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (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 || targetIntStep.signum() <= 0 || maxTargets < 1 || maxTargets > 64
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
|| maxTargets < 1 || maxTargets > 20) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 4, new BigDecimal("0.75"), BigInteger.valueOf(5), 12);
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(18), 16, 6, 20); }
|
||||
}
|
||||
|
||||
private record SelectedTarget(LivingEntity target, LivingEntity previous) { }
|
||||
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested;
|
||||
final BigDecimal defined; final UUID chainId; final int ordinal;
|
||||
final UUID previousId; final Map<String, Object> previousLocation;
|
||||
final BigDecimal totalDefined; final BigDecimal definedShare; final UUID chainId; final int ordinal;
|
||||
final int selectedChainSize; final UUID previousId; final Map<String, Object> previousLocation;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
|
||||
UUID chainId, int ordinal, LivingEntity previous) {
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal totalDefined,
|
||||
BigDecimal definedShare, UUID chainId, int ordinal, int selectedChainSize, LivingEntity previous) {
|
||||
this.execution = execution; this.target = target; this.requested = requested;
|
||||
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
|
||||
this.totalDefined = totalDefined; this.definedShare = definedShare; this.chainId = chainId;
|
||||
this.ordinal = ordinal; this.selectedChainSize = selectedChainSize;
|
||||
previousId = previous == null ? null : previous.getUniqueId();
|
||||
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
|
||||
}
|
||||
@@ -82,36 +86,30 @@ 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, 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.",
|
||||
List.of("Build one damage pool equal to four times your purchased INT modifier.",
|
||||
"Discover up to " + current.maxTargets() + " targets, then divide that pool equally across the complete chain.",
|
||||
"Start within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks; each nearest visible jump is within "
|
||||
+ decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile; cancelled or invalid shares are not redistributed.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
Map.of("initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier(),
|
||||
"target-int-step", new BigDecimal(current.targetIntStep()), "max-targets", BigDecimal.valueOf(current.maxTargets())), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
"max-targets", BigDecimal.valueOf(current.maxTargets()),
|
||||
"int-modifier-damage-factor", new BigDecimal(DAMAGE_FACTOR)), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
|
||||
public static BigInteger intelligenceModifier(BigInteger intelligence) {
|
||||
Objects.requireNonNull(intelligence);
|
||||
if (intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning INT");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
var modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return 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();
|
||||
public static BigInteger totalDefinedDamage(BigInteger intelligence) {
|
||||
return intelligenceModifier(intelligence).multiply(DAMAGE_FACTOR);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
@@ -121,39 +119,76 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var purchasedInt = execution.character().stats().get(Stat.INT);
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
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);
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, current);
|
||||
if (hop + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
previous = current;
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
|
||||
var selected = discover(execution, caster, initial, captured);
|
||||
if (selected.isEmpty()) {
|
||||
execution.resolve(execution.stillCurrent() ? "miss" : "interrupted",
|
||||
Map.of("reason", execution.stillCurrent() ? "initial_target_became_ineligible" : "source_interrupted",
|
||||
"selected_chain_size", 0, "attempted_hops", 0, "accepted_hops", 0));
|
||||
return;
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>();
|
||||
outcome.put("chain_id", chainId.toString());
|
||||
var totalDefined = new BigDecimal(totalDefinedDamage(purchasedInt));
|
||||
var definedShare = totalDefined.divide(BigDecimal.valueOf(selected.size()), SHARE_PRECISION);
|
||||
double nativeShare = definedShare.doubleValue();
|
||||
var chainId = UUID.randomUUID();
|
||||
var common = new LinkedHashMap<String, Object>();
|
||||
common.put("chain_id", chainId.toString());
|
||||
common.put("selected_chain_size", selected.size());
|
||||
common.put("total_defined_pool", decimal(totalDefined));
|
||||
common.put("equal_defined_share", decimal(definedShare));
|
||||
execution.castEvidence(common);
|
||||
if (totalDefined.signum() <= 0 || !Double.isFinite(nativeShare) || nativeShare <= 0) {
|
||||
common.put("terminal_reason", "native_damage_not_finite");
|
||||
common.put("attempted_hops", 0); common.put("accepted_hops", 0);
|
||||
execution.resolve("failed", common); return;
|
||||
}
|
||||
|
||||
int attempted = 0, accepted = 0;
|
||||
for (int index = 0; index < selected.size(); index++) {
|
||||
var chosen = selected.get(index);
|
||||
if (!execution.stillCurrent()) break;
|
||||
var pathSource = chosen.previous() == null ? caster : chosen.previous();
|
||||
double range = chosen.previous() == null ? captured.initialRange() : captured.jumpRange();
|
||||
if (!eligible(caster, chosen.target()) || !inRangeAndVisible(pathSource, chosen.target(), range)) {
|
||||
visual(caster, chosen.target()); continue;
|
||||
}
|
||||
var attempt = strike(execution, caster, chosen.target(), nativeShare, totalDefined, definedShare,
|
||||
chainId, index + 1, selected.size(), chosen.previous());
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, chosen.target());
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>(common);
|
||||
outcome.put("initial_target_id", initial.getUniqueId().toString());
|
||||
outcome.put("initial_target_location", locationData(initial.getLocation()));
|
||||
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
|
||||
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
|
||||
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
|
||||
: selected.size() == captured.maxTargets() ? "target_limit" : "no_eligible_next_target");
|
||||
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
|
||||
}
|
||||
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
|
||||
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
private List<SelectedTarget> discover(AbilityExecution execution, Player caster, LivingEntity initial, Settings captured) {
|
||||
var selected = new ArrayList<SelectedTarget>(); var visited = new HashSet<UUID>();
|
||||
LivingEntity current = initial, previous = null;
|
||||
while (current != null && selected.size() < captured.maxTargets() && execution.stillCurrent()) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
selected.add(new SelectedTarget(current, previous));
|
||||
if (selected.size() == captured.maxTargets()) break;
|
||||
previous = current; current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
return List.copyOf(selected);
|
||||
}
|
||||
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target, double amount,
|
||||
BigDecimal totalDefined, BigDecimal definedShare, UUID chainId, int ordinal,
|
||||
int selectedChainSize, LivingEntity previous) {
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, totalDefined, definedShare, chainId, ordinal,
|
||||
selectedChainSize, previous); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
return attempt;
|
||||
@@ -190,6 +225,8 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
|
||||
private boolean eligible(Player caster, LivingEntity target) {
|
||||
if (target == caster) return false;
|
||||
try { if (target.isDead()) return false; }
|
||||
catch (RuntimeException unavailable) { return false; }
|
||||
if (!(target instanceof Player player)) return true;
|
||||
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
|
||||
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
|
||||
@@ -218,8 +255,12 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_kind", "owned_chain_strike");
|
||||
evidence.put("chain_id", attempt.chainId.toString());
|
||||
evidence.put("selected_chain_size", attempt.selectedChainSize);
|
||||
evidence.put("total_defined_pool", decimal(attempt.totalDefined));
|
||||
evidence.put("equal_defined_share", decimal(attempt.definedShare));
|
||||
evidence.put("hop_ordinal", attempt.ordinal);
|
||||
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
|
||||
evidence.put("defined_amount", decimal(attempt.definedShare));
|
||||
evidence.put("current_target_id", attempt.target.getUniqueId().toString());
|
||||
if (attempt.previousId != null) {
|
||||
evidence.put("previous_target_id", attempt.previousId.toString());
|
||||
evidence.put("previous_target_location", attempt.previousLocation);
|
||||
@@ -255,16 +296,10 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
|
||||
private static double finite(BigDecimal value) {
|
||||
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
|
||||
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
|
||||
}
|
||||
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("target-int-step").toBigIntegerExact(), p.get("max-targets").intValueExact());
|
||||
return new Settings(definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("max-targets").intValueExact());
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
|
||||
@@ -151,7 +151,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
|
||||
List.of("Launch a frost projectile with a " + BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString() + "-block travel limit.",
|
||||
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
|
||||
"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();
|
||||
|
||||
@@ -623,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;
|
||||
@@ -631,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);}
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -94,6 +94,11 @@ public final class AbilityCastEventBridge {
|
||||
"native_velocity_status"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("chain_lightning")) {
|
||||
for (String key : java.util.List.of("chain_id", "selected_chain_size", "total_defined_pool",
|
||||
"equal_defined_share"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public final class PartyManagement implements AutoCloseable {
|
||||
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
|
||||
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public Status accessStatus(Player player,UUID registrarId,UUID character,UUID session){var registrar=registrars.find(registrarId).orElse(null);if(!parties.ready()||!recovered||registrar==null)return Status.INVALID_SESSION;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||!record.id().equals(character)||!active.session().equals(session))return Status.INVALID_SESSION;var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Status.TOO_FAR;double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();return dx*dx+dy*dy+dz*dz>36?Status.TOO_FAR:Status.APPLIED;}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
|
||||
private CharacterParty partyForInvitation(UUID invitation){return parties.all().stream().filter(party->party.invitations().stream().anyMatch(value->value.id().equals(invitation))).findFirst().orElse(null);}
|
||||
private games.dmg.fantasystick.events.PartyEventBridge.Action audit(Player player,Context context,String kind,UUID target,UUID invitation,CharacterParty before,BigInteger charge,UUID operation){return audit(player,context,kind,target,invitation,before,charge,operation,"requested");}
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.inventory.PrepareAnvilEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
@@ -19,17 +21,24 @@ import org.bukkit.plugin.Plugin;
|
||||
public final class PartyRegistrarMenus implements Listener,AutoCloseable {
|
||||
private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS}
|
||||
private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
|
||||
private record Submission(UUID player,UUID registrar,UUID character,UUID session){}
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private final Set<Submission> submissions=new HashSet<>();private final Map<Inventory,Holder> nameScreens=new IdentityHashMap<>();private boolean closed;
|
||||
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
|
||||
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
|
||||
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);}
|
||||
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var opened=org.bukkit.inventory.MenuType.ANVIL.builder().title(net.kyori.adventure.text.Component.text("Choose party name")).location(player.getLocation()).checkReachable(false).build(player);var inventory=opened.getTopInventory();nameScreens.put(inventory,new Holder(registrar,character,session,Screen.NAME,0));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));free(opened);try{player.openInventory(opened);if(player.getOpenInventory()!=opened)nameScreens.remove(inventory);}catch(RuntimeException failure){nameScreens.remove(inventory);throw failure;}}
|
||||
private ItemStack item(Material material,String name,String action,UUID target){return item(material,name,action,target,List.of());}
|
||||
private ItemStack item(Material material,String name,String action,UUID target,List<net.kyori.adventure.text.Component> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void prepare(PrepareAnvilEvent event){var holder=holder(event.getInventory());if(closed||holder==null||holder.screen()!=Screen.NAME)return;var source=event.getInventory().getItem(0);if(!action(source,"name")){event.setResult(null);return;}var result=source.clone();var meta=result.getItemMeta();String name=event.getView().getRenameText();meta.customName(net.kyori.adventure.text.Component.text(name==null||name.isBlank()?"Submit party name":name));result.setItemMeta(meta);event.setResult(result);free(event.getView());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getWhoClicked() instanceof Player player))return;var holder=holder(event.getInventory());if(holder==null)return;event.setCancelled(true);if(closed)return;var access=management.accessStatus(player,holder.registrar(),holder.character(),holder.session());if(access!=PartyManagement.Status.APPLIED){player.sendMessage(access==PartyManagement.Status.TOO_FAR?"Party action refused: too far from the Party Registrar.":"Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){if(!(event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil)||!action(event.getCurrentItem(),"name")){player.sendMessage("Party action refused: the name result is unavailable.");return;}var submission=new Submission(player.getUniqueId(),holder.registrar(),holder.character(),holder.session());if(!submissions.add(submission)){player.sendMessage("Party action refused: submission already in progress.");return;}String name=anvil.getRenameText();try{management.register(player,holder.registrar(),name==null?"":name,outcome->{submissions.remove(submission);respond(player,holder.registrar(),outcome,"Registered party.");});}catch(RuntimeException failure){submissions.remove(submission);throw failure;}return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(holder(event.getInventory())!=null)event.setCancelled(true);}
|
||||
@EventHandler public void close(InventoryCloseEvent event){clearName(event.getInventory());}
|
||||
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
|
||||
private Holder holder(Inventory inventory){return inventory.getHolder() instanceof Holder holder?holder:nameScreens.get(inventory);}
|
||||
private void clearName(Inventory inventory){if(nameScreens.remove(inventory)==null)return;for(int slot:new int[]{0,2})if(action(inventory.getItem(slot),"name"))inventory.setItem(slot,null);}
|
||||
private boolean action(ItemStack item,String expected){return item!=null&&item.hasItemMeta()&&expected.equals(item.getItemMeta().getPersistentDataContainer().get(actionKey,PersistentDataType.STRING));}
|
||||
private static void free(org.bukkit.inventory.view.AnvilView anvil){anvil.setRepairItemCountCost(0);anvil.setRepairCost(0);anvil.setMaximumRepairCost(Integer.MAX_VALUE);anvil.setBypassCost(true);}
|
||||
private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
@Override public void close(){closed=true;}
|
||||
@Override public void close(){closed=true;submissions.clear();for(var inventory:List.copyOf(nameScreens.keySet()))clearName(inventory);}
|
||||
}
|
||||
@@ -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(); }
|
||||
|
||||
@@ -28,17 +28,13 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
/** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */
|
||||
public final class ChainLightningSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "chain-lightning-base-damage";
|
||||
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
|
||||
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
|
||||
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";
|
||||
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 List<String> KEYS = List.of(COOLDOWN, INITIAL_RANGE, JUMP_RANGE, MAX_TARGETS);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "4", MULTIPLIER, "0.75", INT_STEP, "5", MAX_TARGETS, "12");
|
||||
COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", MAX_TARGETS, "20");
|
||||
|
||||
@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,10 +108,9 @@ 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(INT_STEP)).toBigIntegerExact(), new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
|
||||
try { return new ChainLightning.Settings(seconds(values.get(COOLDOWN)),
|
||||
finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(MAX_TARGETS)).intValueExact()); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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) {
|
||||
@@ -88,6 +89,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -484,6 +486,7 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** 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())
|
||||
@@ -1010,6 +1013,69 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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(),
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -107,6 +107,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.events.SwitchConfigEventBridge switchConfigEvents;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PathSpeedSettings pathSpeedSettings;
|
||||
private games.dmg.fantasystick.passive.PathSpeed pathSpeed;
|
||||
public void attachPathSpeed(games.dmg.fantasystick.persistence.PathSpeedSettings settings, games.dmg.fantasystick.passive.PathSpeed speed) {
|
||||
if (closed || pathSpeedSettings != null) throw new IllegalStateException("Road settings already attached or command closed");
|
||||
pathSpeedSettings = Objects.requireNonNull(settings); pathSpeed = Objects.requireNonNull(speed);
|
||||
}
|
||||
private games.dmg.fantasystick.events.RoleplayAvailabilityEventBridge roleplayAvailabilityEvents;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.events.PartyCapacityEventBridge partyCapacityEvents;
|
||||
@@ -1340,6 +1346,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
|
||||
if (pathSpeedSettings != null && games.dmg.fantasystick.persistence.PathSpeedSettings.keys().contains(args[1])) { pathConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (partyEconomySettings != null && games.dmg.fantasystick.persistence.PartyEconomySettings.keys().contains(args[1])) { partyEconomyConfiguration(sender,args); return; }
|
||||
if(gearSettings!=null&&games.dmg.fantasystick.persistence.GearSettings.keys().contains(args[1])){gearConfiguration(sender,args);return;}
|
||||
@@ -2532,6 +2539,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void pathConfiguration(CommandSender sender, String[] args) {
|
||||
var action = adminAudit == null ? null : adminAudit.current();
|
||||
String key = args[1]; var before = pathSpeedSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
adminConfigRead(action, key, before, null); sender.sendMessage(key + "=" + before); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PathSpeedSettings.Update> operation;
|
||||
try { operation = pathSpeedSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { adminConfigRefused(action, key); sender.sendMessage(invalid.getMessage()); return; }
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-path-speed", failure);
|
||||
if (closed) { adminConfigInterrupted(action, key, failure); return; }
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) { adminConfigInterrupted(action, key, failure); return; }
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
|
||||
adminConfigInterrupted(action, key, failure); return;
|
||||
}
|
||||
boolean applied = failure == null && pathSpeedSettings.publish(saved);
|
||||
if (applied) pathSpeed.configure(pathSpeedSettings.settings());
|
||||
adminConfigSaved(action, key, before, pathSpeedSettings.value(key), failure, applied, null);
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (failure != null) sender.sendMessage("Road configuration save failed; effective policy unchanged.");
|
||||
else if (!applied) sender.sendMessage("Saved request superseded; effective " + key + "=" + pathSpeedSettings.value(key));
|
||||
else sender.sendMessage("Saved " + key + "=" + pathSpeedSettings.value(key)
|
||||
+ "; subsequent road grants use this policy; existing finite leases keep their remaining budget.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); adminConfigInterrupted(action, key, failure); }
|
||||
});
|
||||
}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){
|
||||
var configAction = adminAudit == null ? null : adminAudit.current();
|
||||
UUID configReadOperation = UUID.randomUUID();
|
||||
@@ -2782,6 +2818,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
keys.addAll(games.dmg.fantasystick.persistence.SanctuarySettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.WarBannerSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.MeteorSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.PathSpeedSettings.keys());
|
||||
return java.util.List.copyOf(keys);
|
||||
}
|
||||
|
||||
@@ -2858,6 +2895,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return matching(configurationKeys(), args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
if (pathSpeedSettings != null && games.dmg.fantasystick.persistence.PathSpeedSettings.keys().contains(args[1])) {
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PathSpeedSettings.ENABLED)) return matching(List.of("false", "true"), args[2]);
|
||||
String current = pathSpeedSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.PathSpeedSettings.defaultValue(args[1]);
|
||||
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
|
||||
}
|
||||
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -73,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<>();
|
||||
@@ -379,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);
|
||||
@@ -608,19 +624,22 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (hasRemnants(attempt.player) || !gameModes.available(attempt.player)
|
||||
|| vitals != null && !vitals.available(attempt.player)) return false;
|
||||
var marker = EquipmentSessionMarker.read(attempt.player).orElse(null);
|
||||
if (EquipmentSessionMarker.present(attempt.player)) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
if (marker == null || marker.phase() != EquipmentSessionMarker.Phase.RESTORED
|
||||
|| state == null || state.pending() != null || exit == null
|
||||
|| !marker.matches(exit.sessionId(), exit.characterId())) return false;
|
||||
}
|
||||
if (EquipmentSessionMarker.present(attempt.player) && !completedRestoredMarker(marker, state)) return false;
|
||||
attempt.entryMarker = marker;
|
||||
attempt.markerValidated = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean completedRestoredMarker(EquipmentSessionMarker.Token marker,
|
||||
EquipmentSessionStore.State state) {
|
||||
// A terminal marker is resolved only by its matching completed file acknowledgment.
|
||||
// Keep it until the next successful entry; worker acknowledgments never touch PDC.
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
return marker != null && marker.phase() == EquipmentSessionMarker.Phase.RESTORED
|
||||
&& state != null && state.pending() == null && exit != null
|
||||
&& marker.matches(exit.sessionId(), exit.characterId());
|
||||
}
|
||||
|
||||
private static boolean hasRemnants(Player player) {
|
||||
return Arrays.stream(player.getInventory().getContents()).anyMatch(EquipmentSessions::remnant)
|
||||
|| remnant(player.getItemOnCursor());
|
||||
@@ -765,6 +784,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); }
|
||||
@@ -1923,7 +1943,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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)) {
|
||||
else if (hasRemnants(attempt.player)
|
||||
|| (EquipmentSessionMarker.present(attempt.player)
|
||||
&& !completedRestoredMarker(EquipmentSessionMarker.read(attempt.player).orElse(null), state.orElse(null)))) {
|
||||
notice(attempt.owner, "NATIVE_RECOVERY_REQUIRED", "Native equipment remnants have no pending receipt; items were retained for inspection.");
|
||||
}
|
||||
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
|
||||
|
||||
@@ -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
|
||||
@@ -206,16 +212,13 @@ frost-nova-radius-blocks: 5
|
||||
frost-nova-slowness-amplifier: 1
|
||||
frost-nova-slowness-duration-seconds: 3
|
||||
|
||||
# Chain Lightning defaults from the reviewed remaining-development packet.
|
||||
# Chain Lightning effective settings. Damage is always four times the purchased-INT modifier,
|
||||
# divided equally across the discovered chain. Obsolete damage/progression keys are ignored.
|
||||
# Admin command: /fstickadmin config <key> [value]
|
||||
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: 4
|
||||
chain-lightning-jump-multiplier: 0.75
|
||||
chain-lightning-target-int-step: 5
|
||||
chain-lightning-max-targets: 12
|
||||
chain-lightning-max-targets: 20
|
||||
|
||||
# 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
|
||||
|
||||
+4
-3
@@ -20,7 +20,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
|
||||
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), List.of(PartySettings.KEY), PartyEconomySettings.keys(),
|
||||
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),
|
||||
@@ -35,7 +35,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
|
||||
private void seed() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"),
|
||||
"retained: untouched\nfrost-shard-range-blocks: 48\nadventure-mode: true\nroleplay-disabled: false\n");
|
||||
"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"); }
|
||||
@@ -150,6 +150,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
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"},
|
||||
@@ -165,7 +166,7 @@ class AdminActionConfigurationCatalogNativeTest {
|
||||
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[]{"frost-nova-radius-blocks", "6"}, new String[]{"chain-lightning-max-targets", "19"},
|
||||
new String[]{"sanctuary-radius-blocks", "7"}, new String[]{"war-banner-radius-blocks", "9"},
|
||||
new String[]{"meteor-base-damage", "15"});
|
||||
|
||||
|
||||
@@ -43,17 +43,13 @@ class ChainLightningCoverageNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
static Stream<Arguments> coverage() {
|
||||
return Stream.of(
|
||||
Arguments.of("20", "", 5, false), Arguments.of("24", "", 5, false),
|
||||
Arguments.of("25", "", 6, false), Arguments.of("29", "", 6, false),
|
||||
Arguments.of("30", "", 7, false), Arguments.of("34", "", 7, false),
|
||||
Arguments.of("35", "", 8, false), Arguments.of("39", "", 8, false),
|
||||
Arguments.of("54", "", 11, false), Arguments.of("55", "", 12, true),
|
||||
Arguments.of(BigInteger.TEN.pow(500).toString(), "", 12, false),
|
||||
Arguments.of("30", "chain-lightning-additional-targets: 3\n", 6, false),
|
||||
Arguments.of("30", "chain-lightning-target-int-step: 2\n", 10, false),
|
||||
Arguments.of("55", "chain-lightning-max-targets: 2\n", 2, false),
|
||||
Arguments.of("25", "chain-lightning-additional-targets: 0\n", 2, false),
|
||||
Arguments.of("20", "chain-lightning-additional-targets: 63\nchain-lightning-max-targets: 64\n", 64, false));
|
||||
Arguments.of("20", "chain-lightning-max-targets: 1\n", 1, false),
|
||||
Arguments.of("20", "chain-lightning-max-targets: 3\n", 3, false),
|
||||
Arguments.of("20", "", 20, true),
|
||||
Arguments.of("30", "chain-lightning-max-targets: 5\n", 5, false),
|
||||
Arguments.of("30", "chain-lightning-max-targets: 2\n", 2, false),
|
||||
Arguments.of("20", "chain-lightning-base-damage: 999\nchain-lightning-additional-targets: 0\n"
|
||||
+ "chain-lightning-jump-multiplier: 0.01\nchain-lightning-target-int-step: 1\n", 20, false));
|
||||
}
|
||||
@ParameterizedTest @MethodSource("coverage")
|
||||
void purchasedIntAndCapturedSettingsProduceActualUniqueHopsAndTruthfulDamage(
|
||||
@@ -61,7 +57,7 @@ class ChainLightningCoverageNativeTest {
|
||||
exercise(purchasedInt, retained, expected, cancelThird, false);
|
||||
}
|
||||
@Test void pendingCastKeepsCapturedPolicyWhileAcknowledgedAdminUpdateAffectsOnlyNextCast() throws Exception {
|
||||
exercise("55", "", 12, false, true);
|
||||
exercise("30", "", 20, false, true);
|
||||
}
|
||||
private void exercise(String purchasedInt, String retained, int expected, boolean cancelThird, boolean changeDuringSave) throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-cooldown-seconds: 0\n" + retained);
|
||||
@@ -74,7 +70,7 @@ class ChainLightningCoverageNativeTest {
|
||||
var capturedDefinition = ((AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry")).find("chain_lightning").orElseThrow();
|
||||
PartyBookNativeTest.click(f, caster, learn(caster)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var targets = new ArrayList<LivingEntity>();
|
||||
for (int index = 0; index < Math.max(13, expected + 1); index++) {
|
||||
for (int index = 0; index < Math.max(21, expected + 1); index++) {
|
||||
var target = f.own(mock(LivingEntity.class)); targets.add(target);
|
||||
when(target.getUniqueId()).thenReturn(new UUID(0, index + 1)); when(target.getName()).thenReturn("Coverage " + index);
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.HUSK);
|
||||
@@ -132,11 +128,14 @@ class ChainLightningCoverageNativeTest {
|
||||
assertEquals(expected - (cancelThird ? 1 : 0), applied.size());
|
||||
verify(targets.get(expected), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
var parameters = capturedDefinition.parameters();
|
||||
int base = Math.min(parameters.get("max-targets").intValueExact(), 1 + parameters.get("additional-targets").intValueExact());
|
||||
// BookPagination wraps by glyph width, including within words; preserve all actual text/numbers.
|
||||
assertTrue(book.contains(base + "basetotaltargetsatINT20"), "book must explain the actual captured base");
|
||||
assertTrue(book.contains("per" + parameters.get("target-int-step").toPlainString() + "purchasedINTabove20"));
|
||||
assertTrue(book.contains("cappedat" + parameters.get("max-targets").toPlainString() + "totaltargets"));
|
||||
assertEquals(expected, parameters.get("max-targets").intValueExact());
|
||||
assertEquals(new BigDecimal("4"), parameters.get("int-modifier-damage-factor"));
|
||||
assertFalse(parameters.keySet().stream().anyMatch(key -> List.of("base-damage", "additional-targets",
|
||||
"jump-multiplier", "target-int-step").contains(key)));
|
||||
// BookPagination wraps by glyph width, including within words; preserve the effective formula and ceiling.
|
||||
assertTrue(book.contains("fourtimesyourpurchasedINTmodifier"));
|
||||
assertTrue(book.contains("divide thatpoolequally".replace(" ", "")));
|
||||
assertTrue(book.contains("upto" + expected + "targets"));
|
||||
var saved = new YamlCharacterRepository(data).load(caster.core.record.id()).orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), saved.unspentPoints()); assertTrue(saved.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", saved.loadout().getFirst()); assertNotNull(saved.cooldowns().get("chain_lightning"));
|
||||
@@ -145,17 +144,28 @@ class ChainLightningCoverageNativeTest {
|
||||
catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
var hits = rows("combat.damage"); assertEquals(applied.size(), hits.size());
|
||||
var total = new BigDecimal(ChainLightning.totalDefinedDamage(new BigInteger(purchasedInt)));
|
||||
var share = total.divide(BigDecimal.valueOf(expected), java.math.MathContext.DECIMAL128);
|
||||
assertTrue(attempted.values().stream().allMatch(value -> Math.abs(value - share.doubleValue()) < 1e-9));
|
||||
for (var row : hits) {
|
||||
var facts = row.getAsJsonObject("data"); int ordinal = facts.get("hop_ordinal").getAsInt();
|
||||
var defined = new BigDecimal(ChainLightning.initialDamage(new BigInteger(purchasedInt)))
|
||||
.multiply(new BigDecimal("0.75").pow(ordinal - 1));
|
||||
assertEquals(defined.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
|
||||
assertEquals(total.stripTrailingZeros().toPlainString(), facts.get("total_defined_pool").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("equal_defined_share").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), facts.get("defined_amount").getAsString());
|
||||
assertEquals(expected, facts.get("selected_chain_size").getAsInt());
|
||||
assertEquals(targets.get(ordinal - 1).getUniqueId().toString(), facts.get("current_target_id").getAsString());
|
||||
assertEquals(applied.get(targets.get(ordinal - 1).getUniqueId()), facts.get("actual_amount").getAsDouble(), 1e-9);
|
||||
assertTrue(facts.has("target_location")); if (ordinal > 1) assertTrue(facts.has("previous_target_id"));
|
||||
if (cancelThird) assertNotEquals(3, ordinal);
|
||||
}
|
||||
assertEquals(hits.size(), hits.stream().map(row -> row.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
var cast = rows("ability.cast").getFirst().getAsJsonObject("data");
|
||||
var resolved = rows("ability.resolved").getFirst().getAsJsonObject("data");
|
||||
for (var evidence : List.of(cast, resolved)) {
|
||||
assertEquals(expected, evidence.get("selected_chain_size").getAsInt());
|
||||
assertEquals(total.stripTrailingZeros().toPlainString(), evidence.get("total_defined_pool").getAsString());
|
||||
assertEquals(share.stripTrailingZeros().toPlainString(), evidence.get("equal_defined_share").getAsString());
|
||||
}
|
||||
assertEquals(expected, resolved.get("attempted_hops").getAsInt()); assertEquals(applied.size(), resolved.get("accepted_hops").getAsInt());
|
||||
assertEquals(1, rows("ability.cast").size(), "duplicate pending input never starts another business cast");
|
||||
if (changeDuringSave) {
|
||||
@@ -175,47 +185,55 @@ class ChainLightningCoverageNativeTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void newConfigurationKeysUseRegisteredPermissionCheckedDurableReadUpdateCompletionAndReload() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-additional-targets: 3\nunrelated: retained\n");
|
||||
@Test void effectiveConfigurationUsesRegisteredDurableSurfaceAndPreservesIgnoredLegacyKeys() throws Exception {
|
||||
Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\nchain-lightning-base-damage: 999\n"
|
||||
+ "chain-lightning-additional-targets: 3\nchain-lightning-jump-multiplier: 0.25\n"
|
||||
+ "chain-lightning-target-int-step: 1\nunrelated: retained\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "7"});
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
var completions = f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"});
|
||||
assertTrue(completions.containsAll(List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")), "new keys must be wired through real command completion");
|
||||
assertEquals(List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
|
||||
ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS), completions);
|
||||
assertTrue(completions.stream().noneMatch(key -> List.of("chain-lightning-base-damage",
|
||||
"chain-lightning-additional-targets", "chain-lightning-jump-multiplier",
|
||||
"chain-lightning-target-int-step").contains(key)));
|
||||
var store = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
|
||||
assertEquals("3", store.value(ChainLightningSettings.ADDITIONAL), "retained explicit base is never silently replaced");
|
||||
for (var entry : Map.of("chain-lightning-target-int-step", "7", "chain-lightning-max-targets", "2").entrySet()) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", entry.getKey(), entry.getValue()});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertEquals(entry.getValue(), store.value(entry.getKey()));
|
||||
}
|
||||
assertEquals(List.of("5", "7"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "2", "extra"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step"});
|
||||
assertTrue(sender.messages.getLast().contains("=7"));
|
||||
assertEquals("20", store.value(ChainLightningSettings.MAX_TARGETS));
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS));
|
||||
assertEquals(List.of("2", "20"), f.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, ""}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, "2", "extra"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS});
|
||||
assertTrue(sender.messages.getLast().contains("=2"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-target-int-step", "1"});
|
||||
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", ChainLightningSettings.MAX_TARGETS, "1"});
|
||||
callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
|
||||
}
|
||||
assertEquals("7", store.value("chain-lightning-target-int-step")); assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
for (String[] invalid : List.of(new String[]{"chain-lightning-target-int-step", "0"}, new String[]{"chain-lightning-target-int-step", "1.5"},
|
||||
new String[]{"chain-lightning-max-targets", "0"}, new String[]{"chain-lightning-max-targets", "65"})) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", invalid[0], invalid[1]}); assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
assertEquals("2", store.value(ChainLightningSettings.MAX_TARGETS)); assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
for (String invalid : List.of("0", "21", "1.5")) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", ChainLightningSettings.MAX_TARGETS, invalid});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
}
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("retained"));
|
||||
String savedFile = Files.readString(data.resolve("config.yml"));
|
||||
assertTrue(savedFile.contains("retained")); assertTrue(savedFile.contains("chain-lightning-base-damage"));
|
||||
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("7"), registry.find("chain_lightning").orElseThrow().parameters().get("target-int-step"));
|
||||
assertEquals(new BigDecimal("2"), registry.find("chain_lightning").orElseThrow().parameters().get("max-targets"));
|
||||
var parameters = registry.find("chain_lightning").orElseThrow().parameters();
|
||||
assertEquals(new BigDecimal("2"), parameters.get("max-targets"));
|
||||
assertFalse(parameters.containsKey("base-damage")); assertFalse(parameters.containsKey("target-int-step"));
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return configRows().size() == 3; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
try { return configRows().size() == 2; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
var restarted = (ChainLightningSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "chainLightningSettings");
|
||||
assertEquals("7", restarted.value("chain-lightning-target-int-step")); assertEquals("2", restarted.value("chain-lightning-max-targets"));
|
||||
assertEquals("3", restarted.value(ChainLightningSettings.ADDITIONAL));
|
||||
assertEquals("2", restarted.value(ChainLightningSettings.MAX_TARGETS));
|
||||
}
|
||||
}
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor, BigInteger intelligence) throws Exception {
|
||||
@@ -240,6 +258,7 @@ class ChainLightningCoverageNativeTest {
|
||||
}
|
||||
private List<com.google.gson.JsonObject> configRows() throws Exception {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("config.read") || row.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(row -> row.getAsJsonObject("data").has("key") && List.of("chain-lightning-target-int-step", "chain-lightning-max-targets").contains(row.getAsJsonObject("data").get("key").getAsString())).toList();
|
||||
.filter(row -> row.getAsJsonObject("data").has("key")
|
||||
&& ChainLightningSettings.MAX_TARGETS.equals(row.getAsJsonObject("data").get("key").getAsString())).toList();
|
||||
}
|
||||
}
|
||||
@@ -59,9 +59,9 @@ class ChainLightningNativeTest {
|
||||
assertEquals(2, viewer.particleCalls.stream().filter(call -> call[0] == org.bukkit.Particle.ELECTRIC_SPARK
|
||||
&& call[2].equals(18)).count(), "An enabled nearby viewer retains every chain burst");
|
||||
assertFalse(damage.isEmpty(), caster.messages.toString());
|
||||
assertEquals(13, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(9.75, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to Zombie."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Skeleton."::equals).count());
|
||||
assertEquals(10, damage.get(first.getUniqueId()), 1.0e-10); assertEquals(10, damage.get(second.getUniqueId()), 1.0e-10);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Zombie."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Skeleton."::equals).count());
|
||||
var stored = ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(caster.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
@@ -76,8 +76,10 @@ class ChainLightningNativeTest {
|
||||
"The generic native observer must not repeat feature-owned chain hits");
|
||||
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 10));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("selected_chain_size").getAsInt() == 2));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("total_defined_pool").getAsString().equals("20")));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").get("equal_defined_share").getAsString().equals("10")));
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
|
||||
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
@@ -127,7 +129,8 @@ class ChainLightningNativeTest {
|
||||
}
|
||||
var records = events(data); var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(new BigDecimal("20").divide(BigDecimal.valueOf(3), java.math.MathContext.DECIMAL128).toPlainString(),
|
||||
hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
|
||||
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
|
||||
}
|
||||
@@ -170,12 +173,12 @@ class ChainLightningNativeTest {
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, events.size()); assertTrue(events.stream().allMatch(EntityDamageEvent::isCancelled));
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 13 damage to First Target."::equals).count(), caster.messages::toString);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 9.75 damage to Second Target."::equals).count());
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to First Target."::equals).count(), caster.messages::toString);
|
||||
assertEquals(1, caster.messages.stream().filter("Chain Lightning did 10 damage to Second Target."::equals).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void exactChainUsesNearestUuidTiesFiveTargetLimitDecayAndCancellationContinuation() {
|
||||
@Test void completeDeterministicChainPrecedesOneEqualFractionalDamageSharePerSelectedTarget() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); UUID casterId = UUID.randomUUID(); when(caster.getUniqueId()).thenReturn(casterId);
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
@@ -198,10 +201,69 @@ class ChainLightningNativeTest {
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
|
||||
chain.activate(execution);
|
||||
assertEquals(targets.subList(0, 5).stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375, 4.11328125}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
verify(targets.get(5), never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(4)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
assertEquals(targets.stream().map(LivingEntity::getUniqueId).toList(), List.copyOf(attempted.keySet()));
|
||||
assertArrayEquals(new double[]{20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6, 20d / 6},
|
||||
attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
|
||||
var captureAndDiscovery = new ArrayList<Object>(); captureAndDiscovery.add(execution); captureAndDiscovery.addAll(targets);
|
||||
var discovery = inOrder(captureAndDiscovery.toArray());
|
||||
discovery.verify(execution).character();
|
||||
for (var target : targets) discovery.verify(target).getNearbyEntities(6, 6, 6);
|
||||
discovery.verify(targets.getFirst()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution, times(5)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
|
||||
}
|
||||
|
||||
@Test void newlyInvalidReservedShareIsNotRedistributedOrReplayed() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var first = mock(LivingEntity.class); var invalidated = mock(LivingEntity.class); var third = mock(LivingEntity.class);
|
||||
locate(first, world, new UUID(0, 1), 10); locate(invalidated, world, new UUID(0, 2), 14); locate(third, world, new UUID(0, 3), 18);
|
||||
when(caster.hasLineOfSight(first)).thenReturn(true); when(first.hasLineOfSight(invalidated)).thenReturn(true);
|
||||
when(invalidated.hasLineOfSight(third)).thenReturn(true);
|
||||
when(first.getNearbyEntities(6, 6, 6)).thenReturn(List.of(invalidated));
|
||||
when(invalidated.getNearbyEntities(6, 6, 6)).thenReturn(List.of(third));
|
||||
var becameInvalid = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
when(invalidated.isDead()).thenAnswer(call -> becameInvalid.get());
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, first);
|
||||
var chain = new ChainLightning(new ChainLightning.Settings(java.time.Duration.ofSeconds(18), 16, 6, 3), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var applied = new LinkedHashMap<UUID, Double>();
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); applied.put(first.getUniqueId(), amount);
|
||||
chain.acceptedDamage(new EntityDamageEvent(first, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); becameInvalid.set(true); return null;
|
||||
}).when(first).damage(anyDouble(), any(DamageSource.class));
|
||||
doAnswer(call -> {
|
||||
double amount = call.getArgument(0); applied.put(third.getUniqueId(), amount);
|
||||
chain.acceptedDamage(new EntityDamageEvent(third, EntityDamageEvent.DamageCause.LIGHTNING,
|
||||
(DamageSource) call.getArgument(1), amount)); return null;
|
||||
}).when(third).damage(anyDouble(), any(DamageSource.class));
|
||||
chain.activate(execution);
|
||||
verify(invalidated, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
assertEquals(List.of(first.getUniqueId(), third.getUniqueId()), List.copyOf(applied.keySet()));
|
||||
assertTrue(applied.values().stream().allMatch(value -> Math.abs(value - 20d / 3) < 1e-10));
|
||||
verify(execution).resolve(eq("hit"), argThat(evidence -> evidence.get("selected_chain_size").equals(3)
|
||||
&& evidence.get("attempted_hops").equals(2) && evidence.get("accepted_hops").equals(2)));
|
||||
}
|
||||
|
||||
@Test void nonFiniteNativeShareIsRefusedBeforeAnyTargetDamage() {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var caster = mock(Player.class);
|
||||
var world = mock(org.bukkit.World.class); when(caster.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(caster.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(caster.getEyeLocation()).thenReturn(new Location(world, 0, 65, 0));
|
||||
var target = mock(LivingEntity.class); locate(target, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(target)).thenReturn(true);
|
||||
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
|
||||
var execution = execution(caster, active, target); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
|
||||
when(execution.definition()).thenReturn(chain.definition());
|
||||
var ordinary = execution.character(); var stats = new EnumMap<>(ordinary.stats()); stats.put(Stat.INT, BigInteger.TEN.pow(500));
|
||||
when(execution.character()).thenReturn(new CharacterRecord(ordinary.id(), ordinary.name(), ordinary.revision(), stats,
|
||||
ordinary.level(), ordinary.experience(), ordinary.unspentPoints(), ordinary.health(), ordinary.learned(), ordinary.loadout(),
|
||||
ordinary.cooldowns(), ordinary.restrictedKills(), false, null, null));
|
||||
chain.activate(execution);
|
||||
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
|
||||
verify(execution).resolve(eq("failed"), argThat(evidence -> evidence.get("terminal_reason").equals("native_damage_not_finite")
|
||||
&& evidence.get("attempted_hops").equals(0) && evidence.get("accepted_hops").equals(0)));
|
||||
verify(execution, never()).acceptedDamage(any(), anyDouble(), anyDouble(), any(), anyMap());
|
||||
}
|
||||
|
||||
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
|
||||
@@ -244,47 +306,55 @@ class ChainLightningNativeTest {
|
||||
var definition = chain.definition(); assertEquals("chain_lightning", definition.id()); assertEquals(org.bukkit.Material.LIGHTNING_ROD, definition.icon());
|
||||
assertEquals(java.time.Duration.ofSeconds(18), definition.cooldown()); assertEquals(BigInteger.ONE, definition.pointCost());
|
||||
assertEquals(BigInteger.valueOf(20), definition.requirements().get(Stat.INT)); assertEquals(BigInteger.valueOf(12), definition.requirements().get(Stat.DEX));
|
||||
assertEquals(BigInteger.valueOf(13), ChainLightning.initialDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(7), ChainLightning.initialDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500); assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(8)), ChainLightning.initialDamage(huge));
|
||||
assertEquals(BigInteger.valueOf(5), ChainLightning.intelligenceModifier(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(20), ChainLightning.totalDefinedDamage(BigInteger.valueOf(20)));
|
||||
assertEquals(BigInteger.valueOf(40), ChainLightning.totalDefinedDamage(BigInteger.valueOf(30)));
|
||||
assertEquals(BigInteger.valueOf(-1), ChainLightning.intelligenceModifier(BigInteger.valueOf(9)));
|
||||
assertEquals(BigInteger.valueOf(-4), ChainLightning.totalDefinedDamage(BigInteger.valueOf(9)));
|
||||
var huge = BigInteger.TEN.pow(500);
|
||||
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).multiply(BigInteger.valueOf(4)),
|
||||
ChainLightning.totalDefinedDamage(huge));
|
||||
assertEquals(new BigDecimal("4"), definition.parameters().get("int-modifier-damage-factor"));
|
||||
assertEquals(new BigDecimal("20"), definition.parameters().get("max-targets"));
|
||||
assertFalse(definition.parameters().containsKey("base-damage"));
|
||||
assertFalse(definition.parameters().containsKey("jump-multiplier"));
|
||||
}
|
||||
|
||||
@Test void pluginLoadsEverySettingAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 9\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\nchain-lightning-jump-multiplier: 0.5\n");
|
||||
@Test void pluginLoadsEffectiveSettingsAndAdminPublishesOnlyAfterDurableSave() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-base-damage: 999\nchain-lightning-cooldown-seconds: 4\n"
|
||||
+ "chain-lightning-initial-range-blocks: 15\nchain-lightning-jump-range-blocks: 5\nchain-lightning-additional-targets: 2\n"
|
||||
+ "chain-lightning-jump-multiplier: 0.5\nchain-lightning-target-int-step: 1\nchain-lightning-max-targets: 12\n");
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true); var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var definition = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("9"), definition.parameters().get("base-damage"));
|
||||
var definition = registry.find("chain_lightning").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(4), definition.cooldown());
|
||||
assertEquals(0, new BigDecimal("15").compareTo(definition.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("5").compareTo(definition.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("2"), definition.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.5"), definition.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("12"), definition.parameters().get("max-targets"));
|
||||
assertFalse(definition.parameters().containsKey("base-damage")); assertFalse(definition.parameters().containsKey("jump-multiplier"));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}).isEmpty());
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
|
||||
assertEquals(List.of("chain-lightning-additional-targets", "chain-lightning-base-damage", "chain-lightning-cooldown-seconds",
|
||||
"chain-lightning-initial-range-blocks", "chain-lightning-jump-multiplier", "chain-lightning-jump-range-blocks",
|
||||
"chain-lightning-max-targets", "chain-lightning-target-int-step"),
|
||||
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
|
||||
assertEquals(List.of("chain-lightning-cooldown-seconds", "chain-lightning-initial-range-blocks",
|
||||
"chain-lightning-jump-range-blocks", "chain-lightning-max-targets"),
|
||||
fixture.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-"}));
|
||||
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fstickadmin",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks"});
|
||||
assertTrue(sender.messages.getLast().contains("=5"));
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-base-damage", "10"},
|
||||
new String[]{"chain-lightning-cooldown-seconds", "3"}, new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"}, new String[]{"chain-lightning-additional-targets", "1"},
|
||||
new String[]{"chain-lightning-jump-multiplier", "0.25"})) {
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", update[0], update[1]});
|
||||
for (String[] update : List.of(new String[]{"chain-lightning-cooldown-seconds", "3"},
|
||||
new String[]{"chain-lightning-initial-range-blocks", "14"},
|
||||
new String[]{"chain-lightning-jump-range-blocks", "7"},
|
||||
new String[]{"chain-lightning-max-targets", "3"})) {
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", update[0], update[1]});
|
||||
var response = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
}
|
||||
var configured = registry.find("chain_lightning").orElseThrow(); assertEquals(new BigDecimal("10"), configured.parameters().get("base-damage"));
|
||||
var configured = registry.find("chain_lightning").orElseThrow();
|
||||
assertEquals(java.time.Duration.ofSeconds(3), configured.cooldown());
|
||||
assertEquals(0, new BigDecimal("14").compareTo(configured.parameters().get("initial-range")));
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("3"), configured.parameters().get("max-targets"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
@@ -297,22 +367,22 @@ class ChainLightningNativeTest {
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
|
||||
.value("chain-lightning-jump-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-max-targets", "21"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
fixture.command.execute(sender.port, "fstickadmin", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
fixture.plugin.setEnabled(false); fixture.plugin.setEnabled(true);
|
||||
var reloaded = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
|
||||
var saved = reloaded.find("chain_lightning").orElseThrow();
|
||||
assertEquals(0, new BigDecimal("7").compareTo(saved.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(new BigDecimal("3"), saved.parameters().get("max-targets"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList();
|
||||
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
|
||||
assertEquals(5, config.size(), "One authorized read plus four acknowledged effective settings, no denied/invalid success");
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
|
||||
@@ -418,6 +418,8 @@ class CharacterBookNativeTest {
|
||||
var categorized = new java.util.LinkedHashMap<String, java.util.List<String>>();
|
||||
var indexPageByAbility = new java.util.HashMap<String, Integer>();
|
||||
int partyPage = firstPageStarting(pages, "Party");
|
||||
assertTrue(firstPageStarting(pages, "Dirt Path travel") > partyPage,
|
||||
"Road guidance must preserve the existing linked detail/inventory/party section boundaries");
|
||||
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
|
||||
var lines = pages.get(page).getString().split("\\n");
|
||||
assertTrue(lines.length >= 2); String category = lines[1];
|
||||
|
||||
@@ -1128,6 +1128,62 @@ class EquipmentSessionEventsNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinAfterCleanExitIsQuietAndPreservesPlayerAndReceiptState() throws Exception {
|
||||
try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
f.core.sessions.exit(a.player));
|
||||
var completed = f.core.state(a);
|
||||
assertNull(completed.pending()); assertNotNull(completed.lastExit());
|
||||
a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD));
|
||||
var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker();
|
||||
var record = f.core.characters.find(a.record.id()).join().orElseThrow();
|
||||
int notices = f.core.notices.size();
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player,
|
||||
(net.kyori.adventure.text.Component) null));
|
||||
f.core.tick();
|
||||
|
||||
assertEquals(notices, f.core.notices.size(),
|
||||
"A matching completed RESTORED marker is clean arrival proof, not a recovery warning");
|
||||
assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor);
|
||||
assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a));
|
||||
assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow());
|
||||
assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void joinWarnsWithoutMutationForUnsafeMarkersAndGenuineRemnants() throws Exception {
|
||||
for (int scenario = 0; scenario < 4; scenario++) try (var f = new Fixture()) {
|
||||
var a = f.actor; f.core.activate(a);
|
||||
var genuineBox = a.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
f.core.sessions.exit(a.player));
|
||||
var completed = f.core.state(a);
|
||||
assertNull(completed.pending()); assertNotNull(completed.lastExit());
|
||||
a.inventory.setItemInOffHand(new ItemStack(Material.EMERALD));
|
||||
if (scenario == 0) a.pdc.set(new org.bukkit.NamespacedKey("fantasystick", "equipment_session"),
|
||||
org.bukkit.persistence.PersistentDataType.STRING, "malformed");
|
||||
if (scenario == 1) a.marker(java.util.UUID.randomUUID(), "RESTORED");
|
||||
if (scenario == 2) a.marker(completed.lastExit().sessionId(), "PACKED");
|
||||
if (scenario == 3) a.inventory.setItem(12, genuineBox);
|
||||
var before = a.snapshot(); var cursor = a.cursor.clone(); var marker = a.marker();
|
||||
var record = f.core.characters.find(a.record.id()).join().orElseThrow();
|
||||
int notices = f.core.notices.size();
|
||||
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player,
|
||||
(net.kyori.adventure.text.Component) null));
|
||||
f.core.tick();
|
||||
|
||||
assertEquals(notices + 1, f.core.notices.size());
|
||||
assertEquals("NATIVE_RECOVERY_REQUIRED", f.core.notices.getLast().code());
|
||||
assertArrayEquals(before, a.snapshot()); assertEquals(cursor, a.cursor);
|
||||
assertEquals(marker, a.marker()); assertEquals(completed, f.core.state(a));
|
||||
assertEquals(record, f.core.characters.find(a.record.id()).join().orElseThrow());
|
||||
assertTrue(f.core.sessions.active(a.id).isEmpty()); assertTrue(f.ticks.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reservedWorldAndInventorySwapsRestoreBeforeExchangingOrdinaryGear() throws Exception {
|
||||
for (int route = 0; route < 3; route++) {
|
||||
try (var f = new Fixture()) {
|
||||
|
||||
@@ -776,10 +776,15 @@ class FantasyParticlesNativeTest {
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString()
|
||||
.matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var files = java.nio.file.Files.list(day)) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -60,8 +60,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") >= 0);
|
||||
assertTrue(fixture.order.indexOf("CharacterChatNames") < recipe);
|
||||
assertNotNull(fixture.packetListener);
|
||||
assertEquals(19, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, armor/rare/adventure/fishing/dungeon/downed refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PathSpeed") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PathSpeed") < recipe);
|
||||
assertEquals(20, fixture.timers.size(),
|
||||
"Presentation, ability/bond/rally/banner cleanup, provenance, reward retry, recovery, passive effects, road travel, armor/rare/adventure/fishing/dungeon/downed refresh and session particles");
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") >= 0);
|
||||
assertTrue(fixture.order.indexOf("PartyCombat") < recipe);
|
||||
assertTrue(fixture.order.indexOf("CombatExperience") >= 0);
|
||||
@@ -1323,7 +1325,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
private static void assertRestored(Fixture f, Actor actor, Actor viewer) {
|
||||
var spawn = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundAddEntityPacket.class).getLast();
|
||||
assertTrue(f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class).stream()
|
||||
.anyMatch(packet -> packet.getEntityIds().contains(spawn.getId())));
|
||||
.anyMatch(packet -> packet.entityIds().contains(spawn.getId())));
|
||||
var tab = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.class).getLast();
|
||||
assertEquals(net.minecraft.network.chat.Component.literal("Ordinary tab"), tab.entries().getFirst().displayName());
|
||||
var attributes = f.packets(viewer.player, net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket.class).getLast();
|
||||
@@ -1430,9 +1432,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> nativeServer;
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
@@ -1466,6 +1468,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "callEvent" -> { dispatcher().accept((org.bukkit.event.Event) args[0]); yield null; }
|
||||
default -> throw new AssertionError("Unexpected manager call " + name);
|
||||
});
|
||||
when(nativeServer.getPluginManager()).thenReturn(manager);
|
||||
platform.when(org.bukkit.Bukkit::getPluginManager).thenReturn(manager);
|
||||
var scheduler = port(BukkitScheduler.class, (name, args) -> switch (name) {
|
||||
case "runTaskTimer" -> {
|
||||
if ("presentation-timer".equals(failRegistrationAt)) throw new IllegalStateException("Timer rejected");
|
||||
@@ -1495,8 +1499,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getLogger" -> Logger.getLogger("FantasyStick lifecycle fixture");
|
||||
case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT;
|
||||
case "getName" -> "Purpur";
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "isPrimaryThread" -> Thread.currentThread() == main;
|
||||
case "getOnlinePlayers" -> List.copyOf(players.values());
|
||||
case "getWorlds" -> players.values().stream().map(org.bukkit.entity.Player::getWorld).distinct().toList();
|
||||
@@ -1618,7 +1622,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertNotNull(actor.core.marker());assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
}
|
||||
private void captureWorkers(JavaPlugin instance) {
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals", "trainingDummies",
|
||||
for (String name : List.of("characters", "receipts", "adventureSettings", "pathSpeedSettings", "revivals", "trainingDummies",
|
||||
"hardMobXpSettings", "adventureQuestSettings", "adventureQuests")) {
|
||||
var store = field(instance, name);
|
||||
if (store != null) {
|
||||
@@ -1701,6 +1705,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
final List<String> actionBars = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
|
||||
final List<org.bukkit.inventory.InventoryView> inventoryViews = new ArrayList<>();
|
||||
org.bukkit.inventory.InventoryView openView;
|
||||
int inventoryCloses;
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<Object[]> particleCalls = new ArrayList<>();
|
||||
@@ -1745,6 +1751,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
when(craftBoard.getHandle()).thenReturn(board);
|
||||
player = own(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class, this));
|
||||
core.location.setWorld(world);
|
||||
when(nativePlayer.getBukkitEntity()).thenReturn(player);
|
||||
when(nativePlayer.getId()).thenReturn(42);
|
||||
when(nativePlayer.getUUID()).thenReturn(core.id);
|
||||
when(nativePlayer.getTabListDisplayName()).thenReturn(net.minecraft.network.chat.Component.literal("Ordinary tab"));
|
||||
@@ -1792,6 +1799,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "getHandle" -> nativePlayer;
|
||||
case "getTicksLived" -> nativePlayer.tickCount;
|
||||
case "getEntityId" -> 42;
|
||||
// Inventory-only actors have no physics support; road cases explicitly supply these ports.
|
||||
case "isOnGround", "isFlying", "isSwimming", "isGliding" -> false;
|
||||
case "getVelocity" -> new org.bukkit.util.Vector();
|
||||
case "getName" -> accountName;
|
||||
case "hasPermission" -> allowed;
|
||||
case "getWorld" -> core.location.getWorld();
|
||||
@@ -1849,8 +1859,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
yield null;
|
||||
}
|
||||
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
|
||||
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
case "getOpenInventory" -> openView == null ? core.view : openView;
|
||||
case "openInventory" -> {
|
||||
if (args[0] instanceof org.bukkit.inventory.Inventory inventory) { inventories.add(inventory); openView=null; yield core.view; }
|
||||
var opened = (org.bukkit.inventory.InventoryView) args[0]; inventoryViews.add(opened);
|
||||
inventories.add(opened.getTopInventory()); openView=opened; yield null;
|
||||
}
|
||||
case "closeInventory" -> { inventoryCloses++; openView=null; yield null; }
|
||||
case "spawnParticle" -> { particleCalls.add(args.clone()); particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
case "showTitle" -> { titles.add((net.kyori.adventure.title.Title)args[0]); yield null; }
|
||||
|
||||
@@ -92,6 +92,14 @@ class FireballNativeTest {
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
|
||||
new java.util.ArrayList<>(), 0));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
// Health Steal's provisional-health barrier may defer the reward read. Drive the actual
|
||||
// installed retry callback, without sleeping through or extending Fireball's cooldown.
|
||||
((games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience")).retryPending();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
NativeTestAwait.until(f, () -> sessions.currentRecord(sessions.active(a.core.id).orElseThrow())
|
||||
.orElseThrow().experience().equals(BigInteger.TEN), "Fireball's originating character receives its exact combat XP");
|
||||
var rewarded = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(BigInteger.TEN, rewarded.experience(),
|
||||
"Native projectile attribution reaches the originating character's combat XP");
|
||||
|
||||
@@ -73,6 +73,8 @@ class FrostShardNativeTest {
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var vanilla = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
|
||||
@@ -89,7 +91,7 @@ class FrostShardNativeTest {
|
||||
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
|
||||
f.dispatcher.accept(hit);
|
||||
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
|
||||
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
|
||||
assertEquals(1, damages.size()); assertEquals(20.0, damages.getFirst().getDamage());
|
||||
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
|
||||
verify(target).addPotionEffect(effect.capture());
|
||||
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
|
||||
@@ -121,8 +123,8 @@ class FrostShardNativeTest {
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(20, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(20, damage.get("actual_amount").getAsDouble());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.has("direct_entity_id"));
|
||||
}
|
||||
@@ -150,8 +152,23 @@ class FrostShardNativeTest {
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> pendingDamageCases() {
|
||||
return java.util.stream.Stream.of(
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.ZERO, -4.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(12), 20.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.valueOf(20), 36.0),
|
||||
org.junit.jupiter.params.provider.Arguments.of(BigInteger.TEN.pow(500), Double.MAX_VALUE));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("pendingDamageCases")
|
||||
void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch(BigInteger intelligence, double expectedDamage) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var base = actor.core.record; var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, intelligence);
|
||||
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
|
||||
base.level(), base.experience(), base.unspentPoints(), base.health(), base.learned(), base.loadout(), base.cooldowns(),
|
||||
base.restrictedKills(), false, null, null);
|
||||
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
|
||||
@@ -164,7 +181,7 @@ class FrostShardNativeTest {
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.definition()).thenReturn(captured);
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(execution.character()).thenReturn(record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
@@ -181,8 +198,8 @@ class FrostShardNativeTest {
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
|
||||
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
|
||||
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
|
||||
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
|
||||
assertEquals(expectedDamage, amount.getValue());
|
||||
assertTrue(Double.isFinite(amount.getValue()), "uncapped INT must convert to finite native damage");
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@@ -652,12 +669,18 @@ class FrostShardNativeTest {
|
||||
.contains("Unavailable:requirements"));
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
|
||||
}
|
||||
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
|
||||
assertEquals(BigInteger.valueOf(16), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
|
||||
assertEquals(BigInteger.valueOf(20), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)).multiply(BigInteger.valueOf(4)),
|
||||
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({"0,-4", "1,-4", "9,12", "10,16", "11,16", "12,20", "13,20", "20,36", "21,36"})
|
||||
void fourfoldDamagePreservesMathematicalFloor(int intelligence, int expected) {
|
||||
assertEquals(BigInteger.valueOf(expected), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(intelligence)));
|
||||
}
|
||||
|
||||
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
|
||||
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
|
||||
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
|
||||
@@ -787,7 +810,9 @@ class FrostShardNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void acknowledgedUpdatesAffectFutureCastsButNotPendingOrInFlightSnapshots(boolean pending) throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -795,23 +820,39 @@ class FrostShardNativeTest {
|
||||
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
|
||||
|
||||
prepareLaunches(f, a, oldProjectile, newProjectile);
|
||||
Runnable pendingLaunch = null;
|
||||
if (pending) {
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
pendingLaunch = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(pendingLaunch);
|
||||
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
} else cast(f, a);
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
|
||||
var deadlines = repository.load(a.core.record.id()).orElseThrow().cooldowns();
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
assertEquals(deadlines, repository.load(a.core.record.id()).orElseThrow().cooldowns());
|
||||
if (pendingLaunch != null) pendingLaunch.run();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE); when(target.getWorld()).thenReturn(a.world);
|
||||
when(target.getName()).thenReturn("Snapshot hostile");
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
|
||||
var damages = new java.util.ArrayList<Double>();
|
||||
doAnswer(call -> {
|
||||
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(damages.size() == 1 ? oldProjectile : newProjectile, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
|
||||
assertEquals(java.util.List.of(5.0, 10.0), damages);
|
||||
assertEquals(java.util.List.of(20.0, 40.0), damages);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,25 +47,32 @@ class FrostShardRangeNativeTest {
|
||||
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> distantCases() {
|
||||
return java.util.stream.Stream.of(
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss"),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired"));
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.0, "", "hit", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(48.01, "", "miss", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "retained", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "snapshot", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "cancelled", "cancelled", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "obstructed", "miss", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "empty", "expired", 12, 4, 20),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 20, 4, 36),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 21, 4, 36),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "", "hit", 12, 9, 40),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "mitigated", "hit", 20, 9, 56),
|
||||
org.junit.jupiter.params.provider.Arguments.of(36.0, "zero", "miss", 12, 4, 20));
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("distantCases")
|
||||
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome) throws Exception {
|
||||
void bookEquippedRealArrowFlightNativeDistantImpactSlowAndTruthfulJsonl(double distance, String mode, String outcome,
|
||||
int intelligence, int base, double requestedDamage) throws Exception {
|
||||
boolean retained = mode.equals("retained") || mode.equals("snapshot");
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "adventure-daily-site-count: 0\n"
|
||||
String retainedConfiguration = "adventure-daily-site-count: 0\nfrost-shard-base-damage: " + base + "\n"
|
||||
+ (retained ? "frost-shard-range-blocks: 32\n" : "")
|
||||
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : ""));
|
||||
+ (mode.equals("snapshot") ? "frost-shard-cooldown-seconds: 0\n" : "");
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retainedConfiguration);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data); seed(actor, intelligence);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, actor);
|
||||
String book = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
|
||||
@@ -121,6 +128,8 @@ class FrostShardRangeNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
|
||||
damageEvents.add(event); if (mode.equals("cancelled")) event.setCancelled(true);
|
||||
if (mode.equals("mitigated")) event.setDamage(event.getDamage() / 2);
|
||||
if (mode.equals("zero")) event.setDamage(0);
|
||||
f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
nativeArrow.delivery = result -> {
|
||||
@@ -162,11 +171,14 @@ class FrostShardRangeNativeTest {
|
||||
}
|
||||
assertTrue(nativeArrow.isRemoved(), "impact or captured range expiry must physically discard owned Arrow");
|
||||
boolean accepted = outcome.equals("hit");
|
||||
assertEquals(accepted || mode.equals("cancelled") ? 1 : 0, damageEvents.size(), "only actual in-range native collision may attempt damage");
|
||||
assertEquals(accepted || mode.equals("cancelled") || mode.equals("zero") ? 1 : 0, damageEvents.size(),
|
||||
"only actual in-range native collision may attempt damage");
|
||||
double finalDamage = mode.equals("mitigated") ? requestedDamage / 2 : requestedDamage;
|
||||
if (!damageEvents.isEmpty()) verify(target, times(1)).damage(eq(requestedDamage), any(org.bukkit.damage.DamageSource.class));
|
||||
if (accepted) {
|
||||
assertNotNull(nativeArrow.hit); assertEquals(HitResult.Type.ENTITY, nativeArrow.hit.getType());
|
||||
assertEquals(distance, nativeArrow.position().distanceTo(origin), 1e-9);
|
||||
assertEquals(5, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
|
||||
assertEquals(finalDamage, damageEvents.getFirst().getFinalDamage()); assertFalse(damageEvents.getFirst().isCancelled());
|
||||
assertSame(actor.player, damageEvents.getFirst().getDamageSource().getCausingEntity());
|
||||
assertSame(bukkit, damageEvents.getFirst().getDamageSource().getDirectEntity());
|
||||
assertNotNull(slow.get()); assertEquals(40, slow.get().getDuration()); assertEquals(0, slow.get().getAmplifier());
|
||||
@@ -187,12 +199,18 @@ class FrostShardRangeNativeTest {
|
||||
var capture = rows("ability.cast").getFirst().getAsJsonObject("data");
|
||||
assertEquals(retained ? "32" : "48", capture.getAsJsonObject("captured_settings").get("range").getAsString());
|
||||
assertTrue(book.contains((retained ? "32" : "48") + "-blocktravellimit"), "real book must explain captured effective reach");
|
||||
assertEquals(Integer.toString(intelligence), capture.get("captured_int").getAsString());
|
||||
assertEquals(Integer.toString(base), capture.getAsJsonObject("captured_settings").get("base-damage").getAsString());
|
||||
assertTrue(book.contains("4*(" + base + "+INTmodifier)"), "book must expose the effective full fourfold formula");
|
||||
if (!mode.equals("snapshot")) assertEquals(retainedConfiguration, java.nio.file.Files.readString(data.resolve("config.yml")),
|
||||
"loading and casting must not rewrite retained configured bases");
|
||||
if (accepted) {
|
||||
var damage = rows("combat.damage").getFirst().getAsJsonObject("data");
|
||||
assertEquals(capture.get("execution_id"), damage.get("execution_id"));
|
||||
assertEquals(bukkit.getUniqueId().toString(), damage.get("projectile_id").getAsString());
|
||||
assertEquals(nativeArrow.getX(), damage.getAsJsonObject("impact_location").get("x").getAsDouble(), 1e-9);
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(requestedDamage, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(finalDamage, damage.get("actual_amount").getAsDouble());
|
||||
}
|
||||
if (mode.equals("snapshot")) {
|
||||
var next = new CollisionArrow(actor.world.getHandle());
|
||||
@@ -282,10 +300,10 @@ class FrostShardRangeNativeTest {
|
||||
throw new AssertionError("bounded actual native flight cannot reach fixture collision face");
|
||||
} finally { oracle.discard(); }
|
||||
}
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
private void seed(FantasyStickLifecycleNativeTest.Actor actor, int intelligence) throws Exception {
|
||||
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
var stats = new java.util.EnumMap<>(old.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(12));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.INT, java.math.BigInteger.valueOf(intelligence));
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.WIS, java.math.BigInteger.TEN);
|
||||
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), java.math.BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
|
||||
|
||||
@@ -39,6 +39,418 @@ class HealthStealEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredFinalBlowsHealFromNativeHealthDespiteStaleHighLowOrAlreadyMatchingSavedHealth() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
for (String savedHealth : List.of("9", "2", "5", "20")) {
|
||||
var a = actor(f, new BigDecimal(savedHealth));
|
||||
double nativeBefore = savedHealth.equals("20") ? 21.5 : 4;
|
||||
double expected = Math.min(22, nativeBefore + 1);
|
||||
a.player.setHealth(nativeBefore);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
|
||||
assertEquals(expected, a.player.getHealth(), "Saved " + savedHealth + " cannot set the native heal baseline");
|
||||
assertEquals(0, BigDecimal.valueOf(expected).compareTo(current(f, a).health()));
|
||||
var saved = current(f, a);
|
||||
die(f, target); drain(f); assertEquals(saved, current(f, a));
|
||||
assertEquals(expected, a.player.getHealth(), "Consumed defeat cannot heal twice");
|
||||
}
|
||||
awaitHeals(4); f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
var rows = heals(); assertEquals(4, rows.size());
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
var before = new BigDecimal(values.get("health_before").getAsString());
|
||||
var after = new BigDecimal(values.get("health_after").getAsString());
|
||||
var amount = new BigDecimal(values.get("final_amount").getAsString());
|
||||
assertEquals(0, after.subtract(before).compareTo(amount));
|
||||
assertTrue(amount.signum() > 0 && amount.compareTo(BigDecimal.ONE) <= 0);
|
||||
assertEquals(0, after.compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void interveningNativeDamageReconcilesOwnedSaveWithoutHealingOrReplayingDefeat() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9"));
|
||||
a.player.setHealth(4);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
var committed = storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(0, new BigDecimal("5").compareTo(committed.health()), "Fence the save before its native callback");
|
||||
a.player.setHealth(2); drain(f);
|
||||
assertEquals(2, a.player.getHealth(), "Deferred healing cannot erase intervening damage");
|
||||
assertEquals(0, new BigDecimal("2").compareTo(current(f, a).health()), "Failed native publication reconciles saved health");
|
||||
assertEquals(current(f, a), storage(f).find(committed.id()).get(5, TimeUnit.SECONDS).orElseThrow());
|
||||
var reconciled = current(f, a); die(f, target); drain(f);
|
||||
assertEquals(reconciled, current(f, a)); assertEquals(2, a.player.getHealth());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
assertTrue(records().stream().anyMatch(row -> row.getAsJsonObject("data").has("reason")
|
||||
&& row.getAsJsonObject("data").get("reason").getAsString().equals("saved_but_native_health_changed")));
|
||||
}
|
||||
|
||||
@Test void reentrantNativeHealingValidationCannotChangeTheCapturedBaselineAndStillApplyHeal() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var before = current(f, a);
|
||||
class Gate implements Listener {
|
||||
@EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) a.player.setHealth(2);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Gate(), f.plugin);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
|
||||
assertEquals(2, a.player.getHealth(), "Changed native validation cannot authorize an old heal");
|
||||
assertEquals(before.health(), current(f, a).health(), "Refusal before persistence must not write health");
|
||||
assertEquals(before.unspentPoints(), current(f, a).unspentPoints());
|
||||
assertEquals(before.coins(), current(f, a).coins());
|
||||
var afterReward = current(f, a); // The legitimate combat receipt may advance unrelated progress.
|
||||
die(f, target); drain(f); assertEquals(afterReward, current(f, a));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
}
|
||||
|
||||
@Test void queuedRegisteredKillsBoundAcceptedBudgetsAndRefuseCompetingRevisionsWithoutReplay() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var first = mob(a, EntityType.COW); var second = mob(a, EntityType.BEE);
|
||||
hit(f, a, first); die(f, first); hit(f, a, second); die(f, second);
|
||||
// Real combat rewards also advance revisions. Queued healing may refuse that competition,
|
||||
// but must resolve each consumed defeat once, keep its budget, and never retry a refused heal.
|
||||
awaitHealthResolutions(f, 2);
|
||||
assertTrue(a.player.getHealth() == 5 || a.player.getHealth() == 6);
|
||||
assertEquals(a.player.getHealth(), current(f, a).health().doubleValue());
|
||||
awaitHeals((int) (a.player.getHealth() - 4));
|
||||
assertEquals(a.player.getHealth() - 4, heals().size());
|
||||
var saved = current(f, a); die(f, first); die(f, second); drain(f); assertEquals(saved, current(f, a));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
var rows = heals(); assertTrue(rows.size() == 1 || rows.size() == 2);
|
||||
assertEquals(rows.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
var baselines = new java.util.HashSet<String>();
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data"); baselines.add(values.get("health_before").getAsString());
|
||||
assertEquals("1.0", values.get("native_accepted_amount").getAsString());
|
||||
assertEquals("1.0", values.get("final_amount").getAsString());
|
||||
assertEquals(0, new BigDecimal(values.get("health_after").getAsString())
|
||||
.compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
|
||||
}
|
||||
assertEquals(rows.size() == 1 ? java.util.Set.of("4.0") : java.util.Set.of("4.0", "5.0"), baselines);
|
||||
}
|
||||
|
||||
@Test void nativeAcceptedFractionCannotBeAmplifiedByStaleSavedHealth() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
class Gate implements Listener {
|
||||
@EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) event.setAmount(0.25);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Gate(), f.plugin);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
|
||||
assertEquals(4.25, a.player.getHealth()); assertEquals(4.25, current(f, a).health().doubleValue());
|
||||
awaitHeals(1); f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
var values = heals().getFirst().getAsJsonObject("data");
|
||||
assertEquals("0.25", values.get("native_accepted_amount").getAsString());
|
||||
assertEquals("0.25", values.get("final_amount").getAsString());
|
||||
}
|
||||
|
||||
@Test void registeredFractionalHealingUsesRepresentableNativeGainWithinAcceptedBudget() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
int count = 0;
|
||||
for (double accepted : List.of(0.3, 0.1)) {
|
||||
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
// Match the pinned native health pool's float representation, as in Second Wind's fixture.
|
||||
a.core.healthMutation = () -> a.core.health = (float) a.core.health;
|
||||
class Gate implements Listener {
|
||||
@EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) event.setAmount(accepted);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Gate(), f.plugin);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
NativeTestAwait.until(f, () -> a.player.getHealth() > 4, "registered fractional Health Steal publication");
|
||||
double after = a.player.getHealth(); double gain = after - 4;
|
||||
assertTrue(gain <= accepted, "Native float rounding must not exceed the accepted healing budget: " + gain);
|
||||
assertEquals(0, BigDecimal.valueOf(after).compareTo(current(f, a).health()));
|
||||
int expected = ++count;
|
||||
var rows = NativeTestAwait.auditUntil(f, this::heals, values -> values.size() == expected,
|
||||
"truthful fractional Health Steal export");
|
||||
var values = rows.stream().filter(row -> row.get("characterid").getAsString().equals(a.core.record.id().toString()))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(0, BigDecimal.valueOf(4).compareTo(new BigDecimal(values.get("health_before").getAsString())));
|
||||
// Audit amounts use exact subtraction of their serialized decimal health endpoints.
|
||||
var exactGain = BigDecimal.valueOf(after).subtract(BigDecimal.valueOf(4));
|
||||
assertEquals(0, exactGain.compareTo(new BigDecimal(values.get("final_amount").getAsString())));
|
||||
assertTrue(exactGain.signum() > 0 && exactGain.compareTo(BigDecimal.valueOf(accepted)) <= 0);
|
||||
assertEquals(0, BigDecimal.valueOf(accepted).compareTo(new BigDecimal(values.get("native_accepted_amount").getAsString())));
|
||||
assertEquals(0, BigDecimal.valueOf(after).compareTo(new BigDecimal(values.get("persisted_health").getAsString())));
|
||||
var saved = current(f, a); die(f, target); drain(f);
|
||||
assertEquals(after, a.player.getHealth()); assertEquals(saved, current(f, a));
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(2, heals().size(), "Consumed defeats cannot produce duplicate healing or success events");
|
||||
}
|
||||
|
||||
@Test void changedMaximumAndUnconfirmedNativeSetterReconcileWithoutSuccessOrReplay() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
for (boolean maximumChanged : List.of(true, false)) {
|
||||
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
if (maximumChanged) {
|
||||
var attribute = mock(org.bukkit.attribute.AttributeInstance.class); when(attribute.getValue()).thenReturn(4.5);
|
||||
doReturn(attribute).when(a.player).getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
} else {
|
||||
doAnswer(call -> { a.core.health = 4.5; return null; }).when(a.player).setHealth(5d);
|
||||
}
|
||||
drain(f);
|
||||
double expected = maximumChanged ? 4 : 4.5;
|
||||
assertEquals(expected, a.player.getHealth()); assertEquals(expected, current(f, a).health().doubleValue());
|
||||
var saved = current(f, a); die(f, target); drain(f); assertEquals(saved, current(f, a));
|
||||
// Remove the narrowly injected getter/setter before ordinary lifecycle restoration.
|
||||
doAnswer(a).when(a.player).getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);
|
||||
doAnswer(a).when(a.player).setHealth(anyDouble());
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty(), "A changed or unconfirmed native boundary cannot claim persisted healing");
|
||||
}
|
||||
|
||||
@Test void laterAcknowledgedProgressPreservesNewHealthButReconcilesAnInheritedUnpublishedTarget() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f);
|
||||
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
for (boolean healthChanged : List.of(true, false)) {
|
||||
var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var active = sessions.active(a.core.id).orElseThrow(); var before = current(f, a);
|
||||
var target = mob(a, EntityType.COW);
|
||||
// Isolate the acknowledged-progress fault from unrelated reward persistence. The other tests
|
||||
// exercise registered combat; these are the real owner's validation/save/publication boundaries.
|
||||
service.damage(damage(a, target)); service.mobDeath(death(target));
|
||||
var committed = storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertTrue(sessions.acknowledgeProgress(active, before.revision(), committed));
|
||||
var newer = healthChanged
|
||||
? storage(f).saveHealth(before.id(), committed.revision(), new BigDecimal("7"), new BigDecimal("22"))
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow()
|
||||
: storage(f).renameCharacter(before.id(), "Later acknowledged name").get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertTrue(sessions.acknowledgeProgress(active, committed.revision(), newer));
|
||||
if (healthChanged) a.player.setHealth(7);
|
||||
drain(f);
|
||||
double expected = healthChanged ? 7 : 4;
|
||||
assertEquals(expected, a.player.getHealth()); assertEquals(expected, current(f, a).health().doubleValue());
|
||||
assertEquals(newer.name(), current(f, a).name()); assertEquals(newer.unspentPoints(), current(f, a).unspentPoints());
|
||||
assertEquals(newer.coins(), current(f, a).coins());
|
||||
if (healthChanged) assertEquals(newer, current(f, a), "Do not rewrite another owner's newer health");
|
||||
var saved = current(f, a); service.mobDeath(death(target)); drain(f); assertEquals(saved, current(f, a));
|
||||
}
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty(), "Neither a stale save nor its reconciliation is a successful heal");
|
||||
}
|
||||
|
||||
@Test void duplicateNoOpSaveCallbackCannotReplayConsumedHealingAfterLaterDamage() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("5")); a.player.setHealth(4);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
Runnable publication = null;
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (a.player.getHealth() == 4) {
|
||||
long remaining = deadline - System.nanoTime(); assertTrue(remaining > 0, "Health Steal publication must complete");
|
||||
publication = f.tasks.poll(remaining, TimeUnit.NANOSECONDS); assertNotNull(publication); publication.run();
|
||||
}
|
||||
assertEquals(5, a.player.getHealth()); assertNotNull(publication);
|
||||
a.player.setHealth(4); // Native damage need not advance the saved character revision.
|
||||
publication.run();
|
||||
assertEquals(4, a.player.getHealth(), "A repeated acknowledged callback cannot replay a consumed defeat's heal");
|
||||
drain(f); die(f, target); drain(f); assertEquals(4, a.player.getHealth());
|
||||
NativeTestAwait.auditUntil(f, this::heals, rows -> rows.size() == 1, "Original publication's exact heal audit acknowledgment");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(1, heals().size(), "Only the original publication may report healing");
|
||||
}
|
||||
|
||||
@Test void failedReconciliationCannotReplayUnpublishedHealthOnFreshEntry() throws Exception {
|
||||
configure();
|
||||
org.bukkit.inventory.ItemStack[] transferred;
|
||||
UUID character;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
character = a.core.record.id();
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
var store = storage(f);
|
||||
assertEquals(0, new BigDecimal("5").compareTo(store.inspect(character).get(5, TimeUnit.SECONDS).orElseThrow().health()));
|
||||
store.close(); store.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
a.player.setHealth(2);
|
||||
Runnable callback; while ((callback = f.tasks.poll()) != null) callback.run();
|
||||
assertEquals(2, a.player.getHealth(), "The original native publication must refuse intervening damage");
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
// The failed worker cannot accept a character fence; settle the actual remaining receipt callbacks.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
while ((callback = f.tasks.poll()) != null) callback.run();
|
||||
}
|
||||
a.core.inventory.setItemInOffHand(sheet); transferred = a.core.inventory.getContents();
|
||||
}
|
||||
try (var restarted = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
restarted.plugin.setEnabled(true); ready(restarted);
|
||||
var next = new FantasyStickLifecycleNativeTest.Actor(data); next.core.inventory.setContents(transferred);
|
||||
restarted.players.put(next.core.id, next.player);
|
||||
assertRefusedEntry(restarted, next);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(restarted).find(character).get());
|
||||
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(character + ".pending")));
|
||||
}
|
||||
assertTrue(heals().isEmpty(), "A failed native heal cannot later become successful through reload");
|
||||
}
|
||||
|
||||
@Test void acceptedBudgetBelowOneNativeFloatStepDoesNotWriteOrClaimAHeal() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
a.core.healthMutation = () -> a.core.health = (float) a.core.health;
|
||||
class Gate implements Listener {
|
||||
@EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player) event.setAmount(Math.ulp(4f) / 4);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Gate(), f.plugin);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
NativeTestAwait.auditUntil(f, this::records, rows -> rows.stream().anyMatch(row ->
|
||||
row.getAsJsonObject("data").has("reason") && row.getAsJsonObject("data").get("reason").getAsString()
|
||||
.equals("no_positive_representable_heal")), "sub-float healing refusal");
|
||||
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("9").compareTo(current(f, a).health()));
|
||||
die(f, target); drain(f); assertEquals(4, a.player.getHealth());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
}
|
||||
|
||||
@Test void lifecycleExitAndTransferAfterSaveNeverPublishOrReplayAnOldNativeHeal() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target);
|
||||
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); drain(f);
|
||||
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(20, a.player.getHealth(), "Exit restores the fixture's ordinary vitals, not the deferred heal");
|
||||
var saved = storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(0, new BigDecimal("4").compareTo(saved.health()));
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
holder.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(saved));
|
||||
f.players.put(holder.core.id, holder.player); assertRefusedEntry(f, holder);
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(saved.id()).get());
|
||||
die(f, target); drain(f);
|
||||
assertEquals(20, holder.player.getHealth());
|
||||
assertEquals(saved, storage(f).inspect(saved.id()).get().orElseThrow(), "Consumed defeat cannot alter newer exit health");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
}
|
||||
|
||||
@Test void registeredRewardRetryPreservesCoinsAfterTheHealthyPublicationBarrierRetires() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4")); var before = current(f, a);
|
||||
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
|
||||
var rewards = (games.dmg.fantasystick.party.CombatExperience)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "combatExperience");
|
||||
var target = mob(a, EntityType.ZOMBIE); hit(f, a, target); var defeat = death(target);
|
||||
// Hold the actual health callback while registered reward delivery encounters its pending proof.
|
||||
// Redelivering this same native event must not duplicate Health Steal's already-consumed outcome.
|
||||
service.mobDeath(defeat); storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS);
|
||||
f.dispatcher.accept(defeat); storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(before.coins(), current(f, a).coins());
|
||||
drain(f); assertEquals(5, a.player.getHealth());
|
||||
assertEquals(5, storage(f).find(before.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
|
||||
rewards.retryPending(); // The existing installed one-second retry, not a new retry policy.
|
||||
NativeTestAwait.until(f, () -> current(f, a).coins().compareTo(before.coins()) > 0, "The exact pending combat reward settles");
|
||||
var settled = current(f, a); assertEquals(5, settled.health().doubleValue()); assertEquals(5, a.player.getHealth());
|
||||
f.dispatcher.accept(defeat); die(f, target); rewards.retryPending(); drain(f);
|
||||
assertEquals(settled, current(f, a), "Repeated death/retry cannot duplicate XP, coins or health");
|
||||
NativeTestAwait.auditUntil(f, this::heals, rows -> rows.size() == 1, "Exactly one native heal");
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertEquals(1, heals().size());
|
||||
}
|
||||
|
||||
@Test void rejectedPublicationSchedulerRetainsProofWithoutHealingOrEnablingRestartLoad() throws Exception {
|
||||
configure(); UUID character;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4")); character = a.core.record.id();
|
||||
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
|
||||
var scheduling = HealthSteal.class.getDeclaredField("nextTick"); scheduling.setAccessible(true);
|
||||
scheduling.set(service, (java.util.function.Consumer<Runnable>) callback -> {
|
||||
throw new java.util.concurrent.RejectedExecutionException("Injected native callback refusal");
|
||||
});
|
||||
var target = mob(a, EntityType.COW); service.damage(damage(a, target)); service.mobDeath(death(target));
|
||||
assertEquals(5, storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
|
||||
assertEquals(4, a.player.getHealth()); assertEquals(4, current(f, a).health().doubleValue());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(character).get());
|
||||
service.mobDeath(death(target)); assertEquals(4, a.player.getHealth());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> restarted.find(character).get());
|
||||
}
|
||||
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(character + ".pending")));
|
||||
assertTrue(heals().isEmpty());
|
||||
}
|
||||
|
||||
@Test void furtherDamageBeforeReconciliationAcknowledgmentKeepsTheLoadBarrier() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("9")); a.player.setHealth(4);
|
||||
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
|
||||
var callbacks = new java.util.concurrent.LinkedBlockingQueue<Runnable>();
|
||||
var scheduling = HealthSteal.class.getDeclaredField("nextTick"); scheduling.setAccessible(true);
|
||||
scheduling.set(service, (java.util.function.Consumer<Runnable>) callbacks::add);
|
||||
var target = mob(a, EntityType.COW); service.damage(damage(a, target)); service.mobDeath(death(target));
|
||||
storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS); a.player.setHealth(2);
|
||||
var publication = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(publication); publication.run();
|
||||
assertEquals(2, storage(f).inspect(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().health().doubleValue());
|
||||
a.player.setHealth(1);
|
||||
var reconciliation = callbacks.poll(5, TimeUnit.SECONDS); assertNotNull(reconciliation); reconciliation.run();
|
||||
assertEquals(1, a.player.getHealth(), "Reconciliation never replays native health");
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> storage(f).find(a.core.record.id()).get());
|
||||
assertTrue(Files.exists(data.resolve("health-steal-health").resolve(a.core.record.id() + ".pending")));
|
||||
service.mobDeath(death(target)); drain(f); assertEquals(1, a.player.getHealth());
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
}
|
||||
|
||||
private static void assertRefusedEntry(FantasyStickLifecycleNativeTest.Fixture f,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
double ordinary = actor.player.getHealth(); f.changed(actor);
|
||||
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
NativeTestAwait.until(f, () -> {
|
||||
synchronized (sessions) {
|
||||
return !((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "pending"))
|
||||
.containsKey(actor.core.id);
|
||||
}
|
||||
}, "Exact character entry attempt must settle, not merely appear inactive while pending");
|
||||
assertTrue(sessions.active(actor.core.id).isEmpty(), "Unresolved native publication must block entry");
|
||||
assertEquals(ordinary, actor.player.getHealth());
|
||||
}
|
||||
|
||||
@Test void feedbackOffAndRejectedWriterRecoverExactHealWithoutAnotherHealthOrRewardMutation() throws Exception {
|
||||
configure();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
@@ -100,7 +512,8 @@ class HealthStealEventsNativeTest {
|
||||
doThrow(new IllegalStateException("native health publication refused")).when(a.player).setHealth(5d);
|
||||
service.mobDeath(death(nativeFailure));
|
||||
assertThrows(IllegalStateException.class, () -> drain(f));
|
||||
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("5").compareTo(current(f, a).health()));
|
||||
drain(f); // Fence the refused native publication's owned health reconciliation.
|
||||
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("4").compareTo(current(f, a).health()));
|
||||
f.plugin.setEnabled(false); f.awaitWorkers();
|
||||
}
|
||||
assertTrue(heals().isEmpty());
|
||||
@@ -209,7 +622,11 @@ class HealthStealEventsNativeTest {
|
||||
private static LivingEntity mob(FantasyStickLifecycleNativeTest.Actor a, EntityType type) {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(type); when(target.getHealth()).thenReturn(1d); when(target.getAbsorptionAmount()).thenReturn(0d);
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 1, 64, 0)); return target;
|
||||
when(target.getLocation()).thenAnswer(ignored -> new Location(a.world, 1, 64, 0));
|
||||
when(target.getWorld()).thenReturn(a.world);
|
||||
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
|
||||
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
|
||||
return target;
|
||||
}
|
||||
private static EntityDamageByEntityEvent damage(FantasyStickLifecycleNativeTest.Actor a, LivingEntity target) {
|
||||
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getDirectEntity()).thenReturn(a.player);
|
||||
@@ -242,6 +659,18 @@ class HealthStealEventsNativeTest {
|
||||
return records().stream().filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "combat.healed"))
|
||||
.filter(row -> row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).toList();
|
||||
}
|
||||
private void awaitHealthResolutions(FantasyStickLifecycleNativeTest.Fixture f, int count) throws Exception {
|
||||
int resolved = 0;
|
||||
for (int i = 0; i < 500 && resolved != count; i++) {
|
||||
drain(f);
|
||||
resolved = (int) records().stream()
|
||||
.filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "action.resolved"))
|
||||
.filter(row -> row.getAsJsonObject("data").has("healing_source")
|
||||
&& row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).count();
|
||||
if (resolved != count) Thread.sleep(10);
|
||||
}
|
||||
assertEquals(count, resolved, "Every queued defeat must resolve once, including revision refusals");
|
||||
}
|
||||
private void awaitHeals(int count) throws Exception {
|
||||
for (int i = 0; i < 500 && heals().size() != count; i++) Thread.sleep(10);
|
||||
assertEquals(count, heals().size());
|
||||
|
||||
@@ -13,7 +13,6 @@ import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -109,7 +108,7 @@ class LowLightVisionNativeTest {
|
||||
}
|
||||
|
||||
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); var effects = effects(player);
|
||||
UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("low_light_vision", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
|
||||
@@ -141,11 +140,7 @@ class LowLightVisionNativeTest {
|
||||
}
|
||||
|
||||
static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
|
||||
return SpeedINativeTest.effects(player);
|
||||
}
|
||||
static void withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
|
||||
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
|
||||
@@ -204,7 +204,7 @@ class ManualCraftDeliveryNativeTest {
|
||||
assertEquals(ManualCraftDelivery.Result.DELIVERED, ManualCraftDelivery.commit(plan, batch, fixture.view));
|
||||
assertEquals(org.bukkit.Material.DIAMOND, fixture.view.getCursor().getType());
|
||||
assertEquals(record.id(), new CharacterSheets().identify(
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asCraftMirror(fixture.menu.getCarried())).orElseThrow());
|
||||
org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitMirror(fixture.menu.getCarried())).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ class ManualCraftPlanNativeTest {
|
||||
return human;
|
||||
}
|
||||
@Override public InventoryType getType() { return grid.getType(); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asCraftMirror(activeMenu.getCarried()); }
|
||||
@Override public ItemStack getCursor() { return CraftItemStack.asBukkitMirror(activeMenu.getCarried()); }
|
||||
@Override public void setCursor(ItemStack item) { activeMenu.setCarried(CraftItemStack.asNMSCopy(item)); }
|
||||
@Override public String getTitle() { return "Crafting fixture"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
|
||||
@@ -15,7 +15,7 @@ class NativeCompatibilityTest {
|
||||
try (var jar = new JarFile(Path.of(System.getProperty("distribution.jar")).toFile());
|
||||
var descriptor = jar.getInputStream(jar.getJarEntry("plugin.yml"))) {
|
||||
var metadata = new PluginDescriptionFile(descriptor);
|
||||
assertEquals("26.2", metadata.getAPIVersion());
|
||||
assertEquals("26.3", metadata.getAPIVersion());
|
||||
assertEquals(ApiVersion.CURRENT, ApiVersion.getOrCreateVersion(metadata.getAPIVersion()));
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertTrue(org.bukkit.plugin.java.JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
|
||||
@@ -284,8 +284,8 @@ class NativePresentationPacketsTest {
|
||||
assertEquals(Component.literal("Old"), originalMetadata.packedItems().stream()
|
||||
.filter(value -> value.id() == changed.getFirst().id()).findFirst().orElseThrow().value());
|
||||
var removal = wire(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC, label.remove());
|
||||
assertArrayEquals(new int[] {id}, removal.getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, label.remove().getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, removal.entityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {id}, label.remove().entityIds().toIntArray());
|
||||
assertEquals(id, label.id());
|
||||
assertEquals(uuid, label.uuid());
|
||||
assertEquals(allocated, player.world.chunks.checks, "Updates and removals never allocate replacement IDs");
|
||||
@@ -392,7 +392,7 @@ class NativePresentationPacketsTest {
|
||||
private static final class ReadChunks extends net.minecraft.server.level.ServerChunkCache {
|
||||
private int checks;
|
||||
private boolean rejectNext;
|
||||
private ReadChunks() { super(null, null, null, null, null, null, 0, 0, false, null, null, null); }
|
||||
private ReadChunks() { super(null, null, null, null, null, null, 0, 0, false, null, null); }
|
||||
@Override public boolean hasEntityWithId(int id) {
|
||||
checks++;
|
||||
boolean reject = rejectNext;
|
||||
|
||||
@@ -40,12 +40,12 @@ final class NativeRuntime {
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
|
||||
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName("org.bukkit.Registry"); // Initialize the fixed API registry before loading data packs.
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.party.CharacterPartyDirectory;
|
||||
import games.dmg.fantasystick.party.PartyManagement;
|
||||
import games.dmg.fantasystick.party.PartyRegistrar;
|
||||
import games.dmg.fantasystick.party.PartyRegistrarController;
|
||||
import games.dmg.fantasystick.party.PartyRegistrarDirectory;
|
||||
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.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import net.minecraft.world.inventory.AnvilMenu;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Villager;
|
||||
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.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.MenuType;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.view.AnvilView;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PartyRegistrarNameSubmissionNativeTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredNativeResultIsFreeAndCreatesOnePartyAtZeroVanillaExperience() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("success"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName();
|
||||
int paperBefore = paperCount(path.actor.core.inventory);
|
||||
path.actor.core.inventory.setItem(7, new ItemStack(Material.DIAMOND, 3));
|
||||
var unrelatedBefore = path.actor.core.inventory.getItem(7).clone();
|
||||
var inputBefore = view.getTopInventory().getItem(0).clone();
|
||||
|
||||
var result = path.prepare(view, " Cafe\u0301 Heroes ");
|
||||
assertEquals(0, view.getRepairCost());
|
||||
assertEquals(0, view.getRepairItemCountCost());
|
||||
assertTrue(view.canBypassCost());
|
||||
assertNotNull(result, "The registered native prepare path must make the result available");
|
||||
assertEquals(Material.PAPER, result.getType());
|
||||
assertNotNull(view.getTopInventory().getLocation());
|
||||
var nativeMenu = assertInstanceOf(AnvilMenu.class,
|
||||
((org.bukkit.craftbukkit.inventory.CraftInventoryView<?, ?>) view).getHandle());
|
||||
assertFalse(nativeMenu.checkReachable, "The virtual text-entry menu must not require a physical anvil block");
|
||||
|
||||
var priorCancellation = path.submit(view, true);
|
||||
assertTrue(priorCancellation.isCancelled());
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
var first = path.submit(view);
|
||||
var repeated = path.submit(view);
|
||||
assertTrue(first.isCancelled(), "The plugin owns result submission instead of native taking");
|
||||
assertTrue(repeated.isCancelled());
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("already in progress")),
|
||||
path.actor.messages::toString);
|
||||
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
var party = path.parties.partyOf(path.actor.core.record.id()).orElseThrow();
|
||||
assertEquals("Café Heroes", party.displayName());
|
||||
assertEquals(path.actor.core.record.id(), party.leader());
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
assertEquals(0, path.actor.player.getLevel());
|
||||
verify(path.actor.player, never()).setLevel(anyInt());
|
||||
assertEquals(paperBefore, paperCount(path.actor.core.inventory));
|
||||
assertEquals(unrelatedBefore, path.actor.core.inventory.getItem(7));
|
||||
assertTrue(path.actor.core.cursor == null || path.actor.core.cursor.getType().isAir());
|
||||
assertEquals(inputBefore, view.getTopInventory().getItem(0));
|
||||
assertEquals(result, view.getTopInventory().getItem(2));
|
||||
assertEquals(Material.PAPER, view.getTopInventory().getItem(2).getType(),
|
||||
"The cancelled plugin control remains in the view until the view closes");
|
||||
assertTrue(path.actor.inventories.getLast().getItem(4).getItemMeta().getDisplayName().contains("Café Heroes"));
|
||||
path.fixture.dispatcher.accept(new org.bukkit.event.inventory.InventoryCloseEvent(view));
|
||||
assertNull(view.getTopInventory().getItem(0)); assertNull(view.getTopInventory().getItem(2));
|
||||
nativeMenu.removed(path.actor.nativePlayer);
|
||||
assertEquals(0, path.actor.nativePlayer.getInventory().getContents().stream()
|
||||
.filter(item -> item.is(net.minecraft.world.item.Items.PAPER)).mapToInt(net.minecraft.world.item.ItemStack::getCount).sum());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void invalidDuplicateInsufficientDistantAndStaleSubmissionsRefuseWithoutCharging() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("invalid"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName(); view.getTopInventory().setItem(2, null); path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("result is unavailable")), path.actor.messages::toString);
|
||||
path.prepare(view, " "); path.submit(view);
|
||||
path.prepare(view, "x".repeat(33)); path.submit(view);
|
||||
assertEquals(2, path.actor.messages.stream().filter(message -> message.contains("invalid name")).count(), path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("duplicate"), BigInteger.valueOf(20))) {
|
||||
path.parties.create(UUID.randomUUID(), UUID.randomUUID(), "Café Heroes").get();
|
||||
var view = path.openName(); path.prepare(view, " cafe\u0301 heroes "); path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("name taken")), path.actor.messages::toString);
|
||||
assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty());
|
||||
assertEquals(BigInteger.valueOf(20), path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("insufficient"), BigInteger.valueOf(3))) {
|
||||
var view = path.openName(); path.prepare(view, "Poor Heroes"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("insufficient")));
|
||||
path.assertUnchanged(BigInteger.valueOf(3));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("distant"), BigInteger.valueOf(20))) {
|
||||
var view = path.openName(); path.prepare(view, "Far Heroes");
|
||||
path.actor.core.location.setX(path.actor.core.location.getX() + 100);
|
||||
path.submit(view);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("too far from the Party Registrar")),
|
||||
path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("stale"), BigInteger.valueOf(20))) {
|
||||
var stale = path.openName(); path.prepare(stale, "Stale Heroes");
|
||||
path.actor.core.inventory.setItemInOffHand(null); path.fixture.changed(path.actor);
|
||||
PartyBookNativeTest.drain(path.fixture); path.fixture.awaitCharacterAndReceipt(path.actor);
|
||||
var current = new YamlCharacterRepository(path.directory).load(path.actor.core.record.id()).orElseThrow();
|
||||
path.actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); path.fixture.activate(path.actor);
|
||||
path.submit(stale);
|
||||
assertTrue(path.actor.messages.stream().anyMatch(message -> message.contains("character or session changed")),
|
||||
path.actor.messages::toString);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPartySaveRefundsTheCoinAndARegisteredRetrySucceedsOnce() throws Exception {
|
||||
try (var path = new RegistrarPath(data.resolve("save-failure"), BigInteger.valueOf(20))) {
|
||||
var partyFile = path.directory.resolve("parties.yml");
|
||||
assertTrue(Files.notExists(partyFile)); Files.createDirectory(partyFile);
|
||||
var view = path.openName(); path.prepare(view, "Recovered Heroes"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("failed")));
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
assertTrue(Files.isDirectory(partyFile)); Files.delete(partyFile);
|
||||
|
||||
path.reenter(); view = path.openName();
|
||||
path.prepare(view, "Recovered Heroes"); path.submit(view);
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
try (var path = new RegistrarPath(data.resolve("coin-failure"), BigInteger.valueOf(20))) {
|
||||
var characterFile = path.directory.resolve("characters").resolve(path.actor.core.record.id() + ".yml");
|
||||
var backup = path.directory.resolve("character-backup.yml");
|
||||
Files.move(characterFile, backup); Files.createDirectory(characterFile);
|
||||
var view = path.openName(); path.prepare(view, "Coin Recovery"); path.submit(view);
|
||||
path.await(() -> path.actor.messages.stream().anyMatch(message -> message.contains("error")));
|
||||
assertTrue(path.parties.partyOf(path.actor.core.record.id()).isEmpty());
|
||||
assertTrue(Files.isDirectory(characterFile)); Files.delete(characterFile); Files.move(backup, characterFile);
|
||||
path.assertUnchanged(BigInteger.valueOf(20));
|
||||
|
||||
path.prepare(view, "Coin Recovery Retry"); path.submit(view);
|
||||
path.await(() -> path.parties.partyOf(path.actor.core.record.id()).isPresent()
|
||||
&& path.actor.messages.contains("Registered party."));
|
||||
assertEquals(1, path.parties.all().size());
|
||||
assertEquals(BigInteger.TEN, path.characters.find(path.actor.core.record.id()).get().orElseThrow().coins());
|
||||
}
|
||||
}
|
||||
|
||||
private static int paperCount(PlayerInventory inventory) {
|
||||
int count = 0;
|
||||
for (var item : inventory.getContents()) if (item != null && item.getType() == Material.PAPER) count += item.getAmount();
|
||||
return count;
|
||||
}
|
||||
|
||||
private static final class RegistrarPath implements AutoCloseable {
|
||||
final Path directory;
|
||||
final FantasyStickLifecycleNativeTest.Fixture fixture;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final PartyRegistrar registrar;
|
||||
final CharacterPartyDirectory parties;
|
||||
final CharacterStorage characters;
|
||||
final Villager villager;
|
||||
|
||||
RegistrarPath(Path directory, BigInteger coins) throws Exception {
|
||||
this.directory = directory;
|
||||
fixture = new FantasyStickLifecycleNativeTest.Fixture(directory); fixture.plugin.setEnabled(true);
|
||||
actor = new FantasyStickLifecycleNativeTest.Actor(directory); fixture.players.put(actor.core.id, actor.player);
|
||||
when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer,
|
||||
new net.minecraft.world.entity.EntityEquipment()));
|
||||
doReturn(0).when(actor.player).getLevel(); doReturn(true).when(actor.player).isOnline();
|
||||
var repository = new YamlCharacterRepository(directory); var old = repository.load(actor.core.record.id()).orElseThrow();
|
||||
repository.replace(withCoins(old, coins), old.revision()); fixture.activate(actor);
|
||||
var management = (PartyManagement) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyManagement");
|
||||
management.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var registrations = (PartyRegistrarDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "partyRegistrars");
|
||||
var location = actor.core.location; registrar = registrations.place("Central", actor.world.getUID(), location.getX(),
|
||||
location.getY(), location.getZ(), location.getYaw(), location.getPitch()).get(5, TimeUnit.SECONDS);
|
||||
parties = (CharacterPartyDirectory) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characterParties");
|
||||
characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
villager = mock(Villager.class); when(villager.getLocation()).thenReturn(location);
|
||||
when(villager.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var values = new HashMap<String, String>(); values.put("managed_npc_type", "party_registrar");
|
||||
values.put("party_registrar_id", registrar.id().toString());
|
||||
var pdc = mock(PersistentDataContainer.class); when(villager.getPersistentDataContainer()).thenReturn(pdc);
|
||||
when(pdc.get(any(NamespacedKey.class), eq(PersistentDataType.STRING)))
|
||||
.thenAnswer(call -> values.get(((NamespacedKey) call.getArgument(0)).getKey()));
|
||||
interact();
|
||||
}
|
||||
|
||||
void interact() {
|
||||
var interaction = new PlayerInteractEntityEvent(actor.player, villager, EquipmentSlot.HAND);
|
||||
fixture.dispatcher.accept(interaction); assertTrue(interaction.isCancelled());
|
||||
}
|
||||
|
||||
void reenter() throws Exception {
|
||||
actor.core.inventory.setItemInOffHand(null); fixture.changed(actor);
|
||||
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(actor);
|
||||
var current = new YamlCharacterRepository(directory).load(actor.core.record.id()).orElseThrow();
|
||||
actor.core.inventory.setItemInOffHand(new CharacterSheets().create(current)); fixture.activate(actor); interact();
|
||||
}
|
||||
|
||||
AnvilView openName() {
|
||||
var root = actor.inventories.getLast();
|
||||
var rootClick = new InventoryClickEvent(view(actor, root), InventoryType.SlotType.CONTAINER, 11,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(rootClick); assertTrue(rootClick.isCancelled());
|
||||
var opened = actor.inventoryViews.getLast();
|
||||
assertInstanceOf(org.bukkit.craftbukkit.inventory.view.CraftAnvilView.class, opened);
|
||||
assertEquals(org.bukkit.inventory.MenuType.ANVIL, opened.getMenuType());
|
||||
return (AnvilView) opened;
|
||||
}
|
||||
|
||||
ItemStack prepare(AnvilView view, String name) {
|
||||
var nativeMenu = assertInstanceOf(AnvilMenu.class,
|
||||
((org.bukkit.craftbukkit.inventory.CraftInventoryView<?, ?>) view).getHandle());
|
||||
assertTrue(nativeMenu.setItemName(name) || name.equals(view.getRenameText()), "Native rename input was rejected");
|
||||
return view.getTopInventory().getItem(2);
|
||||
}
|
||||
|
||||
InventoryClickEvent submit(AnvilView view) { return submit(view, false); }
|
||||
|
||||
InventoryClickEvent submit(AnvilView view, boolean cancelled) {
|
||||
var event = new InventoryClickEvent(view, InventoryType.SlotType.RESULT, 2,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
event.setCancelled(cancelled); fixture.dispatcher.accept(event); return event;
|
||||
}
|
||||
|
||||
void assertUnchanged(BigInteger coins) throws Exception {
|
||||
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS); parties.defeat(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
|
||||
assertTrue(parties.partyOf(actor.core.record.id()).isEmpty());
|
||||
assertEquals(coins, characters.find(actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow().coins());
|
||||
}
|
||||
|
||||
void await(BooleanSupplier condition) throws Exception {
|
||||
for (int attempt = 0; attempt < 160 && !condition.getAsBoolean(); attempt++) {
|
||||
var task = fixture.tasks.poll(25, TimeUnit.MILLISECONDS); if (task != null) task.run();
|
||||
if (!condition.getAsBoolean()) Thread.sleep(5);
|
||||
}
|
||||
assertTrue(condition.getAsBoolean(), () -> "condition not reached; messages=" + actor.messages + "; parties=" + parties.all());
|
||||
}
|
||||
|
||||
@Override public void close() throws java.io.IOException { fixture.close(); }
|
||||
}
|
||||
|
||||
private static CharacterRecord withCoins(CharacterRecord old, BigInteger coins) {
|
||||
return 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(), coins, true, old.rewardReceipts(), old.pvpCoinEscrow(),
|
||||
old.gear(), old.sheetGeneration());
|
||||
}
|
||||
|
||||
private static org.bukkit.inventory.InventoryView view(FantasyStickLifecycleNativeTest.Actor actor, Inventory top) {
|
||||
return new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
|
||||
@Override public Inventory getTopInventory() { return top; }
|
||||
@Override public PlayerInventory getBottomInventory() { return actor.core.inventory; }
|
||||
@Override public org.bukkit.entity.HumanEntity getPlayer() { return actor.player; }
|
||||
@Override public InventoryType getType() { return top.getType(); }
|
||||
@Override public ItemStack getCursor() { return actor.core.cursor; }
|
||||
@Override public void setCursor(ItemStack item) { actor.core.cursor = item; }
|
||||
@Override public String getTitle() { return "Party Registrar"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected title mutation"); }
|
||||
@Override public MenuType getMenuType() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class PartyRegistrarNativeTest {
|
||||
@Test void adminPlacementReconcilesProtectedVillagerAndActiveCharacterCanPayToRegister()throws Exception{
|
||||
UUID invitedPlayer,invitedCharacter,invitedSession;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);when(actor.nativePlayer.getInventory()).thenReturn(new net.minecraft.world.entity.player.Inventory(actor.nativePlayer,new net.minecraft.world.entity.EntityEquipment()));
|
||||
doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID worldId=UUID.randomUUID();when(actor.world.getUID()).thenReturn(worldId);
|
||||
int chunkX=actor.core.location.getBlockX()>>4,chunkZ=actor.core.location.getBlockZ()>>4;var chunk=mock(Chunk.class);when(chunk.getWorld()).thenReturn(actor.world);when(chunk.getX()).thenReturn(chunkX);when(chunk.getZ()).thenReturn(chunkZ);when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);when(actor.world.getChunkAt(chunkX,chunkZ)).thenReturn(chunk);when(actor.world.getChunkAt(any(Location.class))).thenReturn(chunk);when(actor.world.isChunkLoaded(chunkX,chunkZ)).thenReturn(true);var worldEntities=new ArrayList<org.bukkit.entity.Entity>();when(actor.world.getEntities()).thenAnswer(call->List.copyOf(worldEntities));
|
||||
var villager=mock(Villager.class);when(villager.getUniqueId()).thenReturn(UUID.randomUUID());when(villager.getLocation()).thenReturn(actor.core.location);var values=new HashMap<String,String>();var pdc=mock(PersistentDataContainer.class);when(villager.getPersistentDataContainer()).thenReturn(pdc);doAnswer(call->{values.put(((NamespacedKey)call.getArgument(0)).getKey(),call.getArgument(2));return null;}).when(pdc).set(any(NamespacedKey.class),eq(PersistentDataType.STRING),anyString());when(pdc.get(any(NamespacedKey.class),eq(PersistentDataType.STRING))).thenAnswer(call->values.get(((NamespacedKey)call.getArgument(0)).getKey()));
|
||||
@@ -54,7 +54,7 @@ class PartyRegistrarNativeTest {
|
||||
var directory=(games.dmg.fantasystick.party.PartyRegistrarDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrars");var registrar=directory.all().getFirst();assertEquals("Central",registrar.name());
|
||||
var old=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,null,null,BigInteger.valueOf(20),true,old.rewardReceipts()),old.revision());f.activate(actor);var management=(games.dmg.fantasystick.party.PartyManagement)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyManagement");management.ready().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);assertTrue(management.active(actor.player,registrar.id()).isPresent());f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price","7"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-registration-price=7"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price","6"});for(int attempt=0;attempt<10&&actor.messages.stream().noneMatch(message->message.contains("Saved party-leader-change-price=6"));attempt++){response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();}f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-registration-price"});f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"config","party-leader-change-price"});var controller=(games.dmg.fantasystick.party.PartyRegistrarController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarController");assertTrue(controller.tagged(villager));
|
||||
int openMenus=actor.inventories.size(),openBooks=actor.books.size();var mainInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);f.dispatcher.accept(mainInteraction);var offhandDuplicate=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.OFF_HAND);f.dispatcher.accept(offhandDuplicate);assertTrue(mainInteraction.isCancelled());assertTrue(offhandDuplicate.isCancelled());assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());assertEquals(Material.WRITABLE_BOOK,actor.inventories.getLast().getItem(11).getType());var vetoedInteraction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);vetoedInteraction.setCancelled(true);f.dispatcher.accept(vetoedInteraction);assertEquals(openMenus+1,actor.inventories.size());assertEquals(openBooks,actor.books.size());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvil=actor.inventories.getLast();var submit=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(submit.getInventory()).thenReturn(anvil);when(submit.getWhoClicked()).thenReturn(actor.player);when(submit.getRawSlot()).thenReturn(2);var anvilView=mock(org.bukkit.inventory.view.AnvilView.class);when(anvilView.getRenameText()).thenReturn(" Cafe\u0301 Heroes ");when(submit.getView()).thenReturn(anvilView);menus.click(submit);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
|
||||
var menus=(games.dmg.fantasystick.party.PartyRegistrarMenus)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"partyRegistrarMenus");var root=actor.inventories.getLast();assertEquals(net.kyori.adventure.text.Component.text("Register a party — 7 coins"),root.getItem(11).getItemMeta().displayName());var openName=mock(org.bukkit.event.inventory.InventoryClickEvent.class);when(openName.getInventory()).thenReturn(root);when(openName.getWhoClicked()).thenReturn(actor.player);when(openName.getCurrentItem()).thenReturn(root.getItem(11));menus.click(openName);var anvilView=(org.bukkit.inventory.view.AnvilView)actor.inventoryViews.getLast();var nativeMenu=(net.minecraft.world.inventory.AnvilMenu)((org.bukkit.craftbukkit.inventory.CraftInventoryView<?,?>)anvilView).getHandle();assertTrue(nativeMenu.setItemName(" Cafe\u0301 Heroes "));var submit=new org.bukkit.event.inventory.InventoryClickEvent(anvilView,org.bukkit.event.inventory.InventoryType.SlotType.RESULT,2,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(submit);assertTrue(submit.isCancelled());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");characters.find(UUID.randomUUID()).join();var parties=(games.dmg.fantasystick.party.CharacterPartyDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characterParties");parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals("Café Heroes",parties.partyOf(old.id()).orElseThrow().displayName());assertEquals(BigInteger.valueOf(13),characters.find(old.id()).join().orElseThrow().coins());
|
||||
var member=new FantasyStickLifecycleNativeTest.Actor(data);member.core.location.setWorld(actor.world);f.players.put(member.core.id,member.player);f.activate(member);invitedPlayer=member.core.id;invitedCharacter=member.core.record.id();invitedSession=((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(member.core.id).orElseThrow().session();var leaderRoot=actor.inventories.getLast();click(menus,actor.player,leaderRoot,10,false);var eligible=actor.inventories.getLast();click(menus,actor.player,eligible,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();click(menus,actor.player,actor.inventories.getLast(),22,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.invitationsFor(member.core.record.id()).isEmpty());click(menus,actor.player,actor.inventories.getLast(),10,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));var memberRoot=member.inventories.getLast();click(menus,member.player,memberRoot,15,false);var invitationMenu=member.inventories.getLast();click(menus,member.player,invitationMenu,0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isPresent());controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);assertEquals(List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — 6 coins")),actor.inventories.getLast().getItem(0).getItemMeta().lore());click(menus,actor.player,actor.inventories.getLast(),0,true);characters.find(UUID.randomUUID()).join();parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(member.core.record.id(),parties.partyOf(old.id()).orElseThrow().leader());assertEquals(BigInteger.valueOf(7),characters.find(old.id()).join().orElseThrow().coins());
|
||||
controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),13,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(old.id()).isEmpty());controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,true);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),10,false);click(menus,member.player,member.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),15,false);click(menus,actor.player,actor.inventories.getLast(),0,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND));click(menus,actor.player,actor.inventories.getLast(),13,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();controller.interact(new PlayerInteractEntityEvent(member.player,villager,EquipmentSlot.HAND));click(menus,member.player,member.inventories.getLast(),16,false);parties.defeat(UUID.randomUUID()).join();response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(parties.partyOf(member.core.record.id()).isEmpty());
|
||||
f.command.getExecutor().onCommand(actor.player,f.command,"fantasystick",new String[]{"party-registrar","list"});assertTrue(actor.messages.stream().anyMatch(message->message.contains(registrar.id().toString())&&message.contains("Central")));when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[]{villager});controller.reconcile(chunk);verify(actor.world,times(1)).spawn(any(Location.class),eq(Villager.class),any(java.util.function.Consumer.class));var damage=new org.bukkit.event.entity.EntityDamageEvent(villager,org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE,1);controller.damage(damage);assertTrue(damage.isCancelled());var combust=new org.bukkit.event.entity.EntityCombustEvent(villager,5);controller.combust(combust);assertTrue(combust.isCancelled());var interaction=new PlayerInteractEntityEvent(actor.player,villager,EquipmentSlot.HAND);controller.interact(interaction);assertTrue(interaction.isCancelled());var leash=new org.bukkit.event.entity.PlayerLeashEntityEvent(villager,actor.player,actor.player,EquipmentSlot.HAND);controller.leash(leash);assertTrue(leash.isCancelled());
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.passive.LowLightVision;
|
||||
import games.dmg.fantasystick.passive.SpeedI;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import org.bukkit.craftbukkit.potion.CraftPotionUtil;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered session/downing path with pinned native potion updates, not unconditional map removal. */
|
||||
class PassivePotionCleanupNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredDowningCancellationMustNotOrphanEitherPassiveAfterAcceptDeath() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = actor(f);
|
||||
var nativeEffects = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f);
|
||||
assertOwned(nativeEffects);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
down(f, actor);
|
||||
for (int tick = 0; tick < 4; tick++) sweep(f);
|
||||
assertOwned(nativeEffects);
|
||||
assertTrue(nativeEffects.decisions.stream().filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED
|
||||
&& event.isCancelled()).count() >= 2, "Registered downed containment must really deny native removal for both types");
|
||||
assertTrue(rows("effect.ended").isEmpty(), "Cancelled cleanup is not an acknowledged effect end");
|
||||
acceptDeath(f, actor);
|
||||
assertTrue(sessions(f).downed(actor.core.id).isEmpty());
|
||||
sweep(f);
|
||||
assertNull(nativeEffects.current(PotionEffectType.SPEED), "Accepted final death must retry the retained Speed cleanup lease");
|
||||
assertNull(nativeEffects.current(PotionEffectType.NIGHT_VISION), "Accepted final death must retry the retained vision cleanup lease");
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
for (int tick = 0; tick < 4; tick++) sweep(f);
|
||||
assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size());
|
||||
assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualPhoenixMenuRescueResumesTheSameLayersThenSheetExitCleansThem() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
var original = sessions(f).active(actor.core.id).orElseThrow();
|
||||
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
|
||||
inventory.add(original.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN, java.util.UUID.randomUUID()).join();
|
||||
down(f, actor); sweep(f); assertOwned(port);
|
||||
clickMenu(f, actor, 13);
|
||||
ReskillEventsNativeTest.await(f, () -> sessions(f).current(original));
|
||||
for (int tick = 0; tick < 4; tick++) sweep(f);
|
||||
assertOwned(port); assertEquals(2, rows("effect.applied").size()); assertTrue(rows("effect.ended").isEmpty());
|
||||
assertEquals(java.math.BigInteger.ZERO, inventory.quantity(original.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));
|
||||
exit(f, actor); sweep(f);
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
|
||||
void unavailablePostRemovalReadCannotDiscardEvidenceOrClaimSuccess(boolean removalCancelled) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
exit(f, actor); port.cancelRemoval = removalCancelled; port.failAfterRemoval = true;
|
||||
sweep(f);
|
||||
assertEquals(2, port.failedObservations, "Both actual post-removal reads must be unavailable");
|
||||
if (removalCancelled) assertOwned(port);
|
||||
else { assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION)); }
|
||||
assertTrue(rows("effect.ended").isEmpty(), "Unconfirmed cleanup must retain original application evidence");
|
||||
port.cancelRemoval = false; port.failAfterRemoval = false; sweep(f);
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancellationRetryDoesNotDependOnAnAuditBridge() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor);
|
||||
// The installed owners have not been pulsed; these current-session owners have no bridge.
|
||||
try (var speed = new SpeedI(sessions(f)); var vision = new LowLightVision(sessions(f))) {
|
||||
speed.sweep(); vision.sweep(); assertOwned(port);
|
||||
exit(f, actor); port.cancelRemoval = true;
|
||||
speed.sweep(); vision.sweep(); assertOwned(port);
|
||||
port.cancelRemoval = false; speed.sweep(); vision.sweep();
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
assertTrue(rows("effect.applied").isEmpty()); assertTrue(rows("effect.ended").isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aReplacementSessionCannotInheritTheCancelledOwnersApplicationIdentity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
var original = sessions(f).active(actor.core.id).orElseThrow();
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
var sheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
port.cancelRemoval = true; exit(f, actor); sweep(f); assertOwned(port);
|
||||
actor.core.inventory.setItemInOffHand(sheet); f.activate(actor);
|
||||
var replacement = sessions(f).active(actor.core.id).orElseThrow();
|
||||
assertNotEquals(original.session(), replacement.session()); assertEquals(original.character(), replacement.character());
|
||||
sweep(f); assertOwned(port);
|
||||
assertEquals(2, rows("effect.applied").size()); assertTrue(rows("effect.ended").isEmpty());
|
||||
port.cancelRemoval = false; sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 4 && rows("effect.ended").size() == 2);
|
||||
var contexts = rows("effect.applied").stream().map(row -> row.getAsJsonObject("data").get("owner_session_id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
assertEquals(java.util.Set.of(original.session().toString(), replacement.session().toString()), contexts);
|
||||
exit(f, actor); sweep(f); ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 4); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void transferredHolderIsIndependentOfTheOldHoldersDeniedCleanup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var oldPort = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(oldPort);
|
||||
var sheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
oldPort.cancelRemoval = true; exit(f, actor); sweep(f); assertOwned(oldPort);
|
||||
var holder = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(holder.core.id, holder.player);
|
||||
var newPort = new NativePort(f, holder); holder.core.inventory.setItemInOffHand(sheet);
|
||||
f.activate(holder); sweep(f); assertOwned(newPort); assertOwned(oldPort);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 4);
|
||||
oldPort.cancelRemoval = false; sweep(f);
|
||||
assertNull(oldPort.current(PotionEffectType.SPEED)); assertNull(oldPort.current(PotionEffectType.NIGHT_VISION)); assertOwned(newPort);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
assertTrue(rows("effect.ended").stream().allMatch(row -> row.getAsJsonObject("data").get("target_player_id").getAsString().equals(actor.core.id.toString())));
|
||||
exit(f, holder); sweep(f); ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 4); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
|
||||
void foreignReplacementBeforeOrDuringRetryAndUnrelatedTypesArePreserved(boolean reentrant) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
var unrelated = List.of(new PotionEffect(PotionEffectType.BLINDNESS, 400, 1, true, true, false),
|
||||
new PotionEffect(PotionEffectType.DARKNESS, 400, 0, false, true, true));
|
||||
unrelated.forEach(effect -> assertTrue(port.add(effect)));
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
down(f, actor); sweep(f); assertOwned(port);
|
||||
acceptDeath(f, actor);
|
||||
var foreign = List.of(new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION, 2, true, true, false),
|
||||
new PotionEffect(PotionEffectType.NIGHT_VISION, PotionEffect.INFINITE_DURATION, 1, true, true, false));
|
||||
if (reentrant) port.afterRemoval = type -> assertTrue(port.add(foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow()));
|
||||
else foreign.forEach(effect -> assertTrue(port.add(effect)));
|
||||
sweep(f);
|
||||
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()));
|
||||
for (var effect : unrelated) assertEquals(effect, port.current(effect.getType()));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
assertTrue(rows("effect.ended").stream().noneMatch(row -> row.getAsJsonObject("data").get("unrelated_effect_restored").getAsBoolean()));
|
||||
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aLateFinalCancellationCannotBeMistakenForAnAcceptedNativeRemoval() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
exit(f, actor); port.lateCancelRemoval = true; sweep(f); sweep(f); assertOwned(port);
|
||||
assertTrue(rows("effect.ended").isEmpty(), "Final native flags, not the earlier observer decision, are authoritative");
|
||||
port.lateCancelRemoval = false; sweep(f);
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void deferredNativeRemovalPreservesAnIdenticalForeignReplacement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
var foreign = List.of(port.current(PotionEffectType.SPEED), port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
exit(f, actor); port.ticking(true); int before = port.decisions.size(); sweep(f);
|
||||
assertOwned(port); assertEquals(before, port.decisions.size()); assertTrue(port.deferred.isEmpty());
|
||||
assertTrue(rows("effect.ended").isEmpty(), "An unsafe native phase neither submits removal nor claims cleanup");
|
||||
port.ticking(false);
|
||||
port.afterRemoval = type -> assertTrue(actor.player.addPotionEffect(
|
||||
foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow(), true));
|
||||
sweep(f); // Retry only once native ticking is complete; the accepted removal can expose a replacement.
|
||||
port.afterRemoval = ignored -> {};
|
||||
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void unsafeTickCannotQueueRemovalOfALaterForeignLayer(boolean identical) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
exit(f, actor); port.ticking(true); sweep(f);
|
||||
var foreign = new HashMap<PotionEffectType, MobEffectInstance>();
|
||||
for (var type : List.of(PotionEffectType.SPEED, PotionEffectType.NIGHT_VISION)) {
|
||||
var replacement = CraftPotionUtil.fromBukkit(new PotionEffect(type, PotionEffect.INFINITE_DURATION,
|
||||
identical ? 0 : 2, false, false, true));
|
||||
foreign.put(type, replacement); port.effects.put(type, replacement);
|
||||
}
|
||||
port.ticking(false);
|
||||
var queued = List.copyOf(port.deferred); port.deferred.clear(); queued.forEach(Runnable::run);
|
||||
for (var entry : foreign.entrySet()) assertSame(entry.getValue(), port.effects.get(entry.getKey()),
|
||||
"An earlier by-type request cannot remove a foreign layer installed before its native dispatch");
|
||||
sweep(f);
|
||||
for (var entry : foreign.entrySet()) assertSame(entry.getValue(), port.effects.get(entry.getKey()),
|
||||
"Even an identical replacement is not the old cleanup lease");
|
||||
assertTrue(queued.isEmpty(), "Unsafe native ticking must not enqueue an owned removal");
|
||||
NativeTestAwait.auditUntil(f, () -> rows("effect.ended"), rows -> rows.size() == 2, "Both replaced leases are acknowledged once");
|
||||
assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void deferredDecisionCannotAuthorizeCleanupDuringReentrantSweepOrClose(boolean close) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
var captured = captureAudits(f);
|
||||
f.activate(actor); sweep(f); assertOwned(port); assertEquals(2, captured.size());
|
||||
exit(f, actor); port.ticking(true); sweep(f);
|
||||
// An independent native removal may also be queued. Observing its event is not our mutation proof.
|
||||
actor.player.removePotionEffect(PotionEffectType.SPEED); actor.player.removePotionEffect(PotionEffectType.NIGHT_VISION);
|
||||
class Reentry implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void removal(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() != actor.player || event.getAction() != EntityPotionEffectEvent.Action.REMOVED) return;
|
||||
if (close) {
|
||||
((SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).close();
|
||||
((LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision")).close();
|
||||
} else sweep(f);
|
||||
event.setCancelled(true); // Final decision occurs after the reentrant lifecycle operation.
|
||||
}
|
||||
}
|
||||
var reentry = new Reentry(); f.dispatcher.register(reentry, f.plugin);
|
||||
port.ticking(false); var queued = List.copyOf(port.deferred); port.deferred.clear(); queued.forEach(Runnable::run);
|
||||
assertOwned(port);
|
||||
assertEquals(0, captured.stream().filter(event -> event.type().equals("effect.ended")).count(),
|
||||
"An in-flight accepted decision followed by cancellation cannot claim completed cleanup");
|
||||
org.bukkit.event.HandlerList.unregisterAll(reentry);
|
||||
if (!close) sweep(f);
|
||||
NativeTestAwait.until(f, () -> captured.stream().filter(event -> event.type().equals("effect.ended")).count() == 2,
|
||||
"After dispatch ends, retained cleanup finishes exactly once");
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
sweep(f); assertEquals(4, captured.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void aThrowingRemovalApiCannotTurnAnAcceptedDecisionIntoMutationProof() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
exit(f, actor); port.failRemovalAfterDecision = true; sweep(f); assertOwned(port);
|
||||
assertTrue(rows("effect.ended").isEmpty());
|
||||
port.failRemovalAfterDecision = false; sweep(f); sweep(f);
|
||||
assertNull(port.current(PotionEffectType.SPEED), "A throwing native call must retain cleanup responsibility, not discard the still-present layer");
|
||||
assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
|
||||
void identicalForeignLayerAfterAcceptedRemovalIsNotTheOldLeaseEvenIfObservationFails(boolean observationUnavailable) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
|
||||
f.activate(actor); sweep(f); assertOwned(port);
|
||||
var foreign = List.of(port.current(PotionEffectType.SPEED), port.current(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
down(f, actor); sweep(f); acceptDeath(f, actor);
|
||||
int start = port.decisions.size();
|
||||
port.afterRemoval = type -> assertTrue(actor.player.addPotionEffect(
|
||||
foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow(), true));
|
||||
port.failAfterRemoval = observationUnavailable; sweep(f);
|
||||
assertEquals(2, port.decisions.subList(start, port.decisions.size()).stream()
|
||||
.filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED && !event.isCancelled()).count());
|
||||
assertEquals(2, port.decisions.subList(start, port.decisions.size()).stream()
|
||||
.filter(event -> event.getAction() == EntityPotionEffectEvent.Action.ADDED && !event.isCancelled()).count());
|
||||
if (observationUnavailable) assertTrue(rows("effect.ended").isEmpty(), "An unavailable post-read cannot claim cleanup confirmation");
|
||||
port.afterRemoval = ignored -> {}; port.failAfterRemoval = false; sweep(f);
|
||||
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()),
|
||||
"An actually removed/replaced identical foreign layer must never be deleted as the old owner's retry");
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {20, 2})
|
||||
void retainedApiPriorSnapshotCannotBeRestoredBeforeRemovalOrAfterItsBudgetExpires(int priorTicks) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new ApiSnapshotPort(f, actor);
|
||||
// Supplemental Bukkit snapshot contract only: the pinned native constructor clamps
|
||||
// amplifier -1 to 0, so this is NOT a reachable below-Speed-I native tier assertion.
|
||||
var prior = new PotionEffect(PotionEffectType.SPEED, priorTicks, -1, true, true, false);
|
||||
port.effects.put(PotionEffectType.SPEED, prior);
|
||||
f.activate(actor); sweep(f);
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
|
||||
down(f, actor);
|
||||
for (int tick = 0; tick < 4; tick++) sweep(f);
|
||||
assertEquals(0, port.restoreRequests, "Cancellation must retain the prior snapshot without attempting restoration");
|
||||
assertTrue(port.effects.get(PotionEffectType.SPEED).isInfinite()); assertTrue(rows("effect.ended").isEmpty());
|
||||
acceptDeath(f, actor); sweep(f);
|
||||
boolean live = priorTicks == 20;
|
||||
if (live) assertEquals(new PotionEffect(PotionEffectType.SPEED, 15, -1, true, true, false), port.effects.get(PotionEffectType.SPEED));
|
||||
else assertFalse(port.effects.containsKey(PotionEffectType.SPEED), "Expired displaced snapshot must not be resurrected");
|
||||
assertFalse(port.effects.containsKey(PotionEffectType.NIGHT_VISION));
|
||||
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
|
||||
var end = rows("effect.ended").stream().map(row -> row.getAsJsonObject("data"))
|
||||
.filter(values -> values.get("passive_id").getAsString().equals("speed_i")).findFirst().orElseThrow();
|
||||
assertEquals(live, end.get("unrelated_effect_restored").getAsBoolean());
|
||||
sweep(f); assertEquals(live ? 1 : 0, port.restoreRequests); assertEquals(2, rows("effect.ended").size()); assertPairs();
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unsafeTickDefersApplicationWithoutQueueingOrClaimingANativeLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
|
||||
f.activate(actor); port.ticking(true); sweep(f);
|
||||
assertTrue(port.effects.isEmpty()); assertTrue(port.deferred.isEmpty()); assertTrue(captured.isEmpty());
|
||||
port.ticking(false); sweep(f); assertOwned(port); assertEquals(2, captured.size());
|
||||
exit(f, actor); sweep(f);
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
assertEquals(4, captured.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ownerCloseImmediatelyCleansConfirmedLayersAndDisposesItsTimerWithoutFixtureCleanup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
|
||||
f.activate(actor); sweep(f); assertOwned(port); assertEquals(2, captured.size());
|
||||
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
|
||||
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
|
||||
var speedTask = (org.bukkit.scheduler.BukkitTask) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(speed, "effect"), "sweepTask");
|
||||
var visionTask = (org.bukkit.scheduler.BukkitTask) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(vision, "effect"), "sweepTask");
|
||||
speed.close(); vision.close();
|
||||
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
|
||||
assertEquals(4, captured.size()); assertEquals(0, passiveListeners(f));
|
||||
speed.close(); vision.close(); sweep(f); assertEquals(4, captured.size());
|
||||
verify(speedTask, times(1)).cancel(); verify(visionTask, times(1)).cancel();
|
||||
assertTrue(f.plugin.isEnabled(), "Assertions precede fixture/plugin-wide cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialInstallFailureCannotLeaveAPotionListenerOrTimerBehind() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); int timers = f.timers.size(); long listeners = passiveListeners(f);
|
||||
var owner = new SpeedI(sessions(f)); f.failRegistrationAt = "presentation-timer";
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, () -> owner.install(f.plugin));
|
||||
assertEquals(listeners, passiveListeners(f), "Failed installation cannot leave a listener for fixture shutdown to mask");
|
||||
assertEquals(timers, f.timers.size());
|
||||
} finally { f.failRegistrationAt = null; owner.close(); }
|
||||
assertTrue(f.plugin.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void permanentlyCancelledCloseRetainsUnresolvedOwnershipWithoutFalseSuccessOrForcedRemoval() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
|
||||
f.activate(actor); sweep(f); assertOwned(port); port.cancelRemoval = true;
|
||||
for (String owner : List.of("speedI", "lowLightVision")) {
|
||||
var wrapper = (AutoCloseable) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, owner); wrapper.close();
|
||||
var effect = FantasyStickLifecycleNativeTest.Fixture.field(wrapper, "effect");
|
||||
assertEquals(1, ((Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(effect, "states")).size(),
|
||||
"An unresolved lease is retained, not silently discarded on close");
|
||||
}
|
||||
assertOwned(port); assertEquals(2, captured.size(), "Only the original applications are acknowledged");
|
||||
assertEquals(0, passiveListeners(f));
|
||||
assertTrue(port.decisions.stream().filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
|
||||
.allMatch(EntityPotionEffectEvent::isCancelled));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<games.dmg.fantasystick.events.EventInput> captureAudits(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var captured = new ArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
var bridge = new games.dmg.fantasystick.events.PassivePotionEventBridge(captured::add, java.time.Clock.systemUTC());
|
||||
for (String owner : List.of("speedI", "lowLightVision")) {
|
||||
var effect = FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, owner), "effect");
|
||||
var audit = effect.getClass().getDeclaredField("events"); audit.setAccessible(true); audit.set(effect, bridge);
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
private static long passiveListeners(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return java.util.Arrays.stream(EntityPotionEffectEvent.getHandlerList().getRegisteredListeners())
|
||||
.filter(listener -> listener.getPlugin() == f.plugin
|
||||
&& listener.getListener().getClass().getName().equals("games.dmg.fantasystick.passive.PersistentPotionEffect")).count();
|
||||
}
|
||||
|
||||
static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
clickMenu(f, actor, 11);
|
||||
ReskillEventsNativeTest.await(f, () -> sessions(f).active(actor.core.id).isEmpty());
|
||||
DownedSessionNativeTest.awaitTail(f, actor);
|
||||
assertTrue(sessions(f).downed(actor.core.id).isEmpty());
|
||||
}
|
||||
static void clickMenu(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, int slot) {
|
||||
var menu = actor.inventories.getLast();
|
||||
var view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
|
||||
@Override public org.bukkit.inventory.Inventory getTopInventory() { return menu; }
|
||||
@Override public org.bukkit.inventory.PlayerInventory getBottomInventory() { return actor.core.inventory; }
|
||||
@Override public org.bukkit.entity.HumanEntity getPlayer() { return actor.player; }
|
||||
@Override public org.bukkit.event.inventory.InventoryType getType() { return menu.getType(); }
|
||||
@Override public org.bukkit.inventory.ItemStack getCursor() { return actor.core.cursor; }
|
||||
@Override public void setCursor(org.bukkit.inventory.ItemStack item) { actor.core.cursor = item; }
|
||||
@Override public String getTitle() { return "Downed"; }
|
||||
@Override public String getOriginalTitle() { return getTitle(); }
|
||||
@Override public void setTitle(String title) { throw new AssertionError("Unexpected death-menu title mutation"); }
|
||||
@Override public org.bukkit.inventory.MenuType getMenuType() { return null; }
|
||||
};
|
||||
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
|
||||
slot, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
|
||||
f.dispatcher.accept(click); assertTrue(click.isCancelled());
|
||||
}
|
||||
private static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
|
||||
ReskillEventsNativeTest.await(f, () -> sessions(f).active(actor.core.id).isEmpty());
|
||||
f.awaitCharacterAndReceipt(actor); PartyBookNativeTest.drain(f);
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
SpeedINativeTest.withDex(data, actor, List.of("speed_i", "low_light_vision", ""));
|
||||
LowLightVisionNativeTest.withWis(data, actor, List.of("speed_i", "low_light_vision", ""));
|
||||
f.players.put(actor.core.id, actor.player);
|
||||
return actor;
|
||||
}
|
||||
private static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
}
|
||||
private static void sweep(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
((SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep();
|
||||
((LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision")).sweep();
|
||||
}
|
||||
private static void assertOwned(NativePort port) {
|
||||
for (var type : List.of(PotionEffectType.SPEED, PotionEffectType.NIGHT_VISION)) {
|
||||
var effect = port.current(type); assertNotNull(effect); assertTrue(effect.isInfinite()); assertEquals(0, effect.getAmplifier());
|
||||
}
|
||||
}
|
||||
static void down(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.health = 0;
|
||||
doReturn(null).when(actor.player).getLastDamageCause(); doReturn(null).when(actor.player).getFishHook();
|
||||
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
|
||||
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(actor.player, source, new ArrayList<>(), 0,
|
||||
net.kyori.adventure.text.Component.empty(), true);
|
||||
f.dispatcher.accept(death); assertTrue(death.isCancelled());
|
||||
ReskillEventsNativeTest.await(f, () -> sessions(f).downed(actor.core.id).isPresent() && !actor.inventories.isEmpty());
|
||||
assertFalse(sessions(f).current(sessions(f).active(actor.core.id).orElseThrow()));
|
||||
}
|
||||
private List<com.google.gson.JsonObject> rows(String type) {
|
||||
try {
|
||||
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("." + type)
|
||||
&& row.getAsJsonObject("data").has("passive_id")).toList();
|
||||
} catch (Exception failure) { throw new IllegalStateException("Cannot parse passive cleanup exports", failure); }
|
||||
}
|
||||
private void assertPairs() throws Exception {
|
||||
var applied = rows("effect.applied"); var ended = rows("effect.ended");
|
||||
assertEquals(applied.stream().map(row -> row.getAsJsonObject("data").get("effect_id")).collect(java.util.stream.Collectors.toSet()),
|
||||
ended.stream().map(row -> row.getAsJsonObject("data").get("effect_id")).collect(java.util.stream.Collectors.toSet()));
|
||||
for (var start : applied) {
|
||||
var original = start.getAsJsonObject("data");
|
||||
var end = ended.stream().map(row -> row.getAsJsonObject("data"))
|
||||
.filter(value -> value.get("effect_id").equals(original.get("effect_id"))).findFirst().orElseThrow();
|
||||
for (String key : List.of("application_id", "owner_character_id", "owner_session_id", "target_player_id", "passive_id"))
|
||||
assertEquals(original.get(key), end.get(key));
|
||||
assertEquals("acknowledged", end.get("native_publication").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplemental API-snapshot port; native-tier fidelity is exercised separately by NativePort. */
|
||||
private static final class ApiSnapshotPort {
|
||||
final Map<PotionEffectType, PotionEffect> effects;
|
||||
int restoreRequests;
|
||||
ApiSnapshotPort(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
effects = SpeedINativeTest.effects(actor.player);
|
||||
doAnswer(call -> effects.get(call.getArgument(0))).when(actor.player).getPotionEffect(any(PotionEffectType.class));
|
||||
doAnswer(call -> List.copyOf(effects.values())).when(actor.player).getActivePotionEffects();
|
||||
doAnswer(call -> {
|
||||
PotionEffect requested = call.getArgument(0); var old = effects.get(requested.getType());
|
||||
if (requested.getAmplifier() < 0) restoreRequests++;
|
||||
var event = new EntityPotionEffectEvent(actor.player, old, requested, null, EntityPotionEffectEvent.Cause.PLUGIN,
|
||||
old == null ? EntityPotionEffectEvent.Action.ADDED : EntityPotionEffectEvent.Action.CHANGED, true);
|
||||
f.dispatcher.accept(event);
|
||||
if (event.isCancelled()) return false;
|
||||
effects.put(requested.getType(), requested); return true;
|
||||
}).when(actor.player).addPotionEffect(any(PotionEffect.class), anyBoolean());
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0); var old = effects.get(type);
|
||||
if (old != null) {
|
||||
var event = new EntityPotionEffectEvent(actor.player, old, null, null, EntityPotionEffectEvent.Cause.PLUGIN,
|
||||
EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
f.dispatcher.accept(event); if (!event.isCancelled()) effects.remove(type);
|
||||
}
|
||||
return null;
|
||||
}).when(actor.player).removePotionEffect(any(PotionEffectType.class));
|
||||
}
|
||||
}
|
||||
|
||||
static final class NativePort implements org.bukkit.event.Listener {
|
||||
boolean cancelRemoval, lateCancelRemoval, failAfterRemoval, deferRemoval, failRemovalAfterDecision;
|
||||
final List<Runnable> deferred = new ArrayList<>();
|
||||
int failedObservations;
|
||||
final java.util.Set<PotionEffectType> unavailableReads = new java.util.HashSet<>();
|
||||
java.util.function.Consumer<PotionEffectType> afterRemoval = ignored -> {};
|
||||
final FantasyStickLifecycleNativeTest.Fixture fixture;
|
||||
final FantasyStickLifecycleNativeTest.Actor actor;
|
||||
final Map<PotionEffectType, MobEffectInstance> effects = new HashMap<>();
|
||||
final List<EntityPotionEffectEvent> decisions = new ArrayList<>();
|
||||
NativePort(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
this.fixture = fixture; this.actor = actor;
|
||||
doAnswer(call -> effects.values().stream().filter(effect -> effect.getEffect().equals(call.getArgument(0)))
|
||||
.findFirst().orElse(null)).when(actor.nativePlayer).getEffect(any());
|
||||
fixture.dispatcher.register(this, fixture.plugin);
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0);
|
||||
if (unavailableReads.remove(type)) { failedObservations++; throw new IllegalStateException("Post-removal native read unavailable"); }
|
||||
return current(type);
|
||||
}).when(actor.player).getPotionEffect(any(PotionEffectType.class));
|
||||
doAnswer(call -> effects.values().stream().map(CraftPotionUtil::toBukkit).toList()).when(actor.player).getActivePotionEffects();
|
||||
doAnswer(call -> add(call.getArgument(0))).when(actor.player).addPotionEffect(any(PotionEffect.class), anyBoolean());
|
||||
doAnswer(call -> add(call.getArgument(0))).when(actor.player).addPotionEffect(any(PotionEffect.class));
|
||||
doAnswer(call -> {
|
||||
PotionEffectType type = call.getArgument(0);
|
||||
if (deferRemoval) deferred.add(() -> remove(type)); else remove(type);
|
||||
return null;
|
||||
}).when(actor.player).removePotionEffect(any(PotionEffectType.class));
|
||||
}
|
||||
void ticking(boolean value) throws Exception {
|
||||
var field = net.minecraft.world.entity.LivingEntity.class.getDeclaredField("tickingEffects"); field.setAccessible(true);
|
||||
field.setBoolean(actor.nativePlayer, value); deferRemoval = value;
|
||||
}
|
||||
void milk() { for (var type : List.copyOf(effects.keySet())) remove(type, EntityPotionEffectEvent.Cause.MILK); }
|
||||
private void remove(PotionEffectType type) { remove(type, EntityPotionEffectEvent.Cause.PLUGIN); }
|
||||
private void remove(PotionEffectType type, EntityPotionEffectEvent.Cause cause) {
|
||||
var old = current(type);
|
||||
if (old == null) return;
|
||||
var event = new EntityPotionEffectEvent(actor.player, old, null, null, cause,
|
||||
EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
fixture.dispatcher.accept(event); decisions.add(event);
|
||||
if (failRemovalAfterDecision) throw new IllegalStateException("Native removal failed after decision, before mutation");
|
||||
if (!event.isCancelled()) { effects.remove(type); afterRemoval.accept(type); }
|
||||
if (failAfterRemoval) unavailableReads.add(type);
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void lateCancellation(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == actor.player && lateCancelRemoval && event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
|
||||
event.setCancelled(true);
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancellation(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == actor.player && cancelRemoval && event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
|
||||
event.setCancelled(true);
|
||||
}
|
||||
PotionEffect current(PotionEffectType type) {
|
||||
var effect = effects.get(type); return effect == null ? null : CraftPotionUtil.toBukkit(effect);
|
||||
}
|
||||
boolean add(PotionEffect requested) {
|
||||
if (deferRemoval) { deferred.add(() -> add(requested)); return true; } // Pinned native ticking queues additions too.
|
||||
var old = effects.get(requested.getType()); var candidate = CraftPotionUtil.fromBukkit(requested);
|
||||
boolean override = old != null && new MobEffectInstance(old).update(candidate);
|
||||
var event = new EntityPotionEffectEvent(actor.player, current(requested.getType()), requested, null,
|
||||
EntityPotionEffectEvent.Cause.PLUGIN, old == null ? EntityPotionEffectEvent.Action.ADDED : EntityPotionEffectEvent.Action.CHANGED, override);
|
||||
fixture.dispatcher.accept(event); decisions.add(event);
|
||||
if (event.isCancelled()) return false;
|
||||
if (old == null) { effects.put(requested.getType(), candidate); return true; }
|
||||
if (event.isOverride()) { old.update(candidate); return true; }
|
||||
if (old.getAmplifier() > candidate.getAmplifier() && old.isShorterDurationThan(candidate)) {
|
||||
if (old.hiddenEffect == null) old.hiddenEffect = new MobEffectInstance(candidate);
|
||||
else old.hiddenEffect.update(candidate);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PathSpeedConfigurationNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@Test void registeredAdministratorReadUpdateAndCompletionUseDurableRoadPolicy() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks"});
|
||||
assertTrue(sender.messages.getLast().contains("path-speed-lease-ticks=20"), "Registered config read reports the effective road lease");
|
||||
assertEquals(List.of("path-speed-enabled", "path-speed-lease-ticks"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-"}));
|
||||
assertEquals(List.of("false", "true"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", ""}));
|
||||
assertEquals(List.of("20"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", ""}));
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, actor); PathSpeedNativeTest.support(actor);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); PathSpeedNativeTest.pulse(f);
|
||||
assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", "1"});
|
||||
assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration(), "Queued save alone cannot change native policy");
|
||||
f.tick();
|
||||
assertEquals(20, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration(), "A shorter refused refresh preserves the captured existing finite budget");
|
||||
actor.core.location.setY(65); PathSpeedNativeTest.pulse(f);
|
||||
actor.core.location.setY(64.9375); PathSpeedNativeTest.pulse(f);
|
||||
assertEquals(1, effects.current(org.bukkit.potion.PotionEffectType.SPEED).getDuration());
|
||||
assertTrue(sender.messages.getLast().contains("Saved path-speed-lease-ticks=1"));
|
||||
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml")).contains("path-speed-lease-ticks: 1"));
|
||||
assertEquals(List.of("1", "20"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", ""}));
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); f.tick();
|
||||
assertTrue(sender.messages.getLast().contains("Saved path-speed-enabled=false"));
|
||||
assertNull(effects.current(org.bukkit.potion.PotionEffectType.SPEED));
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks"});
|
||||
assertTrue(sender.messages.getLast().contains("=1"));
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled"});
|
||||
assertTrue(sender.messages.getLast().contains("=false"));
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void lostCompletionPermissionCannotPublishItsPolicyOrLeakThroughASubsequentDifferentKey(boolean subsequentLease) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); PathSpeedNativeTest.support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); PathSpeedNativeTest.pulse(f);
|
||||
var original = effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED);
|
||||
var settings = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings");
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"});
|
||||
sender.allowed = false; f.tick();
|
||||
if (subsequentLease) {
|
||||
sender.allowed = true;
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", "7"}); f.tick();
|
||||
assertEquals(7, settings.settings().leaseTicks());
|
||||
}
|
||||
assertTrue(settings.settings().enabled(), "Native completion must recheck authority, and another key cannot publish the interrupted toggle");
|
||||
assertSame(original, effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedRoadWriteDrainsBeforeNewLifetimeStartsAndOldCallbackCannotPublish() throws Exception {
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
try {
|
||||
f.plugin.setEnabled(true);
|
||||
var old = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings");
|
||||
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(old, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
worker.execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } });
|
||||
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"});
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
|
||||
release.countDown();
|
||||
ReskillEventsNativeTest.await(f, () -> FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions") != null);
|
||||
var current = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings");
|
||||
assertNotSame(old, current); assertFalse(current.settings().enabled()); assertTrue(old.settings().enabled());
|
||||
assertFalse(sender.messages.stream().anyMatch(message -> message.startsWith("Saved path-speed-enabled")));
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredValidationPermissionAndReadOnlyCompletionCannotMutateRoadPolicy() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var setting = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings");
|
||||
for (String value : List.of("0", "201", "-1", "1.5", "", "999999999999999999999")) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-lease-ticks", value});
|
||||
assertTrue(sender.messages.getLast().contains("1–200")); assertEquals(20, setting.settings().leaseTicks());
|
||||
}
|
||||
for (String value : List.of("TRUE", "yes", "1", "")) {
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", value});
|
||||
assertTrue(sender.messages.getLast().contains("true or false")); assertTrue(setting.settings().enabled());
|
||||
}
|
||||
var file = data.resolve("config.yml"); boolean existed = java.nio.file.Files.exists(file);
|
||||
var before = existed ? java.nio.file.Files.readAllBytes(file) : new byte[0];
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{""}).contains("config"));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"con"}));
|
||||
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "t"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "true", ""}).isEmpty());
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "not-a-key", ""}).isEmpty());
|
||||
assertEquals(existed, java.nio.file.Files.exists(file));
|
||||
if (existed) assertArrayEquals(before, java.nio.file.Files.readAllBytes(file));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fstickadmin", new String[]{"config", "path-speed-"}).isEmpty());
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"});
|
||||
assertTrue(sender.messages.getLast().contains("Permission denied")); assertTrue(setting.settings().enabled());
|
||||
sender.allowed = true;
|
||||
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false", "extra"});
|
||||
assertTrue(sender.messages.getLast().contains("Usage")); assertTrue(setting.settings().enabled());
|
||||
}
|
||||
}
|
||||
@Test void failedRegisteredSaveLeavesEffectivePolicyAndActiveRoadLayerUnchanged() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, actor); PathSpeedNativeTest.support(actor);
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor); PathSpeedNativeTest.pulse(f);
|
||||
var original = effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED);
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid()); f.command.execute(sender.port, "fstickadmin", new String[]{"config", "path-speed-enabled", "false"}); f.tick();
|
||||
}
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
assertSame(original, effects.effects.get(org.bukkit.potion.PotionEffectType.SPEED));
|
||||
var settings = (games.dmg.fantasystick.persistence.PathSpeedSettings) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedSettings");
|
||||
assertTrue(settings.settings().enabled());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"path-speed-enabled: 'true'", "path-speed-enabled: 1", "path-speed-lease-ticks: 0", "path-speed-lease-ticks: 201", "path-speed-lease-ticks: 1.5", "path-speed-lease-ticks: '20'"})
|
||||
void invalidTypedStartupIsRefusedBeforeFeatureInstallation(String contents) throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), contents + "\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty()); assertTrue(f.pulses.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static games.dmg.fantasystick.PathSpeedNativeTest.*;
|
||||
import java.nio.file.Path;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
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;
|
||||
|
||||
class PathSpeedInterruptionNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||
void realMilkDecisionCreatesANewApplicationOnlyAfterAcceptedRemoval(boolean veto) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var original = effects.effects.get(PotionEffectType.SPEED);
|
||||
ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 1);
|
||||
effects.lateCancelRemoval = veto; effects.milk(); pulse(f);
|
||||
assertTrue(effects.decisions.stream().anyMatch(e -> e.getCause() == EntityPotionEffectEvent.Cause.MILK && e.isCancelled() == veto));
|
||||
if (veto) assertSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
else assertNotSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
effects.lateCancelRemoval = false; f.plugin.setEnabled(false);
|
||||
ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == (veto ? 2 : 4));
|
||||
var rows = roadEvents(data);
|
||||
assertEquals(veto ? 1 : 2, rows.stream().filter(r -> r.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
assertEquals(veto ? 1 : 2, rows.stream().map(r -> r.getAsJsonObject("data").get("application_id")).distinct().count());
|
||||
assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
@Test void transferredSheetAndRetiredTimerCannotBoostTheFormerHolder() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var first = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a); PartyBookNativeTest.drain(f); pulse(f);
|
||||
assertNull(first.current(PotionEffectType.SPEED));
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data); var second = new PassivePotionCleanupNativeTest.NativePort(f, b); support(b);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); pulse(f);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
assertEquals(a.core.record.id(), sessions.active(b.core.id).orElseThrow().character());
|
||||
assertNull(first.current(PotionEffectType.SPEED)); assertNotNull(second.current(PotionEffectType.SPEED));
|
||||
var retired = f.pulses.stream().filter(p -> p.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")).findFirst().orElseThrow();
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true); retired.run();
|
||||
assertNull(first.current(PotionEffectType.SPEED)); assertNull(second.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
@Test void writerFailureRetainsReplayableFactsButNeverReplaysSpeed() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
pulse(f); assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration());
|
||||
a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
f.plugin.setEnabled(false);
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
f.plugin.setEnabled(true);
|
||||
ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 2);
|
||||
assertNull(effects.current(PotionEffectType.SPEED));
|
||||
var start = roadEvents(data).getFirst().getAsJsonObject("data"); var end = roadEvents(data).getLast().getAsJsonObject("data");
|
||||
assertEquals(start.get("application_id"), end.get("application_id"));
|
||||
assertEquals(start.get("owner_session_id"), end.get("owner_session_id"));
|
||||
assertEquals(a.core.record.id().toString(), start.get("owner_character_id").getAsString());
|
||||
}
|
||||
}
|
||||
@Test void failedAuditContextCaptureIsNotRetriedAsANewBusinessApplicationOnRefresh() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
doThrow(new IllegalStateException("Controlled audit-context capture refusal")).when(a.player).getName();
|
||||
pulse(f); assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration());
|
||||
doReturn("Account").when(a.player).getName();
|
||||
for (int tick = 0; tick < 3; tick++) { effects.effects.get(PotionEffectType.SPEED).tickClient(); a.nativePlayer.tickCount++; pulse(f); }
|
||||
f.plugin.setEnabled(false);
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(roadEvents(data).isEmpty(), "Lost native observations cannot be synthesized from later lease refreshes");
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||
void nativeApplicationCallbacksThatExitOrCloseCannotLeaveANewRoadGrantBehind(boolean close) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
class Invalidate implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void invalidate(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() != a.player || event.getAction() != EntityPotionEffectEvent.Action.ADDED) return;
|
||||
if (close) f.plugin.setEnabled(false);
|
||||
else { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Invalidate(), f.plugin); pulse(f);
|
||||
assertNull(effects.current(PotionEffectType.SPEED), "Post-mutation context invalidation requires proved-layer cleanup, not an ordinary-holder grant");
|
||||
}
|
||||
}
|
||||
@Test void acceptedSameSignatureForeignMutationNestedBeforeTheObserverIsNotMistakenForACancelledRoadRefresh() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var foreign = effects.effects.get(PotionEffectType.SPEED);
|
||||
class Replace implements org.bukkit.event.Listener {
|
||||
boolean fired; boolean nested;
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void replace(EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() != a.player || event.getAction() != EntityPotionEffectEvent.Action.CHANGED) return;
|
||||
if (nested) { event.setOverride(true); return; }
|
||||
if (fired) return; fired = true; event.setCancelled(true); nested = true;
|
||||
try { assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 20, 0, false, false, true))); }
|
||||
finally { nested = false; }
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Replace(), f.plugin); pulse(f);
|
||||
a.core.location.setY(65); pulse(f);
|
||||
assertSame(foreign, effects.effects.get(PotionEffectType.SPEED), "Final cancellation and identical native fields do not hide a separately accepted nested foreign mutation");
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest @ValueSource(strings = {"cancelled", "thrown", "native_tick_phase"})
|
||||
void refusedApplicationNeverAcquiresOwnershipOrPublishesSuccess(String refusal) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
if (refusal.equals("native_tick_phase")) effects.ticking(true);
|
||||
else if (refusal.equals("thrown")) doThrow(new IllegalStateException("Controlled native admission failure")).when(a.player)
|
||||
.addPotionEffect(org.mockito.ArgumentMatchers.any(org.bukkit.potion.PotionEffect.class), org.mockito.ArgumentMatchers.anyBoolean());
|
||||
else {
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void veto(EntityPotionEffectEvent event) { if (event.getEntity() == a.player) event.setCancelled(true); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
}
|
||||
pulse(f); pulse(f);
|
||||
assertNull(effects.current(PotionEffectType.SPEED)); assertTrue(effects.deferred.isEmpty());
|
||||
var road = (games.dmg.fantasystick.passive.PathSpeed) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeed");
|
||||
assertFalse(road.hasRoadLease(a.player));
|
||||
effects.ticking(false); f.plugin.setEnabled(false);
|
||||
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "pathSpeedAuditDrain")).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertTrue(roadEvents(data).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void partialRoadRegistrationClosesAllInstalledTimersAndNeverExposesTheRecipe() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.failRegistrationAt = "PathSpeed"; assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true));
|
||||
assertFalse(f.recipes.containsKey(games.dmg.fantasystick.items.CharacterSheets.RECIPE));
|
||||
f.timers.forEach(timer -> verify(timer).cancel());
|
||||
}
|
||||
}
|
||||
@Test void boundedSessionTraversalRotatesFairlyAndDiscardsRetiredSessionIdentities() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var actors = new java.util.ArrayList<FantasyStickLifecycleNativeTest.Actor>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a); actors.add(a);
|
||||
}
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var seen = new java.util.HashSet<java.util.UUID>();
|
||||
for (int turn = 0; turn < 3; turn++) { var batch = sessions.rotateActiveCharacters(2); assertTrue(batch.size() <= 2); batch.forEach(v -> seen.add(v.session().player())); }
|
||||
assertEquals(5, seen.size()); var retired = sessions.active(actors.getFirst().core.id).orElseThrow();
|
||||
actors.getFirst().core.inventory.setItemInOffHand(null); f.changed(actors.getFirst());
|
||||
for (int turn = 0; turn < 3; turn++) assertTrue(sessions.rotateActiveCharacters(2).stream().noneMatch(v -> v.session().equals(retired)));
|
||||
assertThrows(IllegalArgumentException.class, () -> sessions.rotateActiveCharacters(0));
|
||||
assertThrows(IllegalArgumentException.class, () -> sessions.rotateActiveCharacters(257));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class PathSpeedNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
static void pulse(FantasyStickLifecycleNativeTest.Fixture f) {
|
||||
for (var task : f.pulses) if (task.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")) task.run();
|
||||
}
|
||||
static void support(FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.location.setX(0.5); actor.core.location.setY(64.9375); actor.core.location.setZ(0.5);
|
||||
doReturn(true).when(actor.player).isOnGround();
|
||||
doReturn(false).when(actor.player).isSwimming(); doReturn(false).when(actor.player).isFlying();
|
||||
doReturn(false).when(actor.player).isGliding();
|
||||
var level = actor.nativePlayer.level();
|
||||
doReturn(Blocks.DIRT_PATH.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"jump", "jump_takeoff", "native_dead", "swim", "fly", "glide", "mount", "full_height", "airborne_path", "replaced", "unloaded", "inactive"})
|
||||
void excludedStatesAndStationaryTerrainChangesRemoveOnlyTheOwnedRoadLayer(String state) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
switch (state) {
|
||||
case "jump" -> doReturn(false).when(a.player).isOnGround();
|
||||
case "jump_takeoff" -> doReturn(new org.bukkit.util.Vector(0, 0.42, 0)).when(a.player).getVelocity();
|
||||
case "native_dead" -> a.core.health = 0;
|
||||
case "swim" -> doReturn(true).when(a.player).isSwimming();
|
||||
case "fly" -> doReturn(true).when(a.player).isFlying();
|
||||
case "glide" -> doReturn(true).when(a.player).isGliding();
|
||||
case "mount" -> doReturn(true).when(a.player).isInsideVehicle();
|
||||
case "full_height" -> a.core.location.setY(65);
|
||||
case "airborne_path" -> a.core.location.setY(66);
|
||||
case "replaced", "unloaded" -> {
|
||||
var level = a.nativePlayer.level();
|
||||
doReturn(state.equals("unloaded") ? null : Blocks.DIRT.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class));
|
||||
}
|
||||
case "inactive" -> { a.core.inventory.setItemInOffHand(null); f.changed(a); }
|
||||
default -> throw new AssertionError(state);
|
||||
}
|
||||
pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledCleanupRetainsIdentityAndRetriesWithoutRenewingIneligibleSpeed() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var original = effects.effects.get(PotionEffectType.SPEED);
|
||||
effects.lateCancelRemoval = true; a.core.location.setY(65); pulse(f); pulse(f);
|
||||
assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertEquals(20, original.getDuration());
|
||||
effects.lateCancelRemoval = false; pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void foreignRemovalCancelledAfterRoadObserverRetainsTheRoadLeaseAndRenewalAuthority() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var original = effects.effects.get(PotionEffectType.SPEED); effects.lateCancelRemoval = true;
|
||||
a.player.removePotionEffect(PotionEffectType.SPEED); assertSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
original.tickClient(); a.nativePlayer.tickCount++; pulse(f);
|
||||
assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration(), "A finally cancelled foreign removal cannot discard current road ownership");
|
||||
effects.lateCancelRemoval = false; a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void learnedSpeedTakingOverRoadSpeedRemainsAfterLeavingRoad() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
// Completed passive acquisition prerequisite, using the existing strict saved-record fixture.
|
||||
SpeedINativeTest.withDex(data, a, java.util.List.of("", "", ""));
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
// Learn through the supported native book route; roads themselves spend nothing.
|
||||
PartyBookNativeTest.open(f, a);
|
||||
PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Learn]", "speed_i"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
((games.dmg.fantasystick.passive.SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep();
|
||||
a.core.location.setY(65); pulse(f);
|
||||
var speed = effects.current(PotionEffectType.SPEED);
|
||||
assertNotNull(speed, "Leaving the road cannot remove the other applicable learned Speed I layer");
|
||||
assertTrue(speed.isInfinite());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void realRallyingCryAndRoadSourcesPreserveTheSurvivorInBothActivationOrders(boolean rallyFirst) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
RallyingCryNativeTest.prepareCaster(data, a, true);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
org.mockito.Mockito.doReturn(java.util.List.of()).when(a.player).getNearbyEntities(
|
||||
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble());
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
if (!rallyFirst) pulse(f);
|
||||
RallyingCryNativeTest.use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
if (rallyFirst) pulse(f);
|
||||
assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
assertTrue(effects.current(PotionEffectType.SPEED).isInfinite(), "Rally keeps its native persistent policy rather than borrowing an expiring road layer");
|
||||
a.core.location.setY(65); pulse(f); assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
assertTrue(effects.current(PotionEffectType.SPEED).isInfinite());
|
||||
a.core.location.setY(64.9375);
|
||||
f.delayed.getLast().run(); pulse(f);
|
||||
assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
assertFalse(effects.current(PotionEffectType.SPEED).isInfinite());
|
||||
assertEquals(20, effects.current(PotionEffectType.SPEED).getDuration());
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 20, 200})
|
||||
void genuineNativeTickExpiryKeepsOneLogicalApplicationAcrossContinuousRoadLeases(int lease) throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "path-speed-lease-ticks: " + lease + "\n");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
for (int tick = 0; tick < 25; tick++) {
|
||||
var effect = effects.effects.get(PotionEffectType.SPEED); assertNotNull(effect); a.nativePlayer.tickCount++;
|
||||
if (!effect.tickServer(a.nativePlayer.level(), a.nativePlayer, () -> {})) {
|
||||
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(a.player, effects.current(PotionEffectType.SPEED), null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
f.dispatcher.accept(event); assertFalse(event.isCancelled()); effects.effects.remove(PotionEffectType.SPEED);
|
||||
}
|
||||
pulse(f); assertEquals(lease, effects.current(PotionEffectType.SPEED).getDuration());
|
||||
}
|
||||
f.plugin.setEnabled(false);
|
||||
var rows = roadEvents(data);
|
||||
assertEquals(2, rows.size(), "Continuous native one-tick renewals are one logical start/end, not per-tick business spam");
|
||||
assertEquals(rows.getFirst().getAsJsonObject("data").get("application_id"), rows.getLast().getAsJsonObject("data").get("application_id"));
|
||||
}
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {0, 1})
|
||||
void existingEqualOrStrongerForeignSpeedAndOrdinaryInactiveHoldersAreUntouched(int tier) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 200, tier)));
|
||||
var foreign = effects.effects.get(PotionEffectType.SPEED); f.activate(a); pulse(f);
|
||||
assertSame(foreign, effects.effects.get(PotionEffectType.SPEED));
|
||||
a.core.location.setY(65); pulse(f); assertSame(foreign, effects.effects.get(PotionEffectType.SPEED));
|
||||
assertEquals(200, foreign.getDuration()); assertTrue(roadEvents(data).isEmpty());
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"extended", "stronger_hidden", "identical_replacement"})
|
||||
void inPlaceAndIdenticalForeignLayersIncludingHiddenNativeChainsArePreserved(String change) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var original = effects.effects.get(PotionEffectType.SPEED);
|
||||
if (change.equals("identical_replacement")) effects.effects.put(PotionEffectType.SPEED, new net.minecraft.world.effect.MobEffectInstance(original));
|
||||
else assertTrue(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED,
|
||||
change.equals("extended") ? 40 : 10, change.equals("extended") ? 0 : 1, false, false, true)));
|
||||
var foreign = effects.effects.get(PotionEffectType.SPEED); var hidden = foreign.hiddenEffect;
|
||||
if (change.equals("stronger_hidden")) assertNotNull(hidden);
|
||||
else if (!change.equals("identical_replacement")) assertSame(original, foreign, "Native update mutates the same instance; identity alone is not ownership");
|
||||
pulse(f); a.core.location.setY(65); pulse(f);
|
||||
assertSame(foreign, effects.effects.get(PotionEffectType.SPEED)); assertSame(hidden, foreign.hiddenEffect);
|
||||
assertEquals(change.equals("extended") ? 40 : change.equals("stronger_hidden") ? 10 : 20, foreign.getDuration());
|
||||
}
|
||||
}
|
||||
@Test void refusedForeignNoOpDoesNotDiscardOwnershipAndUnsafeNativePhaseDefersCleanup() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
assertFalse(effects.add(new org.bukkit.potion.PotionEffect(PotionEffectType.SPEED, 20, 0, false, false, true)));
|
||||
var original = effects.effects.get(PotionEffectType.SPEED); original.tickClient(); a.nativePlayer.tickCount++; pulse(f);
|
||||
assertEquals(20, original.getDuration());
|
||||
a.core.location.setY(65); effects.ticking(true); int before = effects.decisions.size(); pulse(f);
|
||||
assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertEquals(before, effects.decisions.size()); assertTrue(effects.deferred.isEmpty());
|
||||
effects.ticking(false); effects.failRemovalAfterDecision = true; pulse(f);
|
||||
assertSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
effects.failRemovalAfterDecision = false; pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void actualDownedContainmentRetainsFiniteCleanupAndFinalDeathOrPhoenixRescueReconcilesIt(boolean rescue) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
var active = sessions.active(a.core.id).orElseThrow(); var original = effects.effects.get(PotionEffectType.SPEED);
|
||||
if (rescue) {
|
||||
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
|
||||
inventory.add(active.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN, java.util.UUID.randomUUID()).join();
|
||||
}
|
||||
PassivePotionCleanupNativeTest.down(f, a); pulse(f); pulse(f);
|
||||
assertSame(original, effects.effects.get(PotionEffectType.SPEED)); assertFalse(effects.current(PotionEffectType.SPEED).isInfinite());
|
||||
assertTrue(effects.decisions.stream().anyMatch(event -> event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED && event.isCancelled()));
|
||||
if (rescue) {
|
||||
PassivePotionCleanupNativeTest.clickMenu(f, a, 13); ReskillEventsNativeTest.await(f, () -> sessions.current(active)); pulse(f);
|
||||
assertSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
} else { PassivePotionCleanupNativeTest.acceptDeath(f, a); pulse(f); assertNull(effects.current(PotionEffectType.SPEED)); }
|
||||
}
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void finallyCancelledMoveOrTeleportNeverGrantsAHypotheticalPathDestination(boolean teleport) throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a); a.core.location.setY(65);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void veto(org.bukkit.event.player.PlayerMoveEvent event) {
|
||||
if (event.getPlayer() == a.player) { event.setCancelled(true); pulse(f); }
|
||||
}
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void teleportVeto(org.bukkit.event.player.PlayerTeleportEvent event) { veto(event); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin); var from = a.player.getLocation(); var to = from.clone(); to.setY(64.9375);
|
||||
org.bukkit.event.player.PlayerMoveEvent event = teleport ? new org.bukkit.event.player.PlayerTeleportEvent(a.player, from, to,
|
||||
org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN) : new org.bukkit.event.player.PlayerMoveEvent(a.player, from, to);
|
||||
f.dispatcher.accept(event); assertTrue(event.isCancelled()); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
// Accepted continuation changes actual position; event proposals alone never do.
|
||||
a.core.location.setY(64.9375); pulse(f); assertNotNull(effects.current(PotionEffectType.SPEED));
|
||||
a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledNativeExpiryDuringDowningRetainsCleanupResponsibilityThroughFinalDeath() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
var original = effects.effects.get(PotionEffectType.SPEED); PassivePotionCleanupNativeTest.down(f, a);
|
||||
for (int tick = 0; tick < 20; tick++) {
|
||||
a.nativePlayer.tickCount++;
|
||||
if (!original.tickServer(a.nativePlayer.level(), a.nativePlayer, () -> {})) {
|
||||
var event = new org.bukkit.event.entity.EntityPotionEffectEvent(a.player, effects.current(PotionEffectType.SPEED), null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION, org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
f.dispatcher.accept(event); assertTrue(event.isCancelled());
|
||||
}
|
||||
}
|
||||
assertEquals(0, original.getDuration()); pulse(f); assertSame(original, effects.effects.get(PotionEffectType.SPEED));
|
||||
PassivePotionCleanupNativeTest.acceptDeath(f, a); pulse(f);
|
||||
assertNull(effects.current(PotionEffectType.SPEED), "A finally refused native expiry does not erase the road cleanup lease");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reentrantSessionExitDuringRoadHandoffCannotPublishAnInfinitePassiveOnTheFormerHolder() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
SpeedINativeTest.withDex(data, a, java.util.List.of("", "", ""));
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
PartyBookNativeTest.open(f, a); PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Learn]", "speed_i"));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
class Exit implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void exit(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == a.player && event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED) {
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Exit(), f.plugin);
|
||||
((games.dmg.fantasystick.passive.SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep();
|
||||
assertNull(effects.current(PotionEffectType.SPEED), "Handoff native callbacks must not grant persistent Speed after current-session authority disappears");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void reentrantSessionExitDuringRoadRallyHandoffCannotPublishAnInfiniteSpeedLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
RallyingCryNativeTest.prepareCaster(data, a, true);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
org.mockito.Mockito.doReturn(java.util.List.of()).when(a.player).getNearbyEntities(
|
||||
org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble());
|
||||
f.players.put(a.core.id, a.player); f.activate(a); pulse(f);
|
||||
class Exit implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
|
||||
public void exit(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
if (event.getEntity() == a.player && event.getAction() == org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED) {
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Exit(), f.plugin); RallyingCryNativeTest.use(f, a);
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNull(effects.current(PotionEffectType.SPEED), "Rally handoff cannot publish persistent Speed on a former source holder");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void actualBookAndJsonlExplainAndRecordTheRoadJourneyWithoutRefreshSpam() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var effects = new PassivePotionCleanupNativeTest.NativePort(f, a); support(a);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
PartyBookNativeTest.open(f, a);
|
||||
assertTrue(PartyBookNativeTest.text(a).contains("Dirt Path travel"), "The real character book teaches the automatic road benefit");
|
||||
pulse(f);
|
||||
for (int i = 0; i < 50; i++) { effects.effects.get(PotionEffectType.SPEED).tickClient(); a.nativePlayer.tickCount++; pulse(f); }
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var record = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertEquals(a.core.record.unspentPoints(), record.unspentPoints()); assertEquals(a.core.record.passives(), record.passives());
|
||||
a.core.location.setY(65); pulse(f); assertNull(effects.current(PotionEffectType.SPEED));
|
||||
ReskillEventsNativeTest.await(f, () -> roadEvents(data).size() == 2);
|
||||
var rows = roadEvents(data); assertEquals(2, rows.size());
|
||||
assertEquals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "effect.applied", rows.getFirst().get("type").getAsString());
|
||||
assertEquals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + "effect.ended", rows.getLast().get("type").getAsString());
|
||||
var start = rows.getFirst().getAsJsonObject("data"); var end = rows.getLast().getAsJsonObject("data");
|
||||
assertEquals(start.get("application_id"), end.get("application_id"));
|
||||
for (var row : rows) {
|
||||
var values = row.getAsJsonObject("data");
|
||||
assertEquals(a.core.id.toString(), values.get("player_id").getAsString());
|
||||
assertEquals(record.id().toString(), values.get("character_id").getAsString());
|
||||
assertTrue(values.has("session_id")); assertTrue(values.has("location")); assertFalse(values.has("passive_id"));
|
||||
assertEquals(0, values.get("amplifier").getAsInt());
|
||||
}
|
||||
}
|
||||
}
|
||||
static java.util.List<com.google.gson.JsonObject> roadEvents(Path directory) {
|
||||
try { return ReskillEventsNativeTest.records(directory).stream().filter(row -> {
|
||||
var values = row.getAsJsonObject("data"); return values.has("feature_id") && values.get("feature_id").getAsString().equals("dirt_path_speed");
|
||||
}).toList(); } catch (Exception failure) { throw new IllegalStateException(failure); }
|
||||
}
|
||||
|
||||
@Test void registeredRoadSweepGrantsFiniteSpeedToAnOrdinaryActiveCharacterOnNativeLoweredPath() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var nativeEffects = new PassivePotionCleanupNativeTest.NativePort(f, actor);
|
||||
actor.core.location.setX(0.5); actor.core.location.setY(64.9375); actor.core.location.setZ(0.5);
|
||||
doReturn(true).when(actor.player).isOnGround();
|
||||
doReturn(false).when(actor.player).isSwimming();
|
||||
doReturn(false).when(actor.player).isFlying();
|
||||
doReturn(false).when(actor.player).isGliding();
|
||||
var level = actor.nativePlayer.level();
|
||||
doReturn(Blocks.DIRT_PATH.defaultBlockState()).when(level).getBlockStateIfLoaded(any(BlockPos.class));
|
||||
f.players.put(actor.core.id, actor.player); f.activate(actor);
|
||||
// Invoke the actual installed feature timer, not an imagined domain helper.
|
||||
for (var pulse : f.pulses) {
|
||||
if (pulse.getClass().getName().startsWith("games.dmg.fantasystick.passive.PathSpeed")) pulse.run();
|
||||
}
|
||||
var speed = nativeEffects.current(PotionEffectType.SPEED);
|
||||
assertNotNull(speed, "A genuine active character supported by a loaded native Dirt Path receives road Speed I");
|
||||
assertEquals(0, speed.getAmplifier()); assertEquals(20, speed.getDuration()); assertFalse(speed.isInfinite());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,7 +319,7 @@ class ProtocolPresentationNativeTest {
|
||||
assertInstanceOf(ClientboundBundlePacket.class, event.getPacket().getHandle()).subPackets().forEach(packets::add);
|
||||
assertEquals(2, packets.size());
|
||||
assertSame(original, packets.getFirst()); assertSame(unrelated, packets.getLast());
|
||||
assertArrayEquals(new int[] {label}, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).getLast().getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {label}, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).getLast().entityIds().toIntArray());
|
||||
assertEquals(1, f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).size());
|
||||
assertTrue(packets.stream().noneMatch(ClientboundBundlePacket.class::isInstance));
|
||||
verifyNoInteractions(errors);
|
||||
@@ -484,7 +484,7 @@ class ProtocolPresentationNativeTest {
|
||||
var sent = f.sent.get(i);
|
||||
if (sent.viewer() != f.viewer) { continue; }
|
||||
if (sent.packet() instanceof ClientboundAddEntityPacket spawn && spawn.getId() == oldId) { oldSpawn = i; }
|
||||
if (sent.packet() instanceof ClientboundRemoveEntitiesPacket remove && remove.getEntityIds().contains(oldId)) { oldRemove = i; }
|
||||
if (sent.packet() instanceof ClientboundRemoveEntitiesPacket remove && remove.entityIds().contains(oldId)) { oldRemove = i; }
|
||||
}
|
||||
assertTrue(oldSpawn >= 0 && oldRemove > oldSpawn, "No old label may escape after its cleanup");
|
||||
assertEquals(2, f.packets(f.viewer, ClientboundAddEntityPacket.class).size());
|
||||
@@ -528,7 +528,7 @@ class ProtocolPresentationNativeTest {
|
||||
f.tick.run(); f.snapshot.set(Map.of()); f.output.clear(newer);
|
||||
var removed = new java.util.HashSet<Integer>();
|
||||
f.packets(f.viewer, ClientboundRemoveEntitiesPacket.class).forEach(packet -> {
|
||||
for (int id : packet.getEntityIds().toIntArray()) { removed.add(id); }
|
||||
for (int id : packet.entityIds().toIntArray()) { removed.add(id); }
|
||||
});
|
||||
for (var spawn : f.packets(f.viewer, ClientboundAddEntityPacket.class)) {
|
||||
assertTrue(removed.contains(spawn.getId()), "Every escaped label remains owned until cleanup: " + spawn.getId());
|
||||
@@ -866,7 +866,7 @@ class ProtocolPresentationNativeTest {
|
||||
assertSame(original, event.getPacket().getHandle(), "Retain native removal/concealment packet and every field");
|
||||
var removed = wire(ClientboundRemoveEntitiesPacket.STREAM_CODEC,
|
||||
fixture.packets(fixture.viewer, ClientboundRemoveEntitiesPacket.class).getLast());
|
||||
assertArrayEquals(new int[] {label}, removed.getEntityIds().toIntArray());
|
||||
assertArrayEquals(new int[] {label}, removed.entityIds().toIntArray());
|
||||
assertFalse(event.isCancelled());
|
||||
}
|
||||
|
||||
@@ -900,9 +900,9 @@ class ProtocolPresentationNativeTest {
|
||||
platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "isPrimaryThread" -> true;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
|
||||
case "getMinecraftVersion" -> "26.3";
|
||||
case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
});
|
||||
var config = new io.papermc.paper.configuration.GlobalConfiguration();
|
||||
|
||||
@@ -304,7 +304,7 @@ class RallyingCryNativeTest {
|
||||
.withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
return new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount);
|
||||
}
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(2);
|
||||
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
|
||||
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
|
||||
@@ -333,7 +333,7 @@ class RallyingCryNativeTest {
|
||||
&& payload.value().contains(":" + ability)) return custom;
|
||||
for (var child : component.getSiblings()) { var found = button(child, label, ability); if (found != null) return found; } return null;
|
||||
}
|
||||
private static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
static void prepareCaster(Path directory, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
|
||||
java.nio.file.Files.createDirectories(directory); var repository = new YamlCharacterRepository(directory);
|
||||
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
|
||||
stats.put(Stat.CHR, BigInteger.valueOf(16)); stats.put(Stat.CON, BigInteger.valueOf(12));
|
||||
|
||||
@@ -112,7 +112,7 @@ class SpeedINativeTest {
|
||||
}
|
||||
|
||||
@Test void milkAndExpiredEffectsReapplyWhileStrongerAndCleanupPathsPreserveUnrelatedSpeed() {
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
|
||||
var sessions = mock(EquipmentSessions.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); var effects = effects(player);
|
||||
UUID playerId = UUID.randomUUID(); var active = new EquipmentSessions.Active(playerId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var learned = mock(CharacterRecord.class); when(learned.passives()).thenReturn(List.of("speed_i", "", "")); when(learned.dead()).thenReturn(false);
|
||||
when(player.getUniqueId()).thenReturn(playerId); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, learned)));
|
||||
@@ -131,7 +131,7 @@ class SpeedINativeTest {
|
||||
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier(), "The passive resumes after stronger Speed expires");
|
||||
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
|
||||
|
||||
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(Player.class); var equalEffects = effects(equalPlayer);
|
||||
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); var equalEffects = effects(equalPlayer);
|
||||
UUID equalId = UUID.randomUUID(); var equalActive = new EquipmentSessions.Active(equalId, UUID.randomUUID(), UUID.randomUUID());
|
||||
var equalRecord = mock(CharacterRecord.class); when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(equalPlayer.getUniqueId()).thenReturn(equalId); when(equalSessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(equalActive, equalRecord)));
|
||||
@@ -145,7 +145,7 @@ class SpeedINativeTest {
|
||||
var weakerSpeed = new SpeedI(equalSessions); weakerSpeed.sweep();
|
||||
assertEquals(0, equalEffects.get(PotionEffectType.SPEED).getAmplifier());
|
||||
when(equalRecord.passives()).thenReturn(List.of("", "", "")); weakerSpeed.sweep();
|
||||
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired weaker native layer is preserved");
|
||||
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired supplemental API snapshot is preserved; native amplifier -1 is not reachable");
|
||||
weakerSpeed.close();
|
||||
|
||||
when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
@@ -157,7 +157,7 @@ class SpeedINativeTest {
|
||||
}
|
||||
|
||||
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() throws Exception {
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
|
||||
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
|
||||
var effects = effects(player); UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
|
||||
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("speed_i", "", ""));
|
||||
when(player.getUniqueId()).thenReturn(id); when(player.getLocation()).thenReturn(new Location(mock(org.bukkit.World.class), 0, 64, 0));
|
||||
@@ -214,8 +214,30 @@ class SpeedINativeTest {
|
||||
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + type)).toList();
|
||||
}
|
||||
|
||||
/** Supplemental API snapshots with stable native identity ports; registered native mutation uses NativePort. */
|
||||
static Map<PotionEffectType, PotionEffect> effects(Player player) {
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>();
|
||||
var identities = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, net.minecraft.world.effect.MobEffectInstance>();
|
||||
var result = new HashMap<PotionEffectType, PotionEffect>() {
|
||||
private static final long serialVersionUID = 1L;
|
||||
@Override public PotionEffect put(PotionEffectType type, PotionEffect value) {
|
||||
identities.put(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type),
|
||||
org.bukkit.craftbukkit.potion.CraftPotionUtil.fromBukkit(value));
|
||||
return super.put(type, value);
|
||||
}
|
||||
@Override public PotionEffect remove(Object type) {
|
||||
if (type instanceof PotionEffectType potion)
|
||||
identities.remove(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(potion));
|
||||
return super.remove(type);
|
||||
}
|
||||
@Override public void clear() { identities.clear(); super.clear(); }
|
||||
};
|
||||
var craft = assertInstanceOf(org.bukkit.craftbukkit.entity.CraftPlayer.class, player);
|
||||
var nativePlayer = craft.getHandle();
|
||||
if (nativePlayer == null) {
|
||||
nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(craft.getHandle()).thenReturn(nativePlayer);
|
||||
}
|
||||
doAnswer(call -> identities.get(call.getArgument(0))).when(nativePlayer).getEffect(any());
|
||||
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
|
||||
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
|
||||
.when(player).addPotionEffect(any(), eq(true));
|
||||
|
||||
@@ -227,6 +227,8 @@ class TrainingDummyEventsNativeTest {
|
||||
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, ports.entity(),
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, (org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
|
||||
assertSame(arrow, event.getDamageSource().getDirectEntity(), "The typed native source must retain the actual owned projectile");
|
||||
assertEquals(abilityId.equals("frost_shard") ? 20.0 : 7.0, event.getDamage(),
|
||||
"Frost Shard multiplies once before native modification; Fireball and Longbow remain unchanged");
|
||||
event.setDamage(4.875); hits.add(event); f.dispatcher.accept(event); return null;
|
||||
}).when(ports.entity()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
clearInvocations(ports.entity()); player.core.inventory.getInventory().setSelectedSlot(2);
|
||||
|
||||
@@ -27,7 +27,7 @@ class DistributionTest {
|
||||
assertEquals(version, metadata.getVersion());
|
||||
assertFalse(metadata.getVersion().contains("${"));
|
||||
assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain());
|
||||
assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract");
|
||||
assertEquals("26.3", metadata.getAPIVersion(), "Declare the exact native API compatibility contract");
|
||||
assertEquals(java.util.List.of("ProtocolLib"), metadata.getDepend());
|
||||
assertEquals(java.util.Set.of("fstick", "fstickadmin"), metadata.getCommands().keySet());
|
||||
assertNull(metadata.getCommands().get("fstick").get("permission"),
|
||||
@@ -71,6 +71,10 @@ class DistributionTest {
|
||||
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
|
||||
"The delivered configuration must expose the reviewed 15-minute default");
|
||||
assertTrue(defaults.contains("adventure-mode: true"));
|
||||
assertTrue(defaults.contains("path-speed-enabled: true"));
|
||||
assertTrue(defaults.contains("path-speed-lease-ticks: 20"));
|
||||
for (String path : java.util.List.of("passive/PathSpeed", "persistence/PathSpeedSettings", "events/PathSpeedEventBridge"))
|
||||
assertNotNull(jar.getJarEntry("games/dmg/fantasystick/" + path + ".class"), "Road wiring needs its packaged production owner: " + path);
|
||||
for (String setting : java.util.List.of("adventure-daily-site-count: 3", "adventure-site-radius: 16",
|
||||
"adventure-site-reset-seconds: 30", "dungeon-room-link-distance: 10", "adventure-camp-enemies: 8",
|
||||
"adventure-camp-reward-xp: 100", "adventure-camp-reward-coins: 20",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PurpurDistributionTest {
|
||||
private static final String API = "org.purpurmc.purpur:purpur-api:26.3.build.2651-stable";
|
||||
private static final String RUNTIME = "purpur-26.3-2651.jar";
|
||||
private static final String DOWNLOAD = "https://api.purpurmc.org/v2/purpur/26.3/2651/download";
|
||||
private static final String SHA256 = "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598";
|
||||
|
||||
@Test
|
||||
void pinsTheExactApprovedApiAndRuntime() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("compileOnly(\"" + API + "\")"));
|
||||
assertTrue(build.contains("testImplementation(\"" + API + "\")"));
|
||||
assertTrue(build.contains(RUNTIME));
|
||||
assertTrue(build.contains(DOWNLOAD));
|
||||
assertTrue(build.contains(SHA256));
|
||||
assertFalse(build.contains("26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("purpur-26.2-2618.jar"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retainsTheApprovedProtocolLibDependency() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
String descriptor = Files.readString(Path.of("src/main/resources/plugin.yml"));
|
||||
assertTrue(build.contains("compileOnly(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertTrue(build.contains("testImplementation(\"net.dmulloy2:ProtocolLib:5.4.0\")"));
|
||||
assertTrue(descriptor.contains("depend: [ProtocolLib]"));
|
||||
}
|
||||
}
|
||||
@@ -14,28 +14,30 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class ChainLightningSettingsTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test void defaultsAndParsingRetainExactDecay() {
|
||||
var settings = ChainLightningSettings.parse(defaults());
|
||||
assertEquals(java.math.BigInteger.valueOf(8), settings.baseDamage());
|
||||
@Test void defaultsAndParsingExposeOnlyEffectivePolicyAndIgnoreObsoleteDamageKeys() {
|
||||
var expected = java.util.List.of(ChainLightningSettings.COOLDOWN, ChainLightningSettings.INITIAL_RANGE,
|
||||
ChainLightningSettings.JUMP_RANGE, ChainLightningSettings.MAX_TARGETS);
|
||||
assertEquals(expected, ChainLightningSettings.keys());
|
||||
assertEquals("20", ChainLightningSettings.defaultValue(ChainLightningSettings.MAX_TARGETS));
|
||||
var values = defaults();
|
||||
values.put("chain-lightning-base-damage", java.math.BigInteger.TEN.pow(500).toString());
|
||||
values.put("chain-lightning-additional-targets", "0");
|
||||
values.put("chain-lightning-jump-multiplier", "0.01");
|
||||
values.put("chain-lightning-target-int-step", "1");
|
||||
var settings = ChainLightningSettings.parse(values);
|
||||
assertEquals(java.time.Duration.ofSeconds(18), settings.cooldown());
|
||||
assertEquals(16, settings.initialRange(), 0); assertEquals(6, settings.jumpRange(), 0);
|
||||
assertEquals(4, settings.additionalTargets());
|
||||
assertEquals(new java.math.BigDecimal("0.75"), settings.jumpMultiplier());
|
||||
assertEquals(20, settings.maxTargets());
|
||||
}
|
||||
|
||||
@Test void targetCoverageKeysAndPackagedDefaultsAreAvailable() {
|
||||
assertTrue(ChainLightningSettings.keys().containsAll(java.util.List.of("chain-lightning-target-int-step", "chain-lightning-max-targets")));
|
||||
assertEquals("5", ChainLightningSettings.defaultValue("chain-lightning-target-int-step"));
|
||||
assertEquals("12", ChainLightningSettings.defaultValue("chain-lightning-max-targets"));
|
||||
@Test void configuredChainCeilingSupportsOnlyOneThroughTwentyTargets() {
|
||||
var values = defaults();
|
||||
for (String invalid : java.util.List.of("0", "-1", "1.5")) {
|
||||
values.put("chain-lightning-target-int-step", invalid);
|
||||
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
|
||||
for (String valid : java.util.List.of("1", "19", "20")) {
|
||||
values.put(ChainLightningSettings.MAX_TARGETS, valid);
|
||||
assertEquals(Integer.parseInt(valid), ChainLightningSettings.parse(values).maxTargets());
|
||||
}
|
||||
values.put("chain-lightning-target-int-step", java.math.BigInteger.TEN.pow(500).toString());
|
||||
assertDoesNotThrow(() -> ChainLightningSettings.parse(values));
|
||||
for (String invalid : java.util.List.of("0", "65", "1.5")) {
|
||||
values.put("chain-lightning-max-targets", invalid);
|
||||
for (String invalid : java.util.List.of("0", "21", "65", "1.5")) {
|
||||
values.put(ChainLightningSettings.MAX_TARGETS, invalid);
|
||||
assertThrows(IllegalArgumentException.class, () -> ChainLightningSettings.parse(values));
|
||||
}
|
||||
}
|
||||
@@ -61,8 +63,7 @@ class ChainLightningSettingsTest {
|
||||
try (var settings = new ChainLightningSettings(data, defaults())) {
|
||||
for (var entry : Map.of(
|
||||
ChainLightningSettings.COOLDOWN, "-1", ChainLightningSettings.INITIAL_RANGE, "0",
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.ADDITIONAL, "-1",
|
||||
ChainLightningSettings.BASE, "-1", ChainLightningSettings.MULTIPLIER, "0").entrySet()) {
|
||||
ChainLightningSettings.JUMP_RANGE, "0", ChainLightningSettings.MAX_TARGETS, "21").entrySet()) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(ChainLightningSettings.COOLDOWN, "0.0000000001"));
|
||||
@@ -82,13 +83,13 @@ class ChainLightningSettingsTest {
|
||||
Files.createSymbolicLink(unsafe.resolve("config.yml"), outside);
|
||||
try (var settings = new ChainLightningSettings(unsafe, defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> settings.set(ChainLightningSettings.MULTIPLIER, "0.5").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
() -> settings.set(ChainLightningSettings.MAX_TARGETS, "10").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals("safe\n", Files.readString(outside));
|
||||
}
|
||||
}
|
||||
var closed = new ChainLightningSettings(data.resolve("closed"), defaults()); closed.close();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class,
|
||||
() -> closed.set(ChainLightningSettings.BASE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
() -> closed.set(ChainLightningSettings.MAX_TARGETS, "19").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
closed.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class HealthStealHealthPersistenceTest {
|
||||
@TempDir Path data;
|
||||
private static final BigDecimal LIMIT = BigDecimal.valueOf(1024);
|
||||
|
||||
@Test void provisionalAndReconciledHealthStayBlockedUntilExactAcknowledgmentAcrossRestart() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
assertBlocked(storage, record);
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
var settled = storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join().orElseThrow();
|
||||
assertEquals(record.cooldowns(), settled.cooldowns()); assertBlocked(storage, record);
|
||||
assertFalse(storage.completeHealthStealHealth(pending, pending.saved()).join());
|
||||
assertTrue(storage.completeHealthStealHealth(pending, settled).join());
|
||||
assertFalse(storage.completeHealthStealHealth(pending, settled).join());
|
||||
assertEquals(settled, storage.find(record.id()).join().orElseThrow());
|
||||
var next = storage.prepareHealthStealHealth(record.id(), settled.revision(), BigDecimal.valueOf(3), LIMIT).join().orElseThrow();
|
||||
assertFalse(storage.completeHealthStealHealth(pending, settled).join(), "Old acknowledgment cannot clear the new operation");
|
||||
assertBlocked(storage, record); assertTrue(storage.completeHealthStealHealth(next, next.saved()).join());
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) {
|
||||
assertEquals(0, BigDecimal.valueOf(3).compareTo(restarted.find(record.id()).join().orElseThrow().health()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inheritedHealthCanBeReconciledWithoutOverwritingNewerProgressButNewerHealthStaysUntouched() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
var renamed = storage.renameCharacter(record.id(), "Newer name").join().orElseThrow();
|
||||
assertTrue(storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join().isEmpty());
|
||||
assertFalse(storage.completeHealthStealHealth(pending, pending.saved()).join());
|
||||
var settled = storage.reconcileHealthStealHealth(pending, renamed, BigDecimal.valueOf(2), LIMIT).join().orElseThrow();
|
||||
assertEquals(renamed.name(), settled.name()); assertEquals(renamed.cooldowns(), settled.cooldowns());
|
||||
assertEquals(renamed.coins(), settled.coins()); assertEquals(renamed.unspentPoints(), settled.unspentPoints());
|
||||
assertTrue(storage.completeHealthStealHealth(pending, settled).join());
|
||||
var next = storage.prepareHealthStealHealth(record.id(), settled.revision(), BigDecimal.valueOf(3), LIMIT).join().orElseThrow();
|
||||
var newerHealth = storage.saveHealth(record.id(), next.saved().revision(), BigDecimal.valueOf(7), LIMIT).join().orElseThrow();
|
||||
assertTrue(storage.reconcileHealthStealHealth(next, newerHealth, BigDecimal.valueOf(2), LIMIT).join().isEmpty());
|
||||
assertFalse(storage.completeHealthStealHealth(next, newerHealth).join());
|
||||
assertEquals(newerHealth, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReconciliationRetainsProofAndCannotAuthorizeRestartLoading() throws Exception {
|
||||
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
fail.set(true);
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join());
|
||||
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
}
|
||||
|
||||
@Test void uncertainProvisionalSaveRetainsProofEvenWhenTheOriginalRecordRemains() throws Exception {
|
||||
var fail = new AtomicBoolean(); var repository = repository(fail); var record = character(); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
fail.set(true);
|
||||
assertThrows(CompletionException.class, () -> storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join());
|
||||
assertEquals(record, storage.inspect(record.id()).join().orElseThrow()); assertBlocked(storage, record);
|
||||
}
|
||||
try (var restarted = new CharacterStorage(new YamlCharacterRepository(data), Clock.systemUTC())) { assertBlocked(restarted, record); }
|
||||
}
|
||||
|
||||
@Test void malformedReplacedOrMissingProofCannotAuthorizeCleanupOrReconciliation() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
var proof = data.resolve("health-steal-health").resolve(record.id() + ".pending");
|
||||
for (String invalid : java.util.List.of(java.util.UUID.randomUUID().toString(), "incomplete")) {
|
||||
Files.writeString(proof, invalid);
|
||||
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
|
||||
assertThrows(CompletionException.class, () -> storage.reconcileHealthStealHealth(pending, pending.saved(), BigDecimal.valueOf(2), LIMIT).join());
|
||||
assertEquals(invalid, Files.readString(proof)); assertBlocked(storage, record);
|
||||
}
|
||||
Files.delete(proof); // Adversarial test only; manual removal is not a supported recovery operation.
|
||||
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void symlinkProofAndUnsafeDirectoryRefuseWithoutTouchingExternalContents() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
var outside = data.resolve("outside-proof"); Files.writeString(outside, "must remain unchanged");
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), BigDecimal.valueOf(5), LIMIT).join().orElseThrow();
|
||||
var directory = data.resolve("health-steal-health"); var proof = directory.resolve(record.id() + ".pending");
|
||||
Files.delete(proof); Files.createSymbolicLink(proof, outside);
|
||||
assertBlocked(storage, record);
|
||||
assertThrows(CompletionException.class, () -> storage.completeHealthStealHealth(pending, pending.saved()).join());
|
||||
assertTrue(Files.isSymbolicLink(proof)); assertEquals("must remain unchanged", Files.readString(outside));
|
||||
Files.delete(proof); Files.delete(directory); Files.writeString(directory, "not a proof directory");
|
||||
assertBlocked(storage, record);
|
||||
assertThrows(CompletionException.class, () -> storage.prepareHealthStealHealth(record.id(), pending.saved().revision(), BigDecimal.valueOf(6), LIMIT).join());
|
||||
assertEquals("not a proof directory", Files.readString(directory));
|
||||
assertEquals(pending.saved(), storage.inspect(record.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void staleRevisionCannotPrepareProofAndUnchangedSavedTargetStillRequiresAcknowledgment() throws Exception {
|
||||
var record = character(); var repository = new YamlCharacterRepository(data); repository.create(record);
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
assertTrue(storage.prepareHealthStealHealth(record.id(), record.revision().add(BigInteger.ONE), record.health(), LIMIT).join().isEmpty());
|
||||
assertFalse(Files.exists(data.resolve("health-steal-health")));
|
||||
var pending = storage.prepareHealthStealHealth(record.id(), record.revision(), record.health(), LIMIT).join().orElseThrow();
|
||||
assertEquals(record, pending.saved()); assertBlocked(storage, record);
|
||||
assertTrue(storage.prepareHealthStealHealth(record.id(), record.revision(), record.health(), LIMIT).join().isEmpty());
|
||||
assertTrue(storage.completeHealthStealHealth(pending, record).join());
|
||||
assertEquals(record, storage.find(record.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
private YamlCharacterRepository repository(AtomicBoolean fail) {
|
||||
return new YamlCharacterRepository(data, (source, target) -> {
|
||||
if (fail.get()) throw new IOException("Injected Health Steal replacement failure");
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertBlocked(CharacterStorage storage, CharacterRecord record) {
|
||||
var failure = assertThrows(CompletionException.class, () -> storage.find(record.id()).join());
|
||||
assertInstanceOf(IOException.class, failure.getCause()); assertTrue(failure.getCause().getMessage().contains("Health Steal"));
|
||||
}
|
||||
|
||||
private static CharacterRecord character() {
|
||||
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var stats = new java.util.EnumMap<>(base.stats());
|
||||
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
|
||||
return new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(), base.unspentPoints(),
|
||||
BigDecimal.valueOf(4), base.learned(), base.loadout(), java.util.List.of("health_steal", "", ""),
|
||||
java.util.Map.of("fireball", java.time.Instant.now().plusSeconds(15)), base.restrictedKills(), false, null, null,
|
||||
base.coins(), base.coinsInitialized(), base.rewardReceipts(), base.pvpCoinEscrow(), base.gear(), base.sheetGeneration());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import games.dmg.fantasystick.passive.PathSpeed;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
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;
|
||||
|
||||
class PathSpeedSettingsTest {
|
||||
@TempDir Path directory;
|
||||
@ParameterizedTest @ValueSource(strings = {"0", "201", "-1", "1.0", "1e2", "true", "", " 20", "20 ", "999999999999999999999"})
|
||||
void invalidLeaseIsRejectedBeforePersistenceOrPublication(String raw) throws Exception {
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(PathSpeedSettings.LEASE, raw));
|
||||
assertEquals("20", settings.value(PathSpeedSettings.LEASE)); assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"TRUE", "False", "yes", "1", "", " true"})
|
||||
void booleanIsStrictAndUnknownKeysCannotWrite(String raw) throws Exception {
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set(PathSpeedSettings.ENABLED, raw));
|
||||
assertThrows(IllegalArgumentException.class, () -> settings.set("other-key", "true"));
|
||||
assertTrue(settings.settings().enabled()); assertFalse(Files.exists(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {1, 20, 200})
|
||||
void legalBoundsPersistBeforeExplicitPublication(int ticks) throws Exception {
|
||||
Files.writeString(directory.resolve("config.yml"), "unrelated: [kept, values]\nroleplay-disabled: false\n");
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
var saved = settings.set(PathSpeedSettings.LEASE, Integer.toString(ticks)).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(20, settings.settings().leaseTicks());
|
||||
var yaml = Files.readString(directory.resolve("config.yml")); assertTrue(yaml.contains("path-speed-lease-ticks: " + ticks));
|
||||
assertTrue(yaml.contains("roleplay-disabled: false")); assertTrue(yaml.contains("kept")); assertTrue(yaml.contains("values"));
|
||||
assertTrue(settings.publish(saved)); assertEquals(ticks, settings.settings().leaseTicks());
|
||||
assertFalse(settings.publish(saved));
|
||||
}
|
||||
}
|
||||
@Test void queuedIndependentKeysComposeAndOutOfOrderOrClosedPublicationCannotRevertPolicy() throws Exception {
|
||||
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var count = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults(), (from, to) -> {
|
||||
if (count.getAndIncrement() == 0) {
|
||||
entered.countDown();
|
||||
try { if (!release.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("Controlled save deadline"); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); }
|
||||
}
|
||||
Files.move(from, to, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try {
|
||||
var first = settings.set(PathSpeedSettings.ENABLED, "false"); assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
var second = settings.set(PathSpeedSettings.LEASE, "17");
|
||||
assertEquals(PathSpeed.Settings.defaults(), settings.settings()); release.countDown();
|
||||
var a = first.toCompletableFuture().get(5, TimeUnit.SECONDS); var b = second.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertEquals(new PathSpeed.Settings(false, 17), b.settings());
|
||||
assertTrue(settings.publish(b)); assertEquals(new PathSpeed.Settings(true, 17), settings.settings());
|
||||
assertTrue(settings.publish(a)); assertFalse(settings.publish(a)); assertEquals(new PathSpeed.Settings(false, 17), settings.settings());
|
||||
var text = Files.readString(directory.resolve("config.yml")); assertTrue(text.contains("path-speed-enabled: false")); assertTrue(text.contains("path-speed-lease-ticks: 17"));
|
||||
settings.close(); settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertFalse(settings.publish(new PathSpeedSettings.Update(PathSpeed.Settings.defaults(), b.sequence() + 1, PathSpeedSettings.LEASE)));
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.LEASE, "2").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
} finally { release.countDown(); settings.close(); }
|
||||
}
|
||||
@Test void anAcknowledgedIndependentKeyCannotPublishAnEarlierUnpublishedKey() throws Exception {
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
var toggle = settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var lease = settings.set(PathSpeedSettings.LEASE, "17").toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertTrue(settings.publish(lease));
|
||||
assertEquals(new PathSpeed.Settings(true, 17), settings.settings(), "Each native callback owns only its requested key");
|
||||
assertTrue(settings.publish(toggle), "A valid independent older-key callback is not superseded by another key");
|
||||
assertEquals(new PathSpeed.Settings(false, 17), settings.settings());
|
||||
assertFalse(settings.publish(toggle));
|
||||
}
|
||||
}
|
||||
@Test void replacementFailureRetainsFileAndRuntimeAndDeletesOwnedTemporaryFile() throws Exception {
|
||||
Files.writeString(directory.resolve("config.yml"), "unrelated: retained\n");
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults(), (from, to) -> { throw new java.io.IOException("Controlled replacement refusal"); })) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(PathSpeed.Settings.defaults(), settings.settings()); assertEquals("unrelated: retained\n", Files.readString(directory.resolve("config.yml")));
|
||||
try (var files = Files.list(directory)) { assertTrue(files.noneMatch(file -> file.getFileName().toString().endsWith(".tmp"))); }
|
||||
}
|
||||
}
|
||||
@Test void busyLockUnsafeFileDuplicateKeysAndOversizedOrUnsafeYamlCannotChangePolicy() throws Exception {
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults());
|
||||
var channel = java.nio.channels.FileChannel.open(directory.resolve(".config.lock"), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.LEASE, "1").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(PathSpeed.Settings.defaults(), settings.settings());
|
||||
}
|
||||
for (String contents : java.util.List.of("duplicate: 1\nduplicate: 2\n", "!!java.lang.ProcessBuilder {}\n", "not-a-map\n", "x".repeat(1_048_577))) {
|
||||
Files.writeString(directory.resolve("config.yml"), contents);
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
assertEquals(PathSpeed.Settings.defaults(), settings.settings()); assertEquals(contents, Files.readString(directory.resolve("config.yml")));
|
||||
}
|
||||
}
|
||||
Files.delete(directory.resolve("config.yml")); Files.createDirectory(directory.resolve("config.yml"));
|
||||
try (var settings = new PathSpeedSettings(directory, PathSpeed.Settings.defaults())) {
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> settings.set(PathSpeedSettings.ENABLED, "false").toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
@Test void startupLoadingRejectsQuotedBooleansFractionalAndOutOfBoundsLeases() {
|
||||
assertEquals(PathSpeed.Settings.defaults(), PathSpeedSettings.load(null, null));
|
||||
assertEquals(new PathSpeed.Settings(false, 200), PathSpeedSettings.load(false, 200L));
|
||||
for (Object value : java.util.List.of("true", 1)) assertThrows(IllegalArgumentException.class, () -> PathSpeedSettings.load(value, 20));
|
||||
for (Object value : java.util.List.of("20", 20.0, 0, 201, Long.MAX_VALUE)) assertThrows(IllegalArgumentException.class, () -> PathSpeedSettings.load(true, value));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user