Compare commits

...
42 Commits
Author SHA1 Message Date
dmg 4517aa3a64 feat(abilities): show cooling controls
Release / release (push) Successful in 9m7s
2026-09-20 21:30:39 -04:00
dmg 89a57167cc feat(book): show ability prerequisites first
Release / release (push) Successful in 9m18s
2026-09-20 21:16:48 -04:00
dmg 9f5fede5e5 feat(book): add linked ability navigation
Release / release (push) Successful in 8m47s
2026-09-20 20:52:21 -04:00
dmg 18b174609d feat(ability): add meteor
Release / release (push) Successful in 8m32s
2026-09-20 19:52:20 -04:00
dmg dfa02bc335 feat(ability): add war banner
Release / release (push) Successful in 8m31s
2026-09-20 19:37:01 -04:00
dmg 4c2340d02b feat(ability): add sanctuary
Release / release (push) Successful in 7m53s
2026-09-20 19:17:06 -04:00
dmg d890fb8915 feat(abilities): add chain lightning
Release / release (push) Successful in 8m37s
2026-09-20 18:56:58 -04:00
dmg 683c4ed312 feat(abilities): add frost nova
Release / release (push) Successful in 8m7s
2026-09-20 18:22:03 -04:00
dmg c80c088945 feat(ability): add rallying cry
Release / release (push) Successful in 7m59s
2026-09-20 17:49:12 -04:00
dmg cc6c1a7f44 feat(ability): add guardian bond
Release / release (push) Successful in 8m4s
2026-09-20 17:31:37 -04:00
dmg 4abaeecf63 feat(ability): add purify
Release / release (push) Successful in 11m36s
2026-09-20 17:00:29 -04:00
dmg 5a22dc343a feat(ability): add mending light
Release / release (push) Successful in 7m41s
2026-09-20 16:42:45 -04:00
dmg db1c7b5906 test(release): verify versioned pack URL
Release / release (push) Successful in 7m50s
2026-09-20 15:56:46 -04:00
dmg 9ab5bffd2c feat(admin): grant fantasy character experience 2026-09-20 15:53:58 -04:00
dmg a58c192358 feat(crafting): discover character sheet recipe
Release / release (push) Failing after 8m34s
2026-09-20 15:29:21 -04:00
dmg 0fcb1e7e9c feat(presentation): hide held character sheets
Release / release (push) Failing after 8m32s
2026-09-20 15:13:54 -04:00
dmg 5d574dc6e9 feat(admin): autocomplete recovery commands
Release / release (push) Successful in 8m41s
2026-09-20 14:25:23 -04:00
dmg a71262f115 feat(admin): configure party capacity
Release / release (push) Successful in 7m14s
2026-09-20 14:18:24 -04:00
dmg 8119673c9f feat(admin): control roleplay availability
Release / release (push) Successful in 7m8s
2026-09-20 14:08:39 -04:00
dmg cc5300bb03 feat(admin): configure character switch cooldown
Release / release (push) Successful in 8m5s
2026-09-20 13:56:29 -04:00
dmg 293d28ca29 feat(admin): revive characters from Tails
Release / release (push) Successful in 7m10s
2026-09-20 13:38:30 -04:00
dmg c530576262 feat(admin): issue new character sheets
Release / release (push) Successful in 7m5s
2026-09-20 13:17:27 -04:00
dmg e850e33975 feat(ability): add Inspire
Release / release (push) Successful in 7m18s
2026-09-20 13:02:56 -04:00
dmg 20abd2fa6e feat(ability): add Hex
Release / release (push) Successful in 7m17s
2026-09-20 12:43:14 -04:00
dmg e054a78762 feat(ability): add Shield Bash
Release / release (push) Successful in 7m4s
2026-09-20 12:19:19 -04:00
dmg 63da22e37d feat(ability): add Stone Skin
Release / release (push) Successful in 7m23s
2026-09-20 12:01:16 -04:00
dmg b2e8da536a feat(ability): add Second Wind
Release / release (push) Successful in 6m54s
2026-09-20 11:41:23 -04:00
dmg c77246321c feat(ability): add Quickstep
Release / release (push) Successful in 6m52s
2026-09-20 11:27:51 -04:00
dmg 438d6acfa4 feat(ability): add Longbow
Release / release (push) Successful in 6m48s
2026-09-20 11:11:10 -04:00
dmg e74f51cc58 feat(ability): add Frost Shard
Release / release (push) Successful in 6m53s
2026-09-20 10:47:57 -04:00
dmg 1361ba3fb2 feat(abilities): add Great Sword
Release / release (push) Successful in 6m16s
2026-09-20 06:52:12 -04:00
dmg 27f38818ae feat(abilities): add Revive
Release / release (push) Successful in 6m27s
2026-09-20 06:19:56 -04:00
dmg 624deb938d feat(abilities): add ability refunds
Release / release (push) Successful in 6m36s
2026-09-20 05:50:08 -04:00
dmg 232b2a190d fix(config): expose character switch default
Release / release (push) Successful in 6m34s
2026-09-20 05:39:26 -04:00
dmg 74e717c6ca feat(abilities): add Fireball
Release / release (push) Successful in 6m11s
2026-09-19 17:52:37 -04:00
dmg 644b343fc9 feat(abilities): add extensible engine
Release / release (push) Successful in 6m8s
2026-09-19 17:29:11 -04:00
dmg ba69a55124 feat(progression): add shared combat experience
Release / release (push) Successful in 6m53s
2026-09-19 17:00:10 -04:00
dmg db35c28f0b feat(nutrition): isolate fantasy hunger and healing
Release / release (push) Successful in 5m54s
2026-09-19 15:26:02 -04:00
dmg 636ff9f076 feat(combat): add built-in basic attack
Release / release (push) Successful in 5m48s
2026-09-19 15:17:53 -04:00
dmg 7158817726 feat(progression): add permanent stat purchases
Release / release (push) Successful in 5m55s
2026-09-19 15:08:54 -04:00
dmg 8ee0d98599 ci(fantasy-stick): avoid duplicate release builds
Release / release (push) Successful in 3m31s
2026-09-19 14:51:20 -04:00
dmg 539900eb14 fix(tests): synchronize health saves before reentry and transfer
Release / release (push) Successful in 8m26s
CI / build (push) Successful in 4m19s
2026-09-19 14:32:35 -04:00
164 changed files with 21583 additions and 160 deletions

No files matched your search

+2
View File
@@ -2,6 +2,8 @@ name: CI
on:
push:
branches-ignore: [main]
tags-ignore: ['**']
pull_request:
jobs:
+4 -2
View File
@@ -96,9 +96,11 @@ jobs:
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-fantasy-stick-${{ steps.release.outputs.version }}
path: build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
path: |
build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
build/distributions/purpur-fantasy-stick-${{ steps.release.outputs.version }}-resource-pack.zip
if-no-files-found: error
- name: Publish verified JAR through a draft release
- name: Publish verified JAR and resource pack through a draft release
if: steps.release.outputs.created == 'true'
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
+39 -5
View File
@@ -1,5 +1,6 @@
import java.net.URI
import java.security.MessageDigest
import org.gradle.api.tasks.bundling.Zip
plugins {
java
@@ -7,6 +8,7 @@ plugins {
group = "games.dmg"
version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get()
val pluginVersion = version.toString()
repositories {
mavenCentral()
@@ -33,11 +35,43 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
val resourcePackInput = layout.buildDirectory.dir("generated/resource-pack-input")
val prepareResourcePack = tasks.register<Copy>("prepareResourcePack") {
from("src/resourcePack")
into(resourcePackInput)
filesMatching("pack.mcmeta") { expand("version" to pluginVersion) }
}
val sheetResourcePack = tasks.register<Zip>("sheetResourcePack") {
dependsOn(prepareResourcePack)
from(resourcePackInput)
archiveFileName.set("purpur-fantasy-stick-$pluginVersion-resource-pack.zip")
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
val resourcePackMetadata = layout.buildDirectory.dir("generated/resource-pack-metadata")
val generateResourcePackMetadata = tasks.register("generateResourcePackMetadata") {
dependsOn(sheetResourcePack)
inputs.file(sheetResourcePack.flatMap { it.archiveFile })
inputs.property("pluginVersion", pluginVersion)
outputs.file(resourcePackMetadata.map { it.file("sheet-resource-pack.properties") })
doLast {
val pack = sheetResourcePack.get().archiveFile.get().asFile
val sha1 = MessageDigest.getInstance("SHA-1").digest(pack.readBytes()).joinToString("") { "%02x".format(it) }
val version = pluginVersion
val output = resourcePackMetadata.get().file("sheet-resource-pack.properties").asFile
output.parentFile.mkdirs()
output.writeText("url=https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v$version/purpur-fantasy-stick-$version-resource-pack.zip\nsha1=$sha1\n")
}
}
sourceSets.main.get().resources.srcDir(resourcePackMetadata)
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
dependsOn(tasks.jar, sheetResourcePack)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
systemProperty("distribution.version", pluginVersion)
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
}
// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base.
@@ -88,13 +122,14 @@ nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
description = "Verifies packaged metadata and native item serialization without starting a server"
dependsOn(tasks.jar)
dependsOn(tasks.jar, sheetResourcePack)
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(nativeRuntime)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
}
val installReleaseTools = tasks.register<Exec>("installReleaseTools") {
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
@@ -105,9 +140,8 @@ val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
}
tasks.check { dependsOn(nativeCompatibilityTest, releaseToolCheck) }
val pluginVersion = version
tasks.processResources {
dependsOn(generateResourcePackMetadata)
inputs.property("pluginVersion", pluginVersion)
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
+2 -1
View File
@@ -9,8 +9,9 @@ try {
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input");
const jar = await readFile(`build/libs/purpur-fantasy-stick-${version}.jar`);
const resourcePack = await readFile(`build/distributions/purpur-fantasy-stick-${version}-resource-pack.zip`);
await publishRelease({
credential, version, commit, jar,
credential, version, commit, jar, resourcePack,
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
}, fetch);
console.log(`Published purpur-fantasy-stick v${version}.`);
+25 -16
View File
@@ -8,12 +8,15 @@ const input: ReleaseInput = {
commit: "a".repeat(40),
notes: "Verified changes",
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
resourcePack: new Uint8Array([0x50, 0x4b, 0x03, 0x05]),
};
function success(init: RequestInit): Response {
function success(init: RequestInit, url = ""): Response {
const pack = url.includes("resource-pack.zip");
return Response.json({
id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3",
name: "purpur-fantasy-stick-1.2.3.jar", size: input.jar.length,
name: pack ? "purpur-fantasy-stick-1.2.3-resource-pack.zip" : "purpur-fantasy-stick-1.2.3.jar",
size: pack ? input.resourcePack.length : input.jar.length,
});
}
@@ -21,7 +24,7 @@ test("creates a draft on the fixed repository and exact source commit, without f
const calls: { url: string; init: RequestInit }[] = [];
const id = await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
return success(init, url);
});
assert.ok(calls.length >= 1);
const first = calls[0]!;
@@ -40,17 +43,21 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
const calls: { url: string; init: RequestInit }[] = [];
await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
return success(init, url);
});
assert.equal(calls.length, 3);
assert.equal(calls.length, 4);
assert.equal(calls[1]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3.jar");
assert.equal(calls[1]!.init.method, "POST");
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream");
assert.equal(calls[2]!.init.method, "PATCH");
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
assert.equal(calls[2]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3-resource-pack.zip");
assert.equal(calls[2]!.init.method, "POST");
assert.deepEqual(new Uint8Array(await new Response(calls[2]!.init.body).arrayBuffer()), input.resourcePack);
assert.equal(calls[3]!.init.method, "PATCH");
assert.equal(calls[3]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
assert.deepEqual(JSON.parse(String(calls[3]!.init.body)), { draft: false });
for (const call of calls) {
assert.equal(call.init.redirect, "error");
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
@@ -58,14 +65,14 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
}
});
for (const failureStep of [1, 2, 3]) {
for (const failureStep of [1, 2, 3, 4]) {
for (const status of [302, 401, 409, 500]) {
test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
if (calls === failureStep) return new Response(input.credential, { status });
return success(init);
return success(init, _url);
}), (error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(error.message.includes(String(status)));
@@ -85,7 +92,7 @@ test("sanitizes transport errors and does not follow a redirect with the credent
for (const invalid of [
{ credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" },
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() },
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() }, { resourcePack: new Uint8Array() },
]) {
test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => {
let calls = 0;
@@ -112,16 +119,18 @@ for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringi
for (const [failureStep, body] of [
[2, {}], [2, { name: "other.jar", size: 4 }],
[2, { name: "purpur-fantasy-stick-1.2.3.jar", size: 0 }],
[3, { id: 42, draft: true, tag_name: "v1.2.3" }],
[3, { id: 77, draft: false, tag_name: "v1.2.3" }],
[3, { id: 42, draft: false, tag_name: "v8.8.8" }],
[3, {}], [3, { name: "other.zip", size: 4 }],
[3, { name: "purpur-fantasy-stick-1.2.3-resource-pack.zip", size: 0 }],
[4, { id: 42, draft: true, tag_name: "v1.2.3" }],
[4, { id: 77, draft: false, tag_name: "v1.2.3" }],
[4, { id: 42, draft: false, tag_name: "v8.8.8" }],
] as const) {
test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
return calls === failureStep ? Response.json(body) : success(init);
}), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." });
return calls === failureStep ? Response.json(body) : success(init, _url);
}), { message: failureStep === 2 || failureStep === 3 ? "Invalid release asset response." : "Invalid published release response." });
assert.equal(calls, failureStep);
});
}
+12 -1
View File
@@ -4,6 +4,7 @@ export interface ReleaseInput {
commit: string;
notes: string;
jar: Uint8Array;
resourcePack: Uint8Array;
}
export type Request = (url: string, init: RequestInit) => Promise<Response>;
@@ -13,7 +14,7 @@ const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/r
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
if (!/^[\x21-\x7e]+$/.test(input.credential)
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) {
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0 || input.resourcePack.length === 0) {
throw new Error("Invalid release input.");
}
const send: Request = async (url, init) => {
@@ -49,6 +50,16 @@ export async function publishRelease(input: ReleaseInput, request: Request): Pro
if (asset.name !== `purpur-fantasy-stick-${input.version}.jar` || asset.size !== input.jar.length) {
throw new Error("Invalid release asset response.");
}
const packName = `purpur-fantasy-stick-${input.version}-resource-pack.zip`;
const packResponse = await send(`${releases}/${release.id}/assets?name=${packName}`, {
method: "POST", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" },
body: new Uint8Array(input.resourcePack).buffer,
});
const pack = await readObject(packResponse, "Invalid release asset response.");
if (pack.name !== packName || pack.size !== input.resourcePack.length) {
throw new Error("Invalid release asset response.");
}
const publishedResponse = await send(`${releases}/${release.id}`, {
method: "PATCH", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
@@ -9,6 +9,7 @@ import games.dmg.fantasystick.crafting.CharacterAnvilNames;
import games.dmg.fantasystick.crafting.ManualCraftingController;
import games.dmg.fantasystick.crafting.ManualCraftingGuard;
import games.dmg.fantasystick.crafting.PlayerSheetDrops;
import games.dmg.fantasystick.crafting.RecipeDiscovery;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
@@ -18,15 +19,17 @@ import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.time.Clock;
import java.util.List;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
/** Crafting, native fantasy sessions, books, parties and names; progression actions and spells remain separate. */
/** Crafting, native fantasy sessions, books, parties, names and permanent-stat progression. */
public final class FantasyStickPlugin extends JavaPlugin {
private CharacterStorage characters;
private ManualCraftingController crafting;
private ManualCraftingGuard guard;
private CharacterAnvilNames anvilNames;
private RecipeDiscovery recipeDiscovery;
private boolean recipeRegistered;
private EquipmentSessionStore receipts;
private EquipmentSessions sessions;
@@ -44,6 +47,60 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.session.AdventureModeLease gameModes;
private games.dmg.fantasystick.session.SessionVitals vitals;
private games.dmg.fantasystick.session.SessionVisuals sessionVisuals;
private games.dmg.fantasystick.combat.BasicAttack basicAttack;
private games.dmg.fantasystick.party.CombatExperience combatExperience;
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.ability.OutgoingDamageMitigation outgoingDamageMitigation;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
private games.dmg.fantasystick.ability.Inspire inspire;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.MendingLight mendingLight;
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
private games.dmg.fantasystick.ability.Purify purify;
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
private games.dmg.fantasystick.ability.OutgoingDamageBonus outgoingDamageBonus;
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
private games.dmg.fantasystick.ability.FrostNova frostNova;
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
private games.dmg.fantasystick.ability.WarBanner warBanner;
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
private games.dmg.fantasystick.ability.Meteor meteor;
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -66,10 +123,56 @@ public final class FantasyStickPlugin extends JavaPlugin {
reloadConfig();
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
var switchDelay = switchDelay();
var defaults = ProgressionRules.defaults();
var rules = new ProgressionRules(new java.math.BigInteger(getConfig().getString("starting-points", "40")),
defaults.xpPerLevel(), defaults.maximumLevel());
var switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var greatSwordSettings = greatSwordSettings();
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
var quickstepConfiguration = quickstepConfiguration();
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
var secondWindConfiguration = secondWindConfiguration();
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
var stoneSkinConfiguration = stoneSkinConfiguration();
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
var shieldBashConfiguration = shieldBashConfiguration();
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var hexConfiguration = hexConfiguration();
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
var inspireConfiguration = inspireConfiguration();
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
var mendingLightConfiguration = mendingLightConfiguration();
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
var purifyConfiguration = purifyConfiguration();
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
var guardianBondConfiguration = guardianBondConfiguration();
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
var rallyingCryConfiguration = rallyingCryConfiguration();
var rallyingCryInitial = games.dmg.fantasystick.persistence.RallyingCrySettings.parse(rallyingCryConfiguration);
var frostNovaConfiguration = frostNovaConfiguration();
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
var chainLightningConfiguration = chainLightningConfiguration();
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
var sanctuaryConfiguration = sanctuaryConfiguration();
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
var warBannerConfiguration = warBannerConfiguration();
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
var meteorConfiguration = meteorConfiguration();
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -106,27 +209,180 @@ public final class FantasyStickPlugin extends JavaPlugin {
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
+ "; session " + problem.session(), problem.cause());
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
}, partySettings::maximum);
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, nextTick,
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
getDataFolder().toPath(), quickstepConfiguration);
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
event -> getServer().getPluginManager().callEvent(event));
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
getDataFolder().toPath(), shieldBashConfiguration);
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
shieldBashInitial,
event -> getServer().getPluginManager().callEvent(event));
outgoingDamageMitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
hexSettings = new games.dmg.fantasystick.persistence.HexSettings(getDataFolder().toPath(), hexConfiguration);
hex = new games.dmg.fantasystick.ability.Hex(hexInitial, outgoingDamageMitigation);
temporaryHealth = new games.dmg.fantasystick.ability.TemporaryHealth(sessions, parties, nextTick,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
getDataFolder().toPath(), mendingLightConfiguration);
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
guardianBondSettings = new games.dmg.fantasystick.persistence.GuardianBondSettings(
getDataFolder().toPath(), guardianBondConfiguration);
guardianBond = new games.dmg.fantasystick.ability.GuardianBond(guardianBondInitial, sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
outgoingDamageBonus = new games.dmg.fantasystick.ability.OutgoingDamageBonus(sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
rallyingCrySettings = new games.dmg.fantasystick.persistence.RallyingCrySettings(
getDataFolder().toPath(), rallyingCryConfiguration);
rallyingCry = new games.dmg.fantasystick.ability.RallyingCry(
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
getDataFolder().toPath(), frostNovaConfiguration);
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
getDataFolder().toPath(), chainLightningConfiguration);
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
getDataFolder().toPath(), sanctuaryConfiguration);
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
games.dmg.fantasystick.session.NativeHealthLimit::read,
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
getDataFolder().toPath(), warBannerConfiguration);
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
abilityRegistry.register(fireball.definition());
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
abilityRegistry.register(quickstep.definition());
abilityRegistry.register(secondWind.definition());
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(hex.definition());
abilityRegistry.register(inspire.definition());
abilityRegistry.register(mendingLight.definition());
abilityRegistry.register(purify.definition());
abilityRegistry.register(guardianBond.definition());
abilityRegistry.register(rallyingCry.definition());
abilityRegistry.register(frostNova.definition());
abilityRegistry.register(chainLightning.definition());
abilityRegistry.register(sanctuary.definition());
abilityRegistry.register(warBanner.definition());
abilityRegistry.register(meteor.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
return task::cancel;
};
adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout);
adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick,
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
+ "; ability " + problem.ability(), problem.cause()));
abilityEngine.install(this);
books = new games.dmg.fantasystick.book.CharacterBookController(characters, rules, sessions, nextTick,
problem -> getLogger().log(Level.WARNING, "Character book failed for player " + problem.player()
+ "; character " + problem.character(), problem.cause()), parties, getServer(), id -> {
String name = localPresentation.get().name(id);
var player = getServer().getPlayer(id);
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
});
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, books);
}, abilityRegistry, abilityEngine);
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
basicAttack = localBasicAttack;
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
books.accept(input);
localBasicAttack.accept(input);
abilityEngine.accept(input);
});
sessionEvents.attachAbilityRegistry(abilityRegistry);
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
+ " for player " + problem.player(), problem.cause()), adventureSettings);
+ " for player " + problem.player(), problem.cause()), adventureSettings,
frostShardSettings, frostShard, longbowSettings, longbow,
quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
inspireSettings, inspire, abilityRegistry);
admin.attachMendingLight(mendingLightSettings, mendingLight);
admin.attachPurify(purifySettings, purify);
admin.attachGuardianBond(guardianBondSettings, guardianBond);
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
admin.attachFrostNova(frostNovaSettings, frostNova);
admin.attachChainLightning(chainLightningSettings, chainLightning);
admin.attachSanctuary(sanctuarySettings, sanctuary);
admin.attachWarBanner(warBannerSettings, warBanner);
admin.attachMeteor(meteorSettings, meteor);
admin.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
characters, rules, sessions, nextTick, getServer()::getPlayer,
problem -> getLogger().log(Level.WARNING,
"Admin experience grant for character " + problem.character(), problem.cause())));
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -138,14 +394,43 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(partyEvents, this);
getServer().getPluginManager().registerEvents(partyCombat, this);
partyCombat.install(this);
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(frostNova, this);
getServer().getPluginManager().registerEvents(chainLightning, this);
getServer().getPluginManager().registerEvents(meteor, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(guardianBond, this);
guardianBond.install(this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
rallyingCry.install(this);
warBanner.install(this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(adminRevival, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
recipeDiscovery.discoverAll(onlinePlayers);
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
sheetResourcePack.deliverAll(onlinePlayers);
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -168,6 +453,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCrafting = crafting; crafting = null;
var closingGuard = guard; guard = null;
var closingAnvil = anvilNames; anvilNames = null;
var closingRecipeDiscovery = recipeDiscovery; recipeDiscovery = null;
var closingSessions = sessions; sessions = null;
var closingReceipts = receipts; receipts = null;
var closingCharacters = characters; characters = null;
@@ -175,15 +461,162 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingModes = gameModes; gameModes = null;
var closingVitals = vitals; vitals = null;
var closingVisuals = sessionVisuals; sessionVisuals = null;
var closingBasicAttack = basicAttack; basicAttack = null;
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
var closingLongbowSettings = longbowSettings; longbowSettings = null;
var closingQuickstep = quickstep; quickstep = null;
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
secondWind = null;
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingHex = hex; hex = null;
var closingInspire = inspire; inspire = null;
var closingInspireSettings = inspireSettings; inspireSettings = null;
mendingLight = null;
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
purify = null;
var closingPurifySettings = purifySettings; purifySettings = null;
var closingGuardianBond = guardianBond; guardianBond = null;
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
var closingRallyingCry = rallyingCry; rallyingCry = null;
var closingRallyingCrySettings = rallyingCrySettings; rallyingCrySettings = null;
var closingFrostNova = frostNova; frostNova = null;
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
var closingChainLightning = chainLightning; chainLightning = null;
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
var closingSanctuary = sanctuary; sanctuary = null;
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
var closingWarBanner = warBanner; warBanner = null;
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
var closingMeteor = meteor; meteor = null;
var closingMeteorSettings = meteorSettings; meteorSettings = null;
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingPartySettings = partySettings; partySettings = null;
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
var closingHexSettings = hexSettings; hexSettings = null;
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
var closingCombatMitigation = combatMitigation; combatMitigation = null;
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
var closingGreatSword = greatSword; greatSword = null;
var closingRevive = revive; revive = null;
var closingRevivals = revivals; revivals = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingSettings != null) {
configurationDrain = closingSettings.drained();
cleanup("configuration", closingSettings::close);
}
if (closingSettings != null) cleanup("configuration", closingSettings::close);
if (closingFrostSettings != null) cleanup("Frost Shard configuration", closingFrostSettings::close);
if (closingLongbowSettings != null) cleanup("Longbow configuration", closingLongbowSettings::close);
if (closingQuickstepSettings != null) cleanup("Quickstep configuration", closingQuickstepSettings::close);
if (closingSecondWindSettings != null) cleanup("Second Wind configuration", closingSecondWindSettings::close);
if (closingStoneSkinSettings != null) cleanup("Stone Skin configuration", closingStoneSkinSettings::close);
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
if (closingRallyingCry != null) cleanup("Rallying Cry", closingRallyingCry::close);
if (closingRallyingCrySettings != null) cleanup("Rallying Cry configuration", closingRallyingCrySettings::close);
if (closingFrostNova != null) cleanup("Frost Nova", closingFrostNova::close);
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
if (closingMeteor != null) cleanup("Meteor", closingMeteor::close);
if (closingMeteorSettings != null) cleanup("Meteor configuration", closingMeteorSettings::close);
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingLongbowSettings.drained().toCompletableFuture(),
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickstepSettings.drained().toCompletableFuture(),
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSecondWindSettings.drained().toCompletableFuture(),
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingStoneSkinSettings.drained().toCompletableFuture(),
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture(),
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHexSettings.drained().toCompletableFuture(),
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingInspireSettings.drained().toCompletableFuture(),
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingMendingLightSettings.drained().toCompletableFuture(),
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPurifySettings.drained().toCompletableFuture(),
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingGuardianBondSettings.drained().toCompletableFuture(),
closingRallyingCrySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRallyingCrySettings.drained().toCompletableFuture(),
closingFrostNovaSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostNovaSettings.drained().toCompletableFuture(),
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingChainLightningSettings.drained().toCompletableFuture(),
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSanctuarySettings.drained().toCompletableFuture(),
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingWarBannerSettings.drained().toCompletableFuture(),
closingMeteorSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingMeteorSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSwitchDelaySettings.drained().toCompletableFuture(),
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRoleplaySettings.drained().toCompletableFuture(),
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture(),
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSheetResourcePackSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingHex != null) cleanup("Hex", closingHex::close);
if (closingInspire != null) cleanup("Inspire", closingInspire::close);
if (closingTemporaryHealth != null) cleanup("temporary health", closingTemporaryHealth::close);
if (closingOutgoingDamageMitigation != null) cleanup("outgoing damage mitigation", closingOutgoingDamageMitigation::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
if (closingParties != null) cleanup("parties", closingParties::close);
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
@@ -195,16 +628,32 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingChat);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingCombatExperience);
unregister(closingFireball);
unregister(closingFrostShard);
unregister(closingLongbow);
unregister(closingQuickstep);
unregister(closingCombatMitigation);
unregister(closingShieldBash);
unregister(closingOutgoingDamageMitigation);
unregister(closingTemporaryHealth);
unregister(closingGreatSword);
unregister(closingRevive);
unregister(closingAdminRevival);
unregister(closingBasicAttack);
unregister(closingNutrition);
unregister(closingBooks);
unregister(closingEvents);
unregister(closingCrafting);
unregister(closingGuard);
unregister(closingAnvil);
unregister(closingRecipeDiscovery);
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
getServer().removeRecipe(CharacterSheets.RECIPE);
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
});
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
// Never await IO on the server thread or close receipts ahead of accepted preparations.
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
if (closingSessions == null) closingReceipts.close();
@@ -225,21 +674,218 @@ public final class FantasyStickPlugin extends JavaPlugin {
}
}
private java.time.Duration switchDelay() {
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
String value = String.valueOf(configured);
if (!value.matches("[0-9]+")) {
throw new IllegalArgumentException("character-swap-cooldown-seconds must be a nonnegative integer");
}
private ProgressionRules progressionRules() {
try {
var delay = java.time.Duration.ofSeconds(new java.math.BigInteger(value).longValueExact());
var deadline = java.time.Instant.now().plus(delay);
if (deadline.atOffset(java.time.ZoneOffset.UTC).getYear() > 9999) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the persisted UTC timestamp range");
}
return delay;
} catch (ArithmeticException | java.time.DateTimeException invalid) {
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the supported duration range", invalid);
var starting = new java.math.BigInteger(getConfig().getString("starting-points", "40"));
var threshold = new java.math.BigInteger(getConfig().getString("xp-per-level", "100"));
var maximum = new java.math.BigInteger(getConfig().getString("maximum-level", "30")).intValueExact();
return new ProgressionRules(starting, threshold, maximum);
} catch (ArithmeticException invalid) {
throw new IllegalArgumentException("maximum-level exceeds the supported integer range", invalid);
}
}
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
try {
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
}
}
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
}
}
private java.util.Map<String, String> frostShardConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FrostShardSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> longbowConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> quickstepConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> secondWindConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> stoneSkinConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> shieldBashConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> hexConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.HexSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.HexSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.HexSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> inspireConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.InspireSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.InspireSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.InspireSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> mendingLightConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.MendingLightSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.MendingLightSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> purifyConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.PurifySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> guardianBondConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.GuardianBondSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.GuardianBondSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> rallyingCryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.RallyingCrySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.RallyingCrySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> frostNovaConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostNovaSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FrostNovaSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> chainLightningConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ChainLightningSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ChainLightningSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> sanctuaryConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SanctuarySettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> warBannerConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> meteorConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.MeteorSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.MeteorSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
var recovery = java.time.Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
return new games.dmg.fantasystick.ability.GreatSword.Settings(base, recovery);
} catch (ArithmeticException | NumberFormatException invalid) {
throw new IllegalArgumentException("Great Sword settings must use supported nonnegative numeric values", invalid);
}
}
@@ -0,0 +1,40 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.bukkit.Material;
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
TargetPolicy targetPolicy, AbilityEffect effect) {
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
EXCEPTIONAL_NONCOMBAT_SELECTION }
private static final Pattern ID = Pattern.compile("[a-z][a-z0-9_]{0,63}");
private static final Pattern PARAMETER = Pattern.compile("[a-z][a-z0-9_-]{0,63}");
public AbilityDefinition {
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
Objects.requireNonNull(effect);
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
if (!ID.matcher(id).matches() || name.isBlank() || icon.isAir() || lore.isEmpty()
|| lore.stream().anyMatch(String::isBlank) || instructions.isBlank() || pointCost.signum() <= 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0 || requirements.values().stream().anyMatch(value -> value.compareTo(BigInteger.TEN) < 0)
|| parameters.entrySet().stream().anyMatch(entry -> !PARAMETER.matcher(entry.getKey()).matches()
|| entry.getValue().signum() < 0 || !Double.isFinite(entry.getValue().doubleValue()))) {
throw new IllegalArgumentException("Invalid ability definition");
}
}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick.ability;
/** Focused native effect invoked only after shared persistence, cooldown and target authorization. */
@FunctionalInterface
public interface AbilityEffect {
/** Effect-specific initial eligibility checked before the shared cooldown commit. */
default boolean accepts(games.dmg.fantasystick.session.ControlInput input) { return true; }
void activate(AbilityExecution execution);
}
@@ -0,0 +1,354 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
/** Shared character mutation, cooldown, target, execution, report and cleanup boundary. */
public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseable {
public record Failure(UUID character, String ability, Throwable cause) { }
public record Mutation(CharacterStorage.AbilityMutationStatus status, CharacterRecord record) { }
private record Key(UUID character, String ability) { }
private final AbilityRegistry registry;
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final AbilityScheduler scheduler;
private final Consumer<AbilityReport> reports;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
this.failures = Objects.requireNonNull(failures);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || sweepTask != null) throw new IllegalStateException("Ability engine is already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public CompletableFuture<Mutation> purchase(ControlInput input, String ability, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
var definition = registry.find(ability).orElse(null);
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.unlockAbility(input.session().character(), expectedRevision, ability,
definition.requirements(), definition.pointCost()));
}
public CompletableFuture<Mutation> assign(ControlInput input, String ability, int slot, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.assignAbility(input.session().character(), expectedRevision, ability, slot));
}
public CompletableFuture<Mutation> unlearn(ControlInput input, String ability, BigInteger expectedRevision) {
Objects.requireNonNull(input); Objects.requireNonNull(expectedRevision);
if (closed || registry.find(ability).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.AbilityMutationStatus.UNAVAILABLE, null));
}
return mutation(input, ability, expectedRevision,
() -> characters.unlearnAbility(input.session().character(), expectedRevision, ability))
.thenApply(mutation -> {
if (mutation.record() != null && !mutation.record().learned().contains(ability)) {
remove(input.session().character(), ability);
}
return mutation;
});
}
private CompletableFuture<Mutation> mutation(ControlInput input, String ability, BigInteger expectedRevision,
java.util.function.Supplier<CompletableFuture<CharacterStorage.AbilityMutation>> operation) {
var result = new CompletableFuture<Mutation>();
CompletableFuture<CharacterStorage.AbilityMutation> work;
try { work = operation.get(); }
catch (RuntimeException failure) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return result; }
work.whenComplete((mutation, failure) -> schedule(input.session().character(), ability, result, () -> {
if (failure != null) { report(input.session().character(), ability, failure); result.completeExceptionally(failure); return; }
var record = mutation.record().orElse(null);
if (mutation.status() == CharacterStorage.AbilityMutationStatus.APPLIED) {
try {
if (record == null || !input.stillCurrent()
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record)) {
result.complete(new Mutation(CharacterStorage.AbilityMutationStatus.STALE, record)); return;
}
} catch (RuntimeException nativeFailure) {
report(input.session().character(), ability, nativeFailure);
result.completeExceptionally(nativeFailure); return;
}
}
result.complete(new Mutation(mutation.status(), record));
}));
return result;
}
@Override public void accept(ControlInput input) {
if (closed || input.kind() != ControlInput.Kind.ABILITY || !input.stillCurrent()) return;
var definition = registry.find(input.ability()).orElse(null);
if (definition == null || definition.gesture() != input.gesture()) {
input.player().sendMessage("That ability is unavailable."); return;
}
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null || !record.learned().contains(definition.id())
|| input.slot() < 2 || !record.loadout().get(input.slot() - 2).equals(definition.id())) {
input.player().sendMessage("That ability is not learned and assigned to this control."); return;
}
Instant now = clock.instant();
Instant existing = record.cooldowns().get(definition.id());
if (existing != null && now.isBefore(existing)) {
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
boolean accepted;
try { accepted = eligible(definition.targetPolicy(), input, input.target()) && definition.effect().accepts(input); }
catch (RuntimeException invalid) {
report(input.session().character(), definition.id(), invalid);
input.player().sendMessage("That target could not be validated for " + definition.name() + "."); return;
}
if (!accepted) {
input.player().sendMessage("That target is not eligible for " + definition.name() + "."); return;
}
var key = new Key(input.session().character(), definition.id());
Object token = new Object();
if (pending.putIfAbsent(key, token) != null) { input.player().sendMessage("That cast is still being saved."); return; }
long generation = generations.getOrDefault(key, 0L);
Instant readyAt;
try { readyAt = now.plus(definition.cooldown()); }
catch (DateTimeException | ArithmeticException invalid) {
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
if (failure != null) {
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
boolean unchangedDeadline = mutation.status() == CharacterStorage.AbilityMutationStatus.UNCHANGED
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
case NOT_LEARNED, UNAVAILABLE -> "That ability is no longer available to this character.";
default -> "The cast was refused without applying an effect.";
});
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
/** Future refund/removal paths invalidate pending callbacks and close only this ability's owned work. */
public void remove(UUID character, String ability) {
var key = new Key(Objects.requireNonNull(character), Objects.requireNonNull(ability));
generations.put(key, generations.getOrDefault(key, 0L) + 1);
pending.remove(key);
for (var runtime : List.copyOf(active.getOrDefault(key, Set.of()))) runtime.close();
}
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
public void sweep() {
if (closed) return;
sessions.refreshCooldownControls(64);
int checked = 0;
outer: for (var runtimes : List.copyOf(active.values())) {
for (var runtime : List.copyOf(runtimes)) {
if (!runtime.stillCurrent()) runtime.close();
if (++checked == 64) break outer;
}
}
}
private boolean eligible(AbilityDefinition.TargetPolicy policy, ControlInput input, Entity target) {
return switch (policy) {
case SELF -> target == null || target == input.player();
case HOSTILE -> hostile(input, target);
case PROJECTILE_HOSTILE -> target == null || hostile(input, target);
case ALLY -> target instanceof Player player && fantasy(player);
case PARTY -> target instanceof Player player
&& (player == input.player() || fantasy(player) && sameParty(input.player(), player));
case AREA -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case SELECTION -> target == null || target instanceof LivingEntity && (!(target instanceof Player player) || fantasy(player));
case EXCEPTIONAL_NONCOMBAT -> target instanceof Player && target != input.player();
case EXCEPTIONAL_NONCOMBAT_SELECTION -> target == null || target instanceof Player && target != input.player();
};
}
private boolean hostile(ControlInput input, Entity target) {
return target instanceof LivingEntity && target != input.player()
&& (!(target instanceof Player player) || fantasy(player) && !sameParty(input.player(), player));
}
private boolean fantasy(Player player) { return sessions.active(player.getUniqueId()).filter(sessions::current).isPresent(); }
private boolean sameParty(Player source, Player target) {
return parties.partyOf(source.getUniqueId()).filter(party -> party.members().contains(target.getUniqueId())).isPresent();
}
private <T> void schedule(UUID character, String ability, CompletableFuture<T> result, Runnable action) {
try { nextTick.accept(action); }
catch (RuntimeException rejected) {
report(character, ability, rejected);
if (result != null) result.completeExceptionally(rejected);
}
}
private void report(UUID character, String ability, Throwable failure) {
try { failures.accept(new Failure(character, ability, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never authorize or replay an effect. */ }
}
private final class Runtime implements AbilityExecution {
private final Key key;
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final Set<AutoCloseable> owned = new HashSet<>();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@Override public CharacterRecord character() {
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
}
@Override public List<Entity> eligible(Collection<? extends Entity> candidates, int maximum) {
Objects.requireNonNull(candidates);
if (maximum < 1 || maximum > 64) throw new IllegalArgumentException("Recipient bound must be 1..64");
var result = new ArrayList<Entity>(); int inspected = 0;
for (var candidate : candidates) {
if (++inspected > 256 || result.size() == maximum) break;
if (stillCurrent() && AbilityEngine.this.eligible(definition.targetPolicy(), input, candidate)) result.add(candidate);
}
return List.copyOf(result);
}
@Override public AbilityScheduler.Task later(Duration delay, Runnable action) {
Objects.requireNonNull(delay); Objects.requireNonNull(action);
if (delay.isNegative() || delay.compareTo(Duration.ofDays(365)) > 0 || ++scheduled > 64) {
throw new IllegalArgumentException("Delayed effect is outside the bounded execution contract");
}
var task = scheduler.later(delay, () -> {
if (!stillCurrent() || !AbilityEngine.this.eligible(definition.targetPolicy(), input, input.target())) {
close(); return;
}
try { action.run(); }
catch (RuntimeException failure) {
close(); AbilityEngine.this.report(key.character(), key.ability(), failure);
}
});
own(task); return task;
}
@Override public void own(AutoCloseable resource) {
Objects.requireNonNull(resource);
if (finished || owned.size() >= 64) {
try { resource.close(); } catch (Exception ignored) { }
if (!finished) throw new IllegalStateException("Ability owns too many resources");
return;
}
owned.add(resource);
}
@Override public void report(AbilityReport.Type type, Entity target, java.math.BigDecimal amount) {
if (!stillCurrent() || definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|| definition.targetPolicy() == AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|| target == null || !AbilityEngine.this.eligible(definition.targetPolicy(), input, target)) return;
reports.accept(new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant()));
}
@Override public void finish() { close(); }
@Override public void close() {
if (finished) return;
finished = true;
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
pending.clear();
for (var runtimes : List.copyOf(active.values())) for (var runtime : List.copyOf(runtimes)) runtime.close();
active.clear(); generations.clear();
}
}
@@ -0,0 +1,22 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import org.bukkit.entity.Entity;
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
public interface AbilityExecution extends AutoCloseable {
AbilityDefinition definition();
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
void finish();
@Override void close();
}
@@ -0,0 +1,36 @@
package games.dmg.fantasystick.ability;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** Process-local validated definitions; saved unknown IDs remain character data, not executable definitions. */
public final class AbilityRegistry {
private final ConcurrentHashMap<String, AbilityDefinition> definitions = new ConcurrentHashMap<>();
public void register(AbilityDefinition definition) {
Objects.requireNonNull(definition);
if (definitions.putIfAbsent(definition.id(), definition) != null) {
throw new IllegalArgumentException("Duplicate ability ID: " + definition.id());
}
}
/** Replace one registered definition after an acknowledged runtime configuration save. */
public void replace(AbilityDefinition definition) {
Objects.requireNonNull(definition);
definitions.compute(definition.id(), (id, existing) -> {
if (existing == null) throw new IllegalArgumentException("Unknown ability ID: " + id);
return definition;
});
}
public Optional<AbilityDefinition> find(String id) {
return id == null ? Optional.empty() : Optional.ofNullable(definitions.get(id));
}
public List<AbilityDefinition> definitions() {
return definitions.values().stream().sorted(Comparator.comparing(AbilityDefinition::id)).toList();
}
}
@@ -0,0 +1,19 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Evidence of an actual accepted effect; consumers decide rewards separately. */
public record AbilityReport(Type type, UUID sourceCharacter, UUID sourceSession, UUID target,
BigDecimal amount, Instant occurredAt) {
public enum Type { DAMAGE, HEALING, CLEANSING, PREVENTION, BOOST_USED }
public AbilityReport {
Objects.requireNonNull(type); Objects.requireNonNull(sourceCharacter); Objects.requireNonNull(sourceSession);
Objects.requireNonNull(target); Objects.requireNonNull(amount); Objects.requireNonNull(occurredAt);
if (amount.signum() <= 0 || !Double.isFinite(amount.doubleValue())) {
throw new IllegalArgumentException("An effective report requires a positive finite amount");
}
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.ability;
import java.time.Duration;
/** Server-thread delayed-work boundary; returned tasks cancel only their owned callback. */
@FunctionalInterface
public interface AbilityScheduler {
Task later(Duration delay, Runnable action);
interface Task extends AutoCloseable {
@Override void close();
}
}
@@ -0,0 +1,203 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
int additionalTargets, BigDecimal jumpMultiplier) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|| additionalTargets < 0 || additionalTargets > 63
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("Invalid Chain Lightning settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private Attempt damaging;
private boolean closed;
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
+ current.additionalTargets() + " times.",
"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.",
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
}
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");
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public boolean accepts(ControlInput input) {
return !closed && selectInitial(input, settings.initialRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var caster = execution.input().player();
var initial = selectInitial(execution.input(), captured.initialRange());
if (initial == null) return;
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
var visited = new HashSet<UUID>(); LivingEntity current = initial;
int maximum = Math.min(64, 1 + captured.additionalTargets());
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
strike(execution, caster, current, defined); visual(caster, current);
if (strike + 1 == maximum) break;
defined = defined.multiply(captured.jumpMultiplier());
current = next(caster, current, visited, captured.jumpRange());
}
}
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
double amount = finite(definiteNonnegative(defined));
var origin = caster.getEyeLocation().clone();
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
.withDirectEntity(caster).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; }
}
private LivingEntity selectInitial(ControlInput input, double range) {
var caster = input.player();
if (input.target() instanceof LivingEntity supplied && eligible(caster, supplied)
&& inRangeAndVisible(caster, supplied, range)) return supplied;
try {
var eye = caster.getEyeLocation();
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), range, entity ->
entity instanceof LivingEntity target && eligible(caster, target) && inRangeAndVisible(caster, target, range));
if (hit == null || !(hit.getHitEntity() instanceof LivingEntity target)) return null;
return eligible(caster, target) && inRangeAndVisible(caster, target, range) ? target : null;
} catch (RuntimeException unavailable) { return null; }
}
private LivingEntity next(Player caster, LivingEntity previous, Set<UUID> visited, double range) {
List<Entity> nearby;
try { nearby = previous.getNearbyEntities(range, range, range); }
catch (RuntimeException unavailable) { return null; }
var candidates = new ArrayList<LivingEntity>(); var seen = new HashSet<UUID>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof LivingEntity target && seen.add(target.getUniqueId()) && !visited.contains(target.getUniqueId())
&& eligible(caster, target) && inRangeAndVisible(previous, target, range)) candidates.add(target);
}
var origin = previous.getLocation();
candidates.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
.thenComparing(LivingEntity::getUniqueId));
return candidates.isEmpty() ? null : candidates.getFirst();
}
private boolean eligible(Player caster, LivingEntity target) {
if (target == caster) 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);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
}
private static boolean inRangeAndVisible(LivingEntity source, LivingEntity target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, LivingEntity target) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static void visual(Player caster, LivingEntity target) {
try {
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
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"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,86 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
catch (RuntimeException invalid) { return false; }
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(player.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
return grant(execution, player, percent, () -> true);
}
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent, current);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void reduce(EntityDamageByEntityEvent event) {
if (closed || !(event.getEntity() instanceof Player player)) return;
Grant strongest = null;
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.player != player || !grant.valid()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
double reduced = BigDecimal.valueOf(original)
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,162 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
private static final double SPEED_PER_TICK = 1.0;
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
throw new IllegalArgumentException("Invalid Fireball settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
}
}
private final Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
LivingEntity target;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
}
@Override public void close() {
if (finished) return;
finished = true;
projectiles.remove(projectile.getUniqueId(), this);
projectile.remove();
}
}
public Fireball(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("fireball", "Fireball", Material.SNOWBALL,
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
"Use the equipped snowball; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(6), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Fireball damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Fireball direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
projectile.setVisualFire(true);
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Fireball projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(settings.lifetime(), execution::finish);
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,234 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Bounded instantaneous Frost Nova damage and ownership-aware Slowness. */
public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double radius,
int slownessAmplifier, Duration slownessDuration) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(slownessDuration);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| slownessAmplifier < 0 || slownessAmplifier > 255
|| slownessDuration.isNegative() || slownessDuration.isZero()
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Invalid Frost Nova settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(12), 5, 1, Duration.ofSeconds(3));
}
}
private static final class SlowLease implements AutoCloseable {
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
final long started = System.nanoTime(); boolean owned; boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
if (closed) return; closed = true; if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
}
}
private final class LeaseGroup implements AutoCloseable {
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
@Override public void close() {
if (closed) return; closed = true; active.remove(execution, this);
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
private Attempt damaging;
private boolean closed;
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("frost_nova", "Frost Nova", Material.SNOWBALL,
List.of("Burst in a " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere for "
+ current.baseDamage() + " + INT modifier damage.",
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
"slowness-duration-seconds", BigDecimal.valueOf(current.slownessDuration().toNanos()).movePointLeft(9)),
AbilityDefinition.TargetPolicy.AREA, this);
}
public static BigInteger damage(BigInteger intelligence) { return damage(BigInteger.valueOf(5), intelligence); }
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Nova damage input");
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var captured = from(execution.definition()); var source = execution.input().player();
Location origin = source.getEyeLocation().clone();
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
int affected = 0;
for (var target : candidates(source, origin, captured.radius())) {
if (affected == 64 || !execution.stillCurrent()) break;
if (!eligible(execution, target, origin, captured.radius())) continue;
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
.withDirectEntity(source).withDamageLocation(origin).build();
var attempt = new Attempt(execution, target); damaging = attempt;
try { target.damage(amount, sourceDamage); }
finally { if (damaging == attempt) damaging = null; }
affected++;
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
int ticks = Math.toIntExact(Math.max(1,
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
captured.slownessAmplifier(), false, true, true));
if (lease.apply()) group.leases.add(lease);
}
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
catch (RuntimeException ignored) { }
if (group.leases.isEmpty()) execution.finish();
else execution.later(captured.slownessDuration(), execution::finish);
}
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
List<Entity> nearby;
try { nearby = source.getNearbyEntities(radius, radius, radius); }
catch (RuntimeException unavailable) { return List.of(); }
var unique = new LinkedHashMap<UUID, LivingEntity>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof LivingEntity target && target != source) unique.putIfAbsent(target.getUniqueId(), target);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
.thenComparing(LivingEntity::getUniqueId));
return result;
}
private boolean eligible(AbilityExecution execution, LivingEntity target, Location origin, double radius) {
if (target == execution.input().player() || !inside(origin, target, radius)) return false;
try {
if (!execution.input().player().hasLineOfSight(target) || !unobstructed(origin, target)) return false;
} catch (RuntimeException unavailable) { return false; }
if (!(target instanceof Player player)) return true;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
var sourceParty = parties.partyOf(execution.input().player().getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var attempt = damaging;
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
}
private static boolean unobstructed(Location origin, LivingEntity target) {
var world = origin.getWorld(); if (world == null) return false;
var location = target.getLocation(); if (location.getWorld() != world) return false;
var bounds = target.getBoundingBox();
var center = bounds == null ? location.toVector().add(new org.bukkit.util.Vector(0, target.getHeight() / 2, 0))
: bounds.getCenter();
var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance,
org.bukkit.FluidCollisionMode.NEVER, true) == null;
}
private static boolean inside(Location origin, LivingEntity target, double radius) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
&& origin.distanceSquared(location) <= radius * radius; }
catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, LivingEntity target) {
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(parameters.get("base-damage").toBigIntegerExact(), definition.cooldown(),
parameters.get("radius").doubleValue(), parameters.get("slowness-amplifier").intValueExact(),
Duration.ofNanos(parameters.get("slowness-duration-seconds").movePointRight(9).longValueExact()));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
private static String seconds(Duration duration) {
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
}
private static String level(int amplifier) {
return switch (amplifier + 1) { case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; default -> Integer.toString(amplifier + 1); };
}
@Override public void close() {
if (closed) return; closed = true; damaging = null;
for (var group : List.copyOf(active.values())) { group.close(); group.execution.finish(); }
active.clear();
}
}
@@ -0,0 +1,233 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector;
/** Frost Shard's bounded direct projectile damage and owned Slowness adapter. */
public final class FrostShard implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime,
double speedPerTick, int slownessAmplifier, Duration slownessDuration) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
Objects.requireNonNull(slownessDuration);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64
|| slownessAmplifier < 0 || slownessAmplifier > 255
|| slownessDuration.isZero() || slownessDuration.isNegative()
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Invalid Frost Shard settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@Override public void close() {
if (finished) return;
finished = true; projectiles.remove(projectile.getUniqueId(), this); projectile.remove();
}
}
private static final class SlowLease implements AutoCloseable {
private final LivingEntity target;
private final PotionEffect previous;
private final PotionEffect applied;
private final long started = System.nanoTime();
private boolean owned;
private boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
if (closed) return;
closed = true;
if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
}
}
private volatile Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
List.of("Launch a bounded frost projectile.",
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()), "projectile-speed", BigDecimal.valueOf(current.speedPerTick()),
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
"slowness-duration", seconds(current.slownessDuration())),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(4), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Shard damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
boolean retainExecution = false;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
var current = state.settings;
BigInteger amount = damage(current.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
current.slownessAmplifier(), false, true, true));
if (lease.apply()) {
state.execution.own(lease);
state.execution.later(current.slownessDuration(), state.execution::finish);
retainExecution = true;
}
}
} finally {
state.close();
if (!retainExecution) state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
state.accepted = true;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static String level(int amplifier) {
return switch (amplifier + 1) {
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
default -> Integer.toString(amplifier + 1);
};
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9))
.stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */
public final class GreatSword implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration recovery) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery);
if (baseDamage.signum() < 0 || recovery.isNegative()
|| recovery.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
}
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
this.execution = execution; this.target = target;
}
}
private final Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
public static BigDecimal damage(BigInteger strength, float charge) {
return damage(BigInteger.valueOf(7), strength, charge);
}
static BigDecimal damage(BigInteger base, BigInteger strength, float charge) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) {
throw new IllegalArgumentException("Invalid Great Sword damage input");
}
BigInteger delta = strength.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
BigDecimal full = new BigDecimal(base.add(modifier));
BigDecimal nativeCharge = new BigDecimal(Float.toString(charge));
BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge)));
return full.multiply(curve).stripTrailingZeros();
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
throw new IllegalStateException("Great Sword damage is outside the native finite range");
}
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
damaging = attempt;
try { target.damage(nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,274 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.projectiles.ProjectileSource;
/** Conserved, non-recursive native combat redirection for Guardian Bond. */
public final class GuardianBond implements AbilityEffect, Listener, AutoCloseable {
public record Settings(Duration cooldown, double range, Duration duration, BigDecimal redirectPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(redirectPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| redirectPercent.signum() < 0 || redirectPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Guardian Bond settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(20), 10, Duration.ofSeconds(6), BigDecimal.valueOf(30));
}
}
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
public Support {
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter);
Objects.requireNonNull(aided); Objects.requireNonNull(amount);
}
}
@FunctionalInterface public interface Damage {
BigDecimal apply(Player guardian, Entity attacker, BigDecimal requested);
}
private final class State implements AutoCloseable {
final AbilityExecution execution;
final Player guardian;
final Player ally;
final EquipmentSessions.Active aided;
final Settings settings;
boolean closed;
State(AbilityExecution execution, Player guardian, Player ally,
EquipmentSessions.Active aided, Settings settings) {
this.execution = execution; this.guardian = guardian; this.ally = ally;
this.aided = aided; this.settings = settings;
}
@Override public void close() {
if (closed) return;
closed = true;
byGuardian.remove(guardian.getUniqueId(), this);
byAlly.remove(ally.getUniqueId(), this);
}
}
private static final class NativeTransfer {
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
BigDecimal accepted = BigDecimal.ZERO;
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
}
}
private static final ThreadLocal<Integer> REDIRECT_DEPTH = ThreadLocal.withInitial(() -> 0);
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Damage damage;
private final Consumer<Support> supports;
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
private int transferring;
private NativeTransfer nativeTransfer;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
Consumer<Support> supports) {
this(settings, sessions, parties, null, supports);
}
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
Damage damage, Consumer<Support> supports) {
this.settings = Objects.requireNonNull(settings);
this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties);
this.damage = damage == null ? this::nativeDamage : damage;
this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Guardian Bond sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
/** Bound active-link validation prevents an invalid link from becoming valid again later. */
public void sweep() {
if (closed) return;
int checked = 0;
for (var state : List.copyOf(byGuardian.values())) {
if (!current(state)) breakBond(state);
if (++checked == 256) break;
}
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("guardian_bond", "Guardian Bond", Material.IRON_CHESTPLATE,
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
"Use on an aimed active party ally; one guardian and one bond per ally.",
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"redirect-percent", current.redirectPercent()),
AbilityDefinition.TargetPolicy.PARTY, this);
}
@Override public boolean accepts(ControlInput input) {
if (closed || !(input.target() instanceof Player ally) || ally == input.player()) return false;
return byGuardian.get(input.player().getUniqueId()) == null && byAlly.get(ally.getUniqueId()) == null
&& eligible(input.player(), ally, settings.range()) && aided(ally) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof Player ally)) return;
var guardian = execution.input().player(); var captured = from(execution.definition()); var aided = aided(ally);
if (ally == guardian || aided == null || !eligible(guardian, ally, captured.range())
|| byGuardian.containsKey(guardian.getUniqueId()) || byAlly.containsKey(ally.getUniqueId())) {
execution.finish(); return;
}
var state = new State(execution, guardian, ally, aided, captured);
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
execution.own(state); execution.later(captured.duration(), execution::finish);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void redirect(EntityDamageByEntityEvent event) {
if (closed || redirectedDamage() || transferring != 0 || !(event.getEntity() instanceof Player ally)) return;
var state = byAlly.get(ally.getUniqueId());
if (state == null) return;
if (!current(state)) { breakBond(state); return; }
double incoming = event.getDamage();
if (!Double.isFinite(incoming) || incoming <= 0) return;
BigDecimal requested = BigDecimal.valueOf(incoming)
.multiply(state.settings.redirectPercent().movePointLeft(2));
if (requested.signum() <= 0) return;
Entity attacker = attacker(event);
if (attacker == null) return;
BigDecimal accepted;
transferring++; REDIRECT_DEPTH.set(REDIRECT_DEPTH.get() + 1);
try { accepted = damage.apply(state.guardian, attacker, requested); }
catch (RuntimeException failure) { return; }
finally {
transferring--;
int depth = REDIRECT_DEPTH.get() - 1;
if (depth == 0) REDIRECT_DEPTH.remove(); else REDIRECT_DEPTH.set(depth);
}
if (accepted == null || accepted.signum() <= 0) return;
BigDecimal maximum = requested.min(BigDecimal.valueOf(incoming));
if (accepted.compareTo(maximum) > 0) accepted = maximum;
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
if (!Double.isFinite(remaining)) return;
event.setDamage(remaining);
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
}
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
private BigDecimal nativeDamage(Player guardian, Entity attacker, BigDecimal requested) {
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
nativeTransfer = transfer;
try { guardian.damage(amount, attacker); }
finally { nativeTransfer = null; }
return transfer.accepted;
}
@EventHandler(priority = EventPriority.MONITOR)
public void acceptedRedirect(EntityDamageByEntityEvent event) {
var transfer = nativeTransfer;
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
}
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
public static boolean redirectedDamage() {
int depth = REDIRECT_DEPTH.get();
if (depth == 0) REDIRECT_DEPTH.remove();
return depth > 0;
}
private EquipmentSessions.Active aided(Player ally) {
return sessions.active(ally.getUniqueId()).filter(sessions::current).orElse(null);
}
private boolean current(State state) {
if (state.closed || !state.execution.stillCurrent() || state.ally != state.execution.input().target()
|| !sessions.current(state.aided)
|| !sessions.active(state.ally.getUniqueId()).filter(state.aided::equals).isPresent()
|| !eligible(state.guardian, state.ally, state.settings.range())) return false;
var guardianParty = parties.partyOf(state.guardian.getUniqueId()).orElse(null);
var allyParty = parties.partyOf(state.ally.getUniqueId()).orElse(null);
return guardianParty != null && allyParty != null && guardianParty.id().equals(allyParty.id());
}
private void breakBond(State state) {
state.close(); state.execution.finish();
}
private static boolean eligible(Player guardian, Player ally, double range) {
try {
var source = guardian.getLocation(); var target = ally.getLocation();
return source.getWorld() == target.getWorld() && source.distanceSquared(target) <= range * range
&& guardian.hasLineOfSight(ally);
} catch (RuntimeException invalid) { return false; }
}
private static Entity attacker(EntityDamageByEntityEvent event) {
Entity causing = event.getDamageSource().getCausingEntity();
if (causing != null) return causing;
if (event.getDamager() instanceof Projectile projectile) {
ProjectileSource shooter = projectile.getShooter();
if (shooter instanceof Entity entity) return entity;
}
return event.getDamager();
}
private static double effectiveHealth(Player player) {
return Math.max(0, player.getHealth()) + Math.max(0, player.getAbsorptionAmount());
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(definition.cooldown(), parameters.get("range").doubleValue(),
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
parameters.get("redirect-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
private static String seconds(Duration duration) {
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
}
@Override public void close() {
if (closed) return; closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
byGuardian.clear(); byAlly.clear();
}
}
@@ -0,0 +1,49 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.LivingEntity;
/** Hex's aimed, owned outgoing combat-damage penalty. */
public final class Hex implements AbilityEffect,AutoCloseable{
public record Settings(Duration cooldown,double range,Duration duration,BigDecimal reductionPercent){
public Settings{Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);Objects.requireNonNull(reductionPercent);
if(cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64
||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0
||reductionPercent.signum()<0||reductionPercent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid Hex settings");}
public static Settings defaults(){return new Settings(Duration.ofSeconds(10),12,Duration.ofSeconds(4),BigDecimal.valueOf(20));}}
private final class State implements AutoCloseable{final AbilityExecution execution;final LivingEntity target;final AutoCloseable grant;boolean closed;
State(AbilityExecution execution,LivingEntity target,AutoCloseable grant){this.execution=execution;this.target=target;this.grant=grant;}
@Override public void close(){if(closed)return;closed=true;hexes.remove(target.getUniqueId(),this);try{grant.close();}catch(Exception failure){throw new IllegalStateException(failure);}}}
private volatile Settings settings;private final OutgoingDamageMitigation mitigation;private final Map<UUID,State> hexes=new java.util.HashMap<>();private boolean closed;
public Hex(Settings settings,OutgoingDamageMitigation mitigation){this.settings=Objects.requireNonNull(settings);this.mitigation=Objects.requireNonNull(mitigation);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
}
@@ -0,0 +1,16 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;import games.dmg.fantasystick.session.ControlInput;import java.math.*;import java.time.Duration;import java.util.*;import org.bukkit.*;import org.bukkit.entity.Player;
/** Inspire's self-or-aimed fantasy-player temporary health grant. */
public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){closed=true;}
}
@@ -0,0 +1,220 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.MathContext;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.util.Vector;
/** Longbow's ammunition-free native draw and bounded custom-arrow damage adapter. */
public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
private static final BigInteger MAX_NATIVE_BASE = BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger();
public record Settings(BigInteger baseDamage, Duration recovery, int minimumDrawTicks, int fullDrawTicks,
double range, Duration lifetime, double fullSpeedPerTick) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery); Objects.requireNonNull(lifetime);
if (baseDamage.signum() < 0 || baseDamage.compareTo(MAX_NATIVE_BASE) > 0
|| recovery.compareTo(Duration.ofSeconds(1)) < 0
|| recovery.compareTo(Duration.ofDays(365)) > 0
|| minimumDrawTicks < 1 || minimumDrawTicks > 72_000
|| fullDrawTicks < minimumDrawTicks || fullDrawTicks > 72_000
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(fullSpeedPerTick) || fullSpeedPerTick < 0.01 || fullSpeedPerTick > 64) {
throw new IllegalArgumentException("Invalid Longbow settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(1), 3, 20, 64,
Duration.ofSeconds(10), 3);
}
}
private final class State implements AutoCloseable {
final Arrow arrow;
final AbilityExecution execution;
final Location origin;
final Settings settings;
final BigDecimal damage;
LivingEntity target;
boolean accepted;
boolean finished;
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
}
@Override public void close() {
if (finished) return;
finished = true; arrows.remove(arrow.getUniqueId(), this); arrow.remove();
}
}
private volatile Settings settings;
private final Map<UUID, State> arrows = new HashMap<>();
private State damaging;
private boolean closed;
public Longbow(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("longbow", "Longbow", Material.BOW,
List.of("Draw and release an ammunition-free owned arrow.",
"A full draw deals " + current.baseDamage() + " + DEX modifier damage."),
"Draw by holding use; release after at least " + current.minimumDrawTicks() + " ticks.",
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
"full-draw-ticks", BigDecimal.valueOf(current.fullDrawTicks()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()),
"full-projectile-speed", BigDecimal.valueOf(current.fullSpeedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return input.gesture() == ControlInput.Gesture.DRAW_RELEASE && input.heldTicks() >= settings.minimumDrawTicks();
}
public static BigDecimal power(int heldTicks, int fullDrawTicks) {
if (heldTicks < 0 || fullDrawTicks < 1) throw new IllegalArgumentException("Invalid Longbow draw");
var x = BigDecimal.valueOf(heldTicks).divide(BigDecimal.valueOf(fullDrawTicks), MathContext.DECIMAL128);
var power = x.multiply(x, MathContext.DECIMAL128).add(x.multiply(BigDecimal.TWO), MathContext.DECIMAL128)
.divide(BigDecimal.valueOf(3), MathContext.DECIMAL128);
return power.min(BigDecimal.ONE);
}
public static BigInteger fullDamage(BigInteger dexterity) {
return fullDamage(BigInteger.valueOf(5), dexterity);
}
static BigInteger fullDamage(BigInteger base, BigInteger dexterity) {
Objects.requireNonNull(base); Objects.requireNonNull(dexterity);
if (base.signum() < 0 || dexterity.signum() < 0) throw new IllegalArgumentException("Invalid Longbow damage input");
BigInteger delta = dexterity.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
Settings shot = from(execution.definition());
BigDecimal power = power(execution.input().heldTicks(), shot.fullDrawTicks());
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Longbow direction is unavailable");
double speed = BigDecimal.valueOf(shot.fullSpeedPerTick()).multiply(power, MathContext.DECIMAL128).doubleValue();
var arrow = player.launchProjectile(Arrow.class, direction.normalize().multiply(speed));
arrow.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
arrow.setDamage(0); arrow.setKnockbackStrength(0); arrow.setPierceLevel(0); arrow.setCritical(false);
var damage = new BigDecimal(fullDamage(shot.baseDamage(), execution.character().stats().get(Stat.DEX)))
.multiply(power, MathContext.DECIMAL128);
var state = new State(arrow, execution, origin, shot, damage);
if (arrows.putIfAbsent(arrow.getUniqueId(), state) != null) {
arrow.remove(); throw new IllegalStateException("Duplicate Longbow arrow identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
execution.later(shot.lifetime(), () -> {
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
});
}
@EventHandler(priority = EventPriority.HIGHEST)
public void containNativeDamage(EntityDamageByEntityEvent event) {
if (!(event.getDamager() instanceof Arrow arrow)) return;
var state = arrows.get(arrow.getUniqueId());
if (state != null && damaging != state) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = arrows.remove(event.getEntity().getUniqueId());
if (state == null) return;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target)
|| !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : state.damage.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.close(); state.execution.finish();
}
}
private static boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.arrow)) return;
state.accepted = true;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
p.get("minimum-draw-ticks").intValueExact(), p.get("full-draw-ticks").intValueExact(),
p.get("range").doubleValue(), duration(p.get("lifetime")),
p.get("full-projectile-speed").doubleValue());
}
private static Duration duration(BigDecimal seconds) {
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(arrows.values())) state.execution.finish();
arrows.clear(); damaging = null;
}
}
@@ -0,0 +1,183 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Mending Light's aimed party healing with persist-before-publication support evidence. */
public final class MendingLight implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown, double range) {
public Settings {
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
if (baseHealing.signum() < 0
|| baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64) {
throw new IllegalArgumentException("Invalid Mending Light settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(8), 12); }
}
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
public Support {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
}
}
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private final Consumer<Support> supports;
private volatile Settings settings;
public MendingLight(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit, Consumer<Support> supports) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("mending_light", "Mending Light", Material.GHAST_TEAR,
List.of("Restore a party ally's missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
}
@Override public boolean accepts(ControlInput input) {
var target = target(input, settings.range());
if (target == null) return false;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
double health = target.player().getHealth();
return attribute != null && Double.isFinite(health) && health >= 0
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
}
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
static BigInteger healing(BigInteger base, BigInteger wisdom) {
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Mending Light healing input");
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
});
}
private void complete(AbilityExecution execution, Target target, BigInteger expected, double accepted,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue());
double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.input().player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
target.player().sendMessage("Mending Light restored " + amount.toPlainString() + " health.");
} finally { execution.finish(); }
}
private Target target(ControlInput input, double range) {
if (!(input.target() instanceof Player player) || player == input.player() || !inRange(input.player(), player, range)
|| !sameParty(input.player(), player)) return null;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return record == null ? null : new Target(player, active, record);
}
private boolean current(Player source, Target target, double range) {
return sessions.current(target.active())
&& sessions.currentRecord(target.active()).filter(record -> record.id().equals(target.record().id())).isPresent()
&& inRange(source, target.player(), range) && sameParty(source, target.player());
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inRange(Player source, Player target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(parameters.get("base-healing").toBigIntegerExact(), definition.cooldown(),
parameters.get("range").doubleValue());
}
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
}
@@ -0,0 +1,79 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.*;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.*;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
/** Bounded solid-top Meteor warning followed by one direct, terrain-safe cylinder impact. */
public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double targetRange, Duration warning,
double radius, double verticalRange) {
public Settings {
Objects.requireNonNull(baseDamage);Objects.requireNonNull(cooldown);Objects.requireNonNull(warning);
long warningTicks;
try { warningTicks=Math.max(1,(warning.toNanos()+49_999_999L)/50_000_000L); }
catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid Meteor warning",invalid);}
if(baseDamage.signum()<0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0
||!Double.isFinite(targetRange)||targetRange<=0||targetRange>64
||warning.isZero()||warning.isNegative()||warningTicks>63
||!Double.isFinite(radius)||radius<=0||radius>64
||!Double.isFinite(verticalRange)||verticalRange<0||verticalRange>64)
throw new IllegalArgumentException("Invalid Meteor settings");
}
public static Settings defaults(){return new Settings(BigInteger.valueOf(14),Duration.ofSeconds(45),24,
Duration.ofSeconds(2),5,4);}
}
private record Ground(Block block,Location origin){ }
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
private final class Warning implements AutoCloseable {
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{target.damage(amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
}
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
private static double finite(BigInteger value){return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(p.get("base-damage").toBigIntegerExact(),definition.cooldown(),p.get("target-range").doubleValue(),Duration.ofNanos(p.get("warning-seconds").movePointRight(9).longValueExact()),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue());}
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}private static String seconds(Duration value){return decimal(nanos(value));}private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;damaging=null;for(var warning:List.copyOf(warnings.values())){warning.close();warning.execution.finish();}warnings.clear();}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.projectiles.ProjectileSource;
/** Strongest deterministic Fantasy Stick outgoing combat bonus stage. */
public final class OutgoingDamageBonus implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player recipient;
final BigDecimal percent; final BooleanSupplier current; boolean closed;
Grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
sequence = ++sequences; this.execution = execution; this.recipient = recipient;
this.percent = percent; this.current = current;
}
boolean valid() {
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
catch (RuntimeException invalid) { return false; }
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(recipient.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Support> supports;
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Used> pending = new IdentityHashMap<>();
private long sequences;
private boolean closed;
public OutgoingDamageBonus(EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.supports = Objects.requireNonNull(supports);
}
public AutoCloseable grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
Objects.requireNonNull(execution); Objects.requireNonNull(recipient);
Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(1000)) > 0) {
throw new IllegalArgumentException("Invalid outgoing damage bonus");
}
var grant = new Grant(execution, recipient, percent, current);
grants.computeIfAbsent(recipient.getUniqueId(), ignored -> new ArrayList<>()).add(grant); return grant;
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void increase(EntityDamageByEntityEvent event) {
if (closed || GuardianBond.redirectedDamage()) return;
Entity attacker = attacker(event); if (!(attacker instanceof Player player)) return;
var owned = grants.get(player.getUniqueId()); if (owned == null) return;
Grant strongest = null;
for (var grant : List.copyOf(owned)) {
if (grant.recipient != player || !grant.valid()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
Entity causing = event.getDamageSource().getCausingEntity(); if (causing != null) return causing;
if (event.getDamager() instanceof Projectile projectile) {
ProjectileSource shooter = projectile.getShooter(); if (shooter instanceof Entity entity) return entity;
}
return event.getDamager();
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,78 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.projectiles.ProjectileSource;
/** Internal pre-recipient strongest outgoing-damage reduction stage. */
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
}
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
}
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
private long sequences; private boolean closed;
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
public void reduce(EntityDamageByEntityEvent event){
if(closed||GuardianBond.redirectedDamage())return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
}
@EventHandler(priority=EventPriority.MONITOR)
public void accepted(EntityDamageEvent event){
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
if(!(prevention.recipient instanceof Player player))return;
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
}
private static Entity attacker(EntityDamageByEntityEvent event){
Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;
if(event.getDamager() instanceof Projectile projectile){ProjectileSource shooter=projectile.getShooter();if(shooter instanceof Entity entity)return entity;}
return event.getDamager();
}
@Override public void close(){if(closed)return;closed=true;for(var owned:List.copyOf(grants.values()))for(var grant:List.copyOf(owned))grant.close();grants.clear();pending.clear();}
}
@@ -0,0 +1,128 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffectType;
/** Purify's self-or-aimed-party native harmful-effect removal. */
public final class Purify implements AbilityEffect {
public record Settings(Duration cooldown, double range) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64) {
throw new IllegalArgumentException("Invalid Purify settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
}
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
public Support {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
}
}
private record Target(Player player, EquipmentSessions.Active active) { }
private static final List<PotionEffectType> HARMFUL = List.of(
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final Consumer<Support> supports;
private volatile Settings settings;
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
List.of("Remove Poison, Wither, Slowness and Weakness.",
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.PARTY, this);
}
@Override public boolean accepts(ControlInput input) {
var target = target(input, settings.range());
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
}
if (removed == 0) return;
execution.input().player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
var amount = BigDecimal.valueOf(removed);
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
target.player().sendMessage("Purify removed " + removed + " harmful effect" + (removed == 1 ? "." : "s."));
}
}
private Target target(ControlInput input, double range) {
if (!(input.target() instanceof Player player)) return null;
if (input.player().isSneaking()) {
if (player != input.player()) return null;
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
return null;
}
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
return active == null ? null : new Target(player, active);
}
private boolean current(ControlInput input, Target target, double range) {
if (!sessions.current(target.active())) return false;
if (target.player() == input.player()) return input.player().isSneaking();
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
&& sameParty(input.player(), target.player());
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inRange(Player source, Player target, double range) {
try {
var from = source.getLocation(); var to = target.getLocation();
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
}
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
}
@@ -0,0 +1,153 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
public record Settings(Duration cooldown, double distance, double speedPerTick) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
throw new IllegalArgumentException("Invalid Quickstep settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
}
private final class State implements AutoCloseable {
final AbilityExecution execution;
final Player player;
final Location origin;
final Vector horizontal;
final Settings settings;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
double magnitude = horizontalCurrent.length();
if (magnitude < 1.0e-6) return true;
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
}
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
player.setVelocity(new Vector(0, current.getY(), 0));
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private final Map<UUID, State> dashes = new HashMap<>();
private boolean closed;
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
var player = input.player();
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
return finite(direction) && direction.lengthSquared() > 1.0e-12;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void moved(PlayerMoveEvent event) {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
}
}
private static boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
definition.parameters().get("speed").doubleValue());
}
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(dashes.values())) state.execution.finish();
dashes.clear();
}
}
@@ -0,0 +1,227 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
/** Dynamic owned party sphere for Rallying Cry movement and outgoing damage benefits. */
public final class RallyingCry implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double radius, Duration duration,
int speedAmplifier, BigDecimal damageBonusPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(damageBonusPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| speedAmplifier < 0 || speedAmplifier > 255
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Rallying Cry settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(25), 8, Duration.ofSeconds(6), 0, BigDecimal.valueOf(20));
}
}
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { breakArea(this); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) {
if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
}
for (var entry : eligible.entrySet()) {
if (!recipients.containsKey(entry.getKey())) {
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
}
}
}
boolean sourceCurrent() {
return execution.stillCurrent() && sessions.active(source.getUniqueId())
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
}
boolean eligible(Player player) {
if (!sourceCurrent()) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null || !inside(source, player, settings.radius())) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var recipientParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && recipientParty != null && sourceParty.id().equals(recipientParty.id());
}
Map<UUID, Player> candidates() {
var result = new LinkedHashMap<UUID, Player>();
if (eligible(source)) result.put(source.getUniqueId(), source);
List<Entity> nearby;
try { nearby = source.getNearbyEntities(settings.radius(), settings.radius(), settings.radius()); }
catch (RuntimeException unavailable) { nearby = List.of(); }
int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256 || result.size() == 64) break;
if (entity instanceof Player player && eligible(player)) result.putIfAbsent(player.getUniqueId(), player);
}
return result;
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
}
}
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; }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class SpeedState {
final Player player; final List<SpeedGrant> grants = new ArrayList<>(); PotionEffect owned;
SpeedState(Player player) { this.player = player; }
void refresh() {
PotionEffect current;
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
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
|| grant.amplifier > strongest.amplifier
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
if (strongest == null) {
if (owned != null && same(current, owned) && !removeOwned()) return;
owned = null; speeds.remove(player.getUniqueId(), this); return;
}
if (owned != null && same(current, owned) && owned.getAmplifier() != strongest.amplifier) {
if (!removeOwned()) 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);
try { if (player.addPotionEffect(effect, true)) owned = effect; }
catch (RuntimeException ignored) { owned = null; }
}
boolean removeOwned() {
try {
player.removePotionEffect(PotionEffectType.SPEED);
if (same(player.getPotionEffect(PotionEffectType.SPEED), owned)) return false;
} catch (RuntimeException failure) { return false; }
owned = null; return true;
}
}
private volatile Settings settings;
private final EquipmentSessions sessions;
private final PartyRoster parties;
private final OutgoingDamageBonus bonuses;
private final Map<Long, Area> areas = new java.util.HashMap<>();
private final Map<UUID, SpeedState> speeds = new java.util.HashMap<>();
private long sequences; private long speedSequences;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Rallying Cry sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public void sweep() {
if (closed) return; int checked = 0;
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
for (var state : List.copyOf(speeds.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("rallying_cry", "Rallying Cry", Material.GOAT_HORN,
List.of("Grant nearby active party members Speed " + (current.speedAmplifier() + 1)
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
"Use to rally yourself and nearby active party members; copies do not stack.",
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"speed-amplifier", BigDecimal.valueOf(current.speedAmplifier()),
"damage-bonus-percent", current.damageBonusPercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
} catch (RuntimeException invalid) { return false; }
}
private static boolean same(PotionEffect first, PotionEffect second) {
return first != null && second != null && first.getType().equals(second.getType())
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite();
}
private static void closeResource(AutoCloseable resource) {
try { resource.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
private static Settings from(AbilityDefinition definition) {
var parameters = definition.parameters();
return new Settings(definition.cooldown(), parameters.get("radius").doubleValue(),
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
parameters.get("speed-amplifier").intValueExact(), parameters.get("damage-bonus-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
private static String seconds(Duration duration) {
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
}
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
}
@@ -0,0 +1,376 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.TailBooks;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.RevivalStore;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Revive target selection, contained popup and proof-based Tail conversion. */
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
private static final Duration DEATH_AGE = Duration.ofHours(24);
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
private static final double RANGE = 12;
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
private record Choice(Candidate candidate, CharacterRecord record) { }
private final CharacterStorage characters;
private final RevivalStore reservations;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final PopupFactory popups;
private final Consumer<Throwable> failures;
private final TailBooks tails = new TailBooks();
private final CharacterSheets sheets = new CharacterSheets();
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
private final Map<UUID, View> targets = new LinkedHashMap<>();
private boolean closed;
private final class View implements AutoCloseable {
final AbilityExecution execution;
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
boolean selecting;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
}
@Override public void close() {
if (finished) return;
finished = true;
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
inventory.clear();
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
}
}
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
PopupFactory popups, Consumer<Throwable> failures) {
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
}
public AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Player target = target(execution.input());
if (target == null) {
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
execution.finish(); return;
}
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
var found = loads.get(index).join();
if (found.isEmpty()) continue;
var record = found.orElseThrow();
var candidate = captured.get(index);
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
}));
}
private Player target(ControlInput input) {
Player caster = input.player();
Entity supplied = input.target();
Player selected = supplied instanceof Player player ? player : null;
if (selected == null) {
Location eye = caster.getEyeLocation();
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
entity -> entity instanceof Player && entity != caster);
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
}
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|| !caster.hasLineOfSight(selected)) return null;
return selected;
}
private List<Candidate> candidates(Player target) {
var result = new ArrayList<Candidate>();
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
ItemStack item = target.getInventory().getItem(slot);
if (item == null || item.isEmpty()) continue;
var identity = tails.identify(item);
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
}
return List.copyOf(result);
}
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
Duration age = Duration.between(record.diedAt(), now);
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
}
private boolean unchanged(Player target, Candidate candidate) {
ItemStack current = target.getInventory().getItem(candidate.slot());
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
var choices = new LinkedHashMap<Integer, Choice>();
for (int index = 0; index < eligible.size() && index < size; index++) {
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
var meta = display.getItemMeta();
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
meta.lore(List.of(Component.text("Level " + choice.record().level()),
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view == null) return;
event.setCancelled(true);
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
Choice choice = view.choices.get(event.getRawSlot());
if (choice == null || view.selecting) return;
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
view.inventory.clear(); view.target.closeInventory();
confirm(view, choice);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && !view.selecting) view.execution.finish();
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
for (var view : List.copyOf(inventories.values())) {
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
}));
}
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
candidate.slot(), source.getAmount(), destination);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|| !unchanged(view.target, candidate)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.execution.finish(); return;
}
deliver(view, dead, reservation);
}));
}
private int emptyDirectSlot(Player target, int source) {
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40 || slot == source) continue;
ItemStack item = target.getInventory().getItem(slot);
if (item == null || item.isEmpty()) return slot;
}
return -1;
}
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
try {
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
ItemStack sheet = sheets.create(provisional);
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
else {
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
}
view.target.updateInventory();
} catch (RuntimeException uncertain) {
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.execution.finish(); return;
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
view.execution.finish(); return;
}
if (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
view.execution.finish();
}));
}
private boolean installFinalSheet(Player target, CharacterRecord record) {
int slot = uniqueSheetSlot(target, record.id());
if (slot < 0) return false;
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
}
private int uniqueSheetSlot(Player target, UUID character) {
int found = -1;
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
if (found >= 0) return -2;
found = slot;
}
return found;
}
public void reconcile(Player target) {
if (closed || !target.isOnline()) return;
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
if (failure != null) { report(failure); return; }
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
if (loadFailure != null) { report(loadFailure); return; }
if (closed || !target.isOnline() || record.isEmpty()) return;
reconcile(target, reservation, record.orElseThrow());
}));
});
}
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
int foundSheet = uniqueSheetSlot(target, reservation.character());
boolean hasSheet = foundSheet >= 0;
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
: source != null && source.getAmount() == reservation.sourceAmount() - 1
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
if (unchangedTail && reservation.sourceAmount() > 1) {
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
(reservation.administrative()
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit)
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit))
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id());
}
}));
}
}
private void schedule(Runnable action) {
try { nextTick.accept(action); } catch (RuntimeException rejected) { report(rejected); }
}
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() {
if (closed) return; closed = true;
for (var view : List.copyOf(inventories.values())) view.execution.finish();
inventories.clear(); targets.clear();
}
}
@@ -0,0 +1,248 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
public final class Sanctuary implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|| duration.toNanos() / pulseInterval.toNanos() > 64
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid Sanctuary settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
}
}
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
int pending; boolean expired; boolean closed;
State(AbilityExecution execution, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
this.captured = captured; this.nextPulse = captured.pulseInterval();
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
this.scanPeriod = Duration.ofNanos(period);
}
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
void schedule() {
if (closed || expired) return;
Duration remaining = captured.duration().minus(elapsed);
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
}
void step() {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Duration remaining = captured.duration().minus(elapsed);
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
reconcile(); visual();
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
}
if (elapsed.compareTo(captured.duration()) >= 0) {
expired = true; closeMitigation(); if (pending == 0) execution.finish();
} else schedule();
}
void reconcile() {
var eligible = recipients();
for (var entry : List.copyOf(mitigation.entrySet())) {
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
}
}
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
}
void pulse() {
for (var player : recipients()) if (owner(player) == this) heal(player);
}
void heal(Player player) {
var target = target(player); if (target == null) return;
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
pending++; var expected = target.record().revision();
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
.whenComplete((saved, failure) -> {
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
catch (RuntimeException rejected) { completePending(this); }
});
}
List<Player> recipients() {
List<Entity> nearby;
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
}
boolean recipientCurrent(Player player) {
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
.map(active -> active.equals(execution.input().session())).orElse(false);
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
return active != null && sameParty(source, player);
}
Target target(Player player) {
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return record == null ? null : new Target(player, active, record);
}
void visual() {
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
}
void closeMitigation() {
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
mitigation.clear();
}
@Override public void close() {
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
}
}
private volatile Settings settings;
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
private boolean closed;
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
Consumer<MendingLight.Support> supports) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
this.supports = Objects.requireNonNull(supports);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; new State(execution, from(execution.definition())).start();
}
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|| !state.recipientCurrent(target.player())) return;
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
double current = target.player().getHealth(), maximum = attribute.getValue();
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
target.player().setHealth(Math.min(maximum, current + restored));
if (target.player() != state.source) supports.accept(new MendingLight.Support(
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
} finally { completePending(state); }
}
private static void completePending(State state) {
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
}
private State owner(Player player) {
for (var state : List.copyOf(states)) if (!state.expired
&& state.mitigation.containsKey(player.getUniqueId())) return state;
return null;
}
private boolean sameParty(Player source, Player target) {
var party = parties.partyOf(source.getUniqueId()).orElse(null);
return party != null && party.members().contains(target.getUniqueId());
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
var center = box.getCenter(); var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
} catch (RuntimeException invalid) { return false; }
}
private static double distance(Location origin, Player player) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
}
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
private static String seconds(Duration value) { return decimal(nanos(value)); }
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
}
}
@@ -0,0 +1,115 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Second Wind's validated persist-before-publication self-heal. */
public final class SecondWind implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown) {
public Settings {
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Second Wind settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private volatile Settings settings;
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
double health = input.player().getHealth();
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
return attribute != null && Double.isFinite(health) && health >= 0
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
}
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
static BigInteger healing(BigInteger base, BigInteger wisdom) {
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var definition = execution.definition();
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
var player = execution.input().player();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = player.getHealth(); double maximum = attribute.getValue();
double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(missing, event.getAmount());
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var record = execution.character(); var expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> {
try {
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
player.setHealth(publish);
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
});
}
}
@@ -0,0 +1,144 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
double horizontalKnockback, double minimumVerticalKnockback) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
this.execution = execution; this.target = target; this.settings = settings;
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private Attempt damaging;
private boolean closed;
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
}
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
static BigInteger damage(BigInteger base, BigInteger strength) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
var delta = strength.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
}
private static boolean inRange(Player player, LivingEntity target, double range) {
try {
Location source = player.getLocation(); Location destination = target.getLocation();
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
&& player.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static boolean finite(Vector value) {
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
}
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Player;
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
public final class StoneSkin implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid Stone Skin settings");
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
}
}
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
State(AbilityExecution execution, Player player, AutoCloseable grant) {
this.execution = execution; this.player = player; this.grant = grant;
}
@Override public void close() {
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
}
private volatile Settings settings;
private final CombatMitigation mitigation;
private final Map<UUID, State> protections = new java.util.HashMap<>();
private boolean closed;
public StoneSkin(Settings settings, CombatMitigation mitigation) {
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(),
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
definition.parameters().get("damage-reduction-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
protections.clear();
}
}
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
}
@@ -0,0 +1,241 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import org.bukkit.Color;
import org.bukkit.FluidCollisionMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Particle;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.attribute.AttributeModifier;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
public final class WarBanner implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|| knockbackResistancePercent.signum() < 0
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
throw new IllegalArgumentException("Invalid War Banner settings");
}
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
}
}
private final class Area implements AutoCloseable {
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
boolean closed;
Area(AbilityExecution execution, Location origin, Settings captured) {
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
}
void refresh() {
if (closed) return;
if (!sourceCurrent()) { execution.finish(); return; }
var eligible = candidates();
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
visual();
}
boolean sourceCurrent() {
return execution.stillCurrent() && sessions.active(source.getUniqueId())
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
}
boolean eligible(Player player) {
if (!sourceCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|| !unobstructed(origin, player)) return false;
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
if (active == null) return false;
if (player == source) return active.equals(execution.input().session());
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
}
Map<UUID, Player> candidates() {
List<Entity> nearby;
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
captured.radius(), captured.verticalRange(), captured.radius())); }
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
for (var entity : nearby) {
if (++inspected > 256) break;
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
}
var result = new ArrayList<>(unique.values());
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
if (result.size() > 64) result.subList(64, result.size()).clear();
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
}
void visual() {
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
BooleanSupplier current = () -> !closed && area.eligible(player);
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(resistance);
}
}
private final class ResistanceGrant implements AutoCloseable {
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
final BooleanSupplier current; boolean closed;
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
this.state = state; this.percent = percent; this.current = current;
}
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
}
private final class ResistanceState {
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
ResistanceState(Player player) { this.player = player; }
void refresh() {
AttributeInstance attribute;
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
catch (RuntimeException unavailable) { return; }
if (attribute == null) return;
ResistanceGrant strongest = null;
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|| grant.percent.compareTo(strongest.percent) > 0
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
attribute.removeModifier(owned); owned = null;
}
if (strongest != null && owned == null) {
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
}
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
}
}
private volatile Settings settings;
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin); if (closed || sweepTask != null) throw new IllegalStateException("War Banner sweep already installed or closed");
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
}
public void sweep() {
if (closed) return; int checked = 0;
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
for (var state : List.copyOf(resistances.values())) state.refresh();
}
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
var origin = placement(execution.input().player(), captured.placementRange());
if (origin == null) { execution.finish(); return; }
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
area.refresh(); execution.later(captured.duration(), execution::finish);
}
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
}
private static Location placement(Player player, double range) {
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
FluidCollisionMode.NEVER, true);
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|| !hit.getHitBlock().getType().isSolid()) return null;
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
} catch (RuntimeException invalid) { return null; }
}
private static boolean inside(Location origin, Player player, double radius, double vertical) {
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
} catch (RuntimeException invalid) { return false; }
}
private static boolean unobstructed(Location origin, Player player) {
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().subtract(origin.toVector());
double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
} catch(RuntimeException invalid){return false;}
}
private static double distance(Location origin, Player player) {
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
}
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
private static String seconds(Duration value){return decimal(nanos(value));}
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
@Override public void close(){if(closed)return;closed=true;if(sweepTask!=null)sweepTask.cancel();for(var area:List.copyOf(areas.values()))area.execution.finish();areas.clear();for(var state:List.copyOf(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
}
@@ -9,6 +9,15 @@ public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = lines(text);
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
static List<String> lines(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
@@ -28,10 +37,6 @@ public final class BookPagination {
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
return List.copyOf(lines);
}
}
@@ -16,30 +16,56 @@ import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BookMeta;
/** Authoritative inspection and validated party controls; character mutation actions remain unfinished. */
/** Authoritative inspection, permanent-stat purchases and validated party controls. */
public final class CharacterBookController implements Consumer<ControlInput>, AutoCloseable, org.bukkit.event.Listener {
public record Failure(UUID player, UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules rules;
private final games.dmg.fantasystick.session.EquipmentSessions sessions;
private final PartyBookControls partyControls;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
this(characters, rules, sessions, nextTick, failures, parties, server, names,
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
}
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
games.dmg.fantasystick.session.EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
games.dmg.fantasystick.ability.AbilityRegistry abilities,
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
this.characters = Objects.requireNonNull(characters);
this.rules = Objects.requireNonNull(rules);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
this.abilities = Objects.requireNonNull(abilities);
this.abilityEngine = abilityEngine;
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@@ -51,6 +77,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -78,29 +106,47 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString()
+ "\n\nAbilities: " + learned;
var item = new ItemStack(Material.WRITTEN_BOOK);
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var all = BookPagination.pages(first + "\n" + second);
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
if (all.size() > VOLUME_SIZE) {
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
var allOverview = overviewPages(record, input, statView);
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
if (record.unspentPoints().signum() > 0 && start == 0) statViews.put(input.session().player(), statView);
var definitions = abilities.definitions().stream()
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
.toList();
if (!definitions.isEmpty() && end == allOverview.size()) {
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
abilityViews.put(input.session().player(), abilityView);
var details = definitions.stream().map(definition -> abilityPages(record, definition, abilityView)).toList();
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
int activeCount = activePages(record, definitions, placeholderTargets).size();
int indexCount = indexPages(definitions, placeholderTargets).size();
var targets = new java.util.HashMap<String, Integer>();
int target = pages.size() + activeCount + indexCount + 1;
for (int index = 0; index < definitions.size(); index++) {
targets.put(definitions.get(index).id(), target);
target += details.get(index).size();
}
pages.addAll(activePages(record, definitions, targets));
pages.addAll(indexPages(definitions, targets));
details.forEach(pages::addAll);
}
if (allOverview.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
@@ -111,6 +157,157 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
return item;
}
private java.util.List<Component> overviewPages(CharacterRecord record, ControlInput input, StatView view) {
var builder = new PageBuilder();
builder.text(record.name());
builder.blank();
for (var stat : Stat.values()) {
Component action = record.unspentPoints().signum() > 0
? statButton(stat, view) : null;
builder.inline(stat + ": " + record.stats().get(stat), action);
}
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
String xp = record.level() >= rules.maximumLevel() ? "MAX"
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
builder.text("Level: " + record.level());
builder.text("XP: " + xp);
builder.text("Unspent points: " + record.unspentPoints());
builder.text("Health: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
+ "/" + maximum.stripTrailingZeros().toPlainString());
return builder.pages();
}
private static Component statButton(Stat stat, StatView view) {
return Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + stat + "\"")));
}
private static java.util.List<Component> activePages(CharacterRecord record,
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
var builder = new PageBuilder();
builder.text("Active abilities"); builder.blank();
for (int slot = 0; slot < record.loadout().size(); slot++) {
String id = record.loadout().get(slot);
var definition = byId.get(id);
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
else if (definition == null) builder.text((slot + 3) + ": " + id);
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
}
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
if (!unassigned.isEmpty()) {
builder.blank(); builder.text("Unassigned learned:");
for (var id : unassigned) {
var definition = byId.get(id);
if (definition == null) builder.text(id);
else builder.link("", definition.name(), targets.get(id));
}
}
return builder.pages();
}
private static java.util.List<Component> indexPages(
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < definitions.size(); start += 11) {
var builder = new PageBuilder(); builder.text("Ability index"); builder.blank();
for (var definition : definitions.subList(start, Math.min(start + 11, definitions.size()))) {
builder.link("", definition.name(), targets.get(definition.id()));
}
pages.addAll(builder.pages());
}
return java.util.List.copyOf(pages);
}
private static java.util.List<Component> abilityPages(CharacterRecord record,
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
.collect(java.util.stream.Collectors.joining(", "));
boolean learned = record.learned().contains(definition.id());
boolean stats = definition.requirements().entrySet().stream()
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
boolean slot = record.loadout().contains("");
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
var builder = new PageBuilder();
builder.text(definition.name());
builder.text(requirements);
if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
else builder.text(state);
builder.blank();
definition.lore().forEach(builder::text);
builder.blank(); builder.text(definition.instructions());
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + definition.parameters().entrySet().stream()
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(java.util.stream.Collectors.joining(", ")));
builder.text(state);
return builder.pages();
}
private static final class PageBuilder {
private final java.util.List<Component> lines = new java.util.ArrayList<>();
void blank() { lines.add(Component.empty()); }
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
void component(Component component) { lines.add(component); }
void inline(String text, Component action) {
var wrapped = BookPagination.lines(text);
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
var last = Component.text(wrapped.getLast());
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
lines.add(last.append(Component.space()).append(action));
} else {
lines.add(last);
if (action != null) lines.add(action);
}
}
void link(String prefix, String label, int page) {
if (displayWidth(prefix + label) <= 108) {
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
return;
}
for (var part : BookPagination.lines(prefix + label)) {
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
}
}
private static int displayWidth(String text) {
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
}
java.util.List<Component> pages() {
var pages = new java.util.ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
Component page = Component.empty();
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
if (index > start) page = page.append(Component.newline());
page = page.append(lines.get(index));
}
pages.add(page);
}
if (pages.isEmpty()) pages.add(Component.empty());
return java.util.List.copyOf(pages);
}
}
private static String durationSeconds(java.time.Duration duration) {
return java.math.BigDecimal.valueOf(duration.getSeconds())
.add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString();
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
String ability) {
String payload = "\"" + view.nonce() + ":" + ability + "\"";
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
}
private static Component pageButton(String label, View view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
@@ -139,6 +336,110 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
}
@org.bukkit.event.EventHandler
public void statAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !STATS.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = statViews.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 80) return;
var match = STAT_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))
|| !statViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
var stat = Stat.valueOf(match.group(2));
var currentHealth = java.math.BigDecimal.valueOf(player.getHealth());
var nativeLimit = NativeHealthLimit.read();
characters.purchaseStat(view.input().session().character(), view.revision(), stat, currentHealth, nativeLimit)
.whenComplete((purchase, failure) -> {
try {
nextTick.accept(() -> finishPurchase(view, stat, purchase, failure));
} catch (RuntimeException rejected) { report(view.input(), rejected); }
});
}
private void finishPurchase(StatView view, Stat stat, CharacterStorage.StatPurchase purchase, Throwable failure) {
var input = view.input();
if (closed || !input.stillCurrent()) return;
if (failure != null) {
report(input, failure);
input.player().sendMessage("Stat purchase could not be saved; no character state was changed.");
return;
}
if (purchase.status() == CharacterStorage.StatPurchaseStatus.APPLIED) {
var saved = purchase.record().orElseThrow();
try {
if (!sessions.acknowledgeProgress(input.session(), view.revision(), saved)) {
input.player().sendMessage("Stat purchase was saved; reopen the character after the session transition.");
return;
}
} catch (RuntimeException nativeFailure) {
report(input, nativeFailure);
input.player().sendMessage("Stat purchase was saved, but native health refresh failed; exit for recovery.");
return;
}
input.player().sendMessage(stat + " increased to " + saved.stats().get(stat) + ".");
load(input, 0);
return;
}
String message = switch (purchase.status()) {
case STALE -> "That character page is stale; reopen it before spending points.";
case NO_POINTS -> "This character has no unspent points.";
case UNAVAILABLE -> "This character is no longer available.";
case APPLIED -> throw new IllegalStateException("Handled applied purchase");
};
input.player().sendMessage(message);
load(input, 0);
}
@org.bukkit.event.EventHandler
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
var view = abilityViews.get(player.getUniqueId());
if (view == null || view.input().player() != player) return;
String raw = event.getTag().string();
if (raw.length() > 120) return;
var match = ABILITY_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: abilityEngine.unlearn(view.input(), ability, view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
if (failure != null) {
report(view.input(), failure);
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, String ability,
CharacterStorage.AbilityMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
: "Unlearned " + ability + " and refunded 1 point.";
case UNCHANGED -> ability + " is unchanged.";
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
case STALE -> "That character page is stale; reopen it before changing abilities.";
case COOLDOWN -> "That ability is still on cooldown.";
case UNAVAILABLE -> "That character or ability is unavailable.";
};
}
@org.bukkit.event.EventHandler
public void partyAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) { partyControls.click(event); }
@@ -147,8 +448,12 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
statViews.remove(owner);
abilityViews.remove(owner);
partyControls.forget(owner);
}
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
@Override public void close() {
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
}
}
@@ -69,12 +69,12 @@ public final class PartyBookControls implements AutoCloseable {
} else {
var group = party.orElseThrow();
boolean leader = group.leader().equals(owner);
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
if (leader) {
summary = summary.append(Component.newline())
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
if (group.members().size() < 6) summary = summary.append(Component.newline())
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
}
build.pages.add(summary);
@@ -0,0 +1,91 @@
package games.dmg.fantasystick.combat;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Server-thread built-in attack consumer; the bound stick's vanilla attack is always contained upstream. */
public final class BasicAttack implements Consumer<ControlInput>, Listener, AutoCloseable {
private static final Duration CADENCE = Duration.ofSeconds(1);
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private Pending pending;
private boolean closed;
private static final class Pending {
final ControlInput input;
final LivingEntity target;
final Instant attackedAt;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
this.input = input; this.target = target; this.attackedAt = attackedAt;
}
}
public BasicAttack(EquipmentSessions sessions) {
this(sessions, Clock.systemUTC());
}
public BasicAttack(EquipmentSessions sessions, Clock clock) {
this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock);
}
@Override public void accept(ControlInput input) {
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|| input.gesture() != ControlInput.Gesture.ATTACK
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var attempt = new Pending(input, target, now);
pending = attempt;
try {
target.damage(nativeDamage, input.player());
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
}
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
}
@Override public void close() {
closed = true;
pending = null;
readyAt.clear();
}
}
@@ -0,0 +1,49 @@
package games.dmg.fantasystick.crafting;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
public final class RecipeDiscovery implements Listener {
private final NamespacedKey recipe;
private final Logger logger;
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
this.recipe = Objects.requireNonNull(recipe, "recipe");
this.logger = Objects.requireNonNull(logger, "logger");
}
public void discoverAll(Iterable<? extends Player> players) {
for (var player : players) discover(player);
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
discover(event.getPlayer());
}
public void discover(Player player) {
Objects.requireNonNull(player, "player");
try {
if (player.hasDiscoveredRecipe(recipe)) return;
player.discoverRecipes(List.of(recipe));
if (!player.hasDiscoveredRecipe(recipe)) {
logger.warning("Could not discover character-sheet recipe for " + label(player));
}
} catch (RuntimeException failure) {
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
}
}
private static String label(Player player) {
try { return player.getName(); }
catch (RuntimeException ignored) { return "an unidentified player"; }
}
}
@@ -14,6 +14,7 @@ import org.bukkit.persistence.PersistentDataType;
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
public final class CharacterSheets {
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
@@ -26,15 +27,18 @@ public final class CharacterSheets {
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
meta.setItemModel(MODEL);
item.setItemMeta(meta);
return item;
}
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
public ShapelessRecipe recipe() {
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
var recipe = new ShapelessRecipe(RECIPE, paper("Character sheet"))
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
recipe.setCategory(org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
return recipe;
}
private static ItemStack paper(String name) {
@@ -46,6 +50,16 @@ public final class CharacterSheets {
return item;
}
/** Adds only the cosmetic model association to an already authentic sheet. */
public boolean applyModel(ItemStack item) {
if (identify(item).isEmpty()) return false;
var meta = item.getItemMeta();
if (MODEL.equals(meta.getItemModel())) return true;
meta.setItemModel(MODEL);
item.setItemMeta(meta);
return true;
}
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
public Optional<UUID> identify(ItemStack item) {
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -17,6 +18,8 @@ import org.bukkit.persistence.PersistentDataType;
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
*/
public final class SessionControls {
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
private final Clock clock;
private static final NamespacedKey VERSION = key("control_version");
private static final NamespacedKey OWNER = key("control_owner_id");
private static final NamespacedKey SESSION = key("control_session_id");
@@ -51,6 +54,20 @@ public final class SessionControls {
public record Identity(UUID owner, UUID session, int slot) { }
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
this(registry, Clock.systemUTC());
}
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
this.registry = Objects.requireNonNull(registry);
this.clock = Objects.requireNonNull(clock);
}
private Icon icon(String ability) {
var definition = registry.find(ability).orElse(null);
return definition == null ? ABILITIES.get(ability) : new Icon(definition.icon(), definition.name());
}
/**
* Builds the entire detached hotbar before a caller may start packing equipment.
* Slot zero is a plain book control, not the pending character-book UI.
@@ -60,12 +77,17 @@ public final class SessionControls {
Objects.requireNonNull(owner);
Objects.requireNonNull(session);
var items = new ItemStack[9];
var now = clock.instant();
for (int slot = 0; slot < items.length; slot++) {
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
: ABILITIES.getOrDefault(ability, new Icon(Material.PAPER, "Unavailable ability: " + ability));
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
var item = new ItemStack(icon.material());
var meta = item.getItemMeta();
meta.displayName(Component.text(icon.name()));
@@ -76,7 +98,7 @@ public final class SessionControls {
data.set(SESSION, PersistentDataType.STRING, session.toString());
data.set(SLOT, PersistentDataType.INTEGER, slot);
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
: ability.isEmpty() ? "empty" : ABILITIES.containsKey(ability) ? "ability" : "unavailable");
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
item.setItemMeta(meta);
items[slot] = item;
@@ -111,11 +133,12 @@ public final class SessionControls {
public Optional<String> ability(ItemStack item) {
if (identify(item).isEmpty()) return Optional.empty();
var data = item.getPersistentDataContainer();
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
String kind = data.get(KIND, PersistentDataType.STRING);
return kind.equals("ability") || kind.equals("cooldown")
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
}
private static boolean validKind(Material material, int slot, PersistentDataContainer data) {
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
String kind = data.get(KIND, PersistentDataType.STRING);
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
@@ -123,8 +146,9 @@ public final class SessionControls {
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
String ability = data.get(ABILITY, PersistentDataType.STRING);
if (ability.isBlank()) return false;
Icon icon = ABILITIES.get(ability);
Icon icon = icon(ability);
return kind.equals("ability") ? icon != null && icon.material() == material
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
}
@@ -0,0 +1,269 @@
package games.dmg.fantasystick.party;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.CombatContributions;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.session.SessionVisuals;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
/** Accepted native contribution capture and durable character-bound XP delivery. */
public final class CombatExperience implements Listener, AutoCloseable {
public record Failure(UUID character, Throwable cause) { }
private record Defeat(EquipmentSessions.Active active, int level) { }
private static final Set<EntityType> PASSIVE = Set.of(
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
EntityType.DONKEY, EntityType.FOX, EntityType.FROG, EntityType.GLOW_SQUID, EntityType.HAPPY_GHAST,
EntityType.HORSE, EntityType.MOOSHROOM, EntityType.MULE, EntityType.NAUTILUS, EntityType.OCELOT,
EntityType.PARROT, EntityType.PIG, EntityType.RABBIT, EntityType.SALMON, EntityType.SHEEP,
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
private final CharacterStorage characters;
private final ProgressionRules progression;
private final CombatRewardRules rewards;
private final EquipmentSessions sessions;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private final CombatSources sources;
private final CombatContributions contributions;
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
private org.bukkit.scheduler.BukkitTask retryTask;
private boolean closed;
private final class PendingAward {
final UUID id = UUID.randomUUID();
final UUID character;
final BigInteger reward;
final CombatRewardRules.KillPosition restricted;
BigInteger expectedRevision;
boolean running;
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
this.character = character; this.reward = reward; this.restricted = restricted;
}
}
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
}
public void install(org.bukkit.plugin.Plugin plugin) {
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
}
/** Records target-specific, effective support; only a direct contributor can activate it at defeat. */
public void support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
}
/** Records effective healing; any directly contributed target can qualify it within ten seconds. */
public void support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
contributions.support(aided.character(), supportingCharacter, clock.instant());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|| event.getFinalDamage() <= 0) return;
var origin = source(event);
if (origin == null || origin.session() == null || !sources.current(origin)
|| event.getEntity().getUniqueId().equals(origin.player())) return;
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
}
private CombatSources.Origin source(EntityDamageEvent event) {
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null) return ongoing.source();
}
var origin = sources.read(event.getDamageSource().getDirectEntity());
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
return origin;
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void beforePlayerDeath(PlayerDeathEvent event) {
if (closed) return;
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
if (record != null) defeats.put(event, new Defeat(active, record.level()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void playerDeath(PlayerDeathEvent event) {
var defeat = defeats.remove(event);
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void mobDeath(EntityDeathEvent event) {
if (closed || event instanceof PlayerDeathEvent) return;
award(event.getEntity(), classify(event.getEntity()), 0, null);
}
@EventHandler(priority = EventPriority.MONITOR)
public void removed(EntityRemoveEvent event) {
if (!closed && event.getCause() != EntityRemoveEvent.Cause.DEATH) {
contributions.invalidate(event.getEntity().getUniqueId());
}
}
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
Instant now = clock.instant();
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
if (contributors.isEmpty()) return;
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
for (UUID character : contributors) {
var operation = new PendingAward(character, share, restricted);
pending.put(operation.id, operation);
attempt(operation);
}
}
private static CombatRewardRules.KillPosition position(Location location) {
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
BigDecimal.valueOf(location.getX()), BigDecimal.valueOf(location.getY()), BigDecimal.valueOf(location.getZ()));
}
private void attempt(PendingAward operation) {
synchronized (operation) {
if (closed || operation.running || !pending.containsKey(operation.id)) return;
operation.running = true;
}
if (operation.expectedRevision == null) {
characters.find(operation.character).whenComplete((record, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (record.isEmpty() || record.orElseThrow().dead()) { pending.remove(operation.id); return; }
operation.expectedRevision = record.orElseThrow().revision();
attempt(operation);
});
return;
}
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
synchronized (operation) { operation.running = false; }
if (failure != null) { fail(operation, failure); return; }
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
operation.expectedRevision = award.record().orElseThrow().revision(); attempt(operation); return;
}
pending.remove(operation.id);
if (award.record().isEmpty() || award.status() == CharacterStorage.CombatAwardStatus.UNAVAILABLE) return;
try { nextTick.accept(() -> acknowledge(operation, award)); }
catch (RuntimeException rejected) { report(operation.character, rejected); }
});
}
private void fail(PendingAward operation, Throwable failure) {
report(operation.character, failure);
if (!closed && pending.containsKey(operation.id)) retries.offer(operation.id);
}
public void retryPending() {
if (closed) return;
contributions.sweep(clock.instant());
for (int attempted = 0; attempted < 64; attempted++) {
UUID id = retries.poll();
if (id == null) break;
var operation = pending.get(id);
if (operation != null) attempt(operation);
}
}
private void acknowledge(PendingAward operation, CharacterStorage.CombatAward award) {
if (closed) return;
CharacterRecord saved = award.record().orElseThrow();
var active = sessions.activeCharacter(operation.character).orElse(null);
if (active != null && !sessions.acknowledgeProgress(active, saved.revision().subtract(BigInteger.ONE), saved)
&& !sessions.currentRecord(active).filter(saved::equals).isPresent()) {
report(operation.character, new IllegalStateException("Saved combat progress could not synchronize with active session"));
return;
}
Player player = active == null ? null : players.apply(active.player());
if (player == null || !player.isOnline()) return;
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
for (int gained = 1; gained <= award.levels(); gained++) {
int level = saved.level() - award.levels() + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} else if (award.status() == CharacterStorage.CombatAwardStatus.SPACING_BLOCKED) {
player.sendMessage("No fantasy XP: this restricted kill was too close to your previous one in this world.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.AT_CAP) {
player.sendMessage("No fantasy XP: this character is already at the configured maximum level.");
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
}
}
static CombatRewardRules.Target classify(LivingEntity entity) {
EntityType type = entity.getType();
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
}
@Override public void close() {
closed = true;
if (retryTask != null) retryTask.cancel();
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
}
}
@@ -66,6 +66,7 @@ public final class PartyCombat implements Listener, AutoCloseable {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
if (event.getEntity() instanceof Player target) {
var ongoing = OngoingDamage.read(target, event.getCause());
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
@@ -25,10 +25,19 @@ public final class PartyRoster implements AutoCloseable {
private boolean closed;
private final Clock clock;
private final Predicate<UUID> online;
private final java.util.function.IntSupplier maximumSize;
public PartyRoster(Clock clock, Predicate<UUID> online) {
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
this.clock = Objects.requireNonNull(clock);
this.online = Objects.requireNonNull(online);
this.maximumSize = Objects.requireNonNull(maximumSize);
maximumSize();
}
public int maximumSize() {
int value = maximumSize.getAsInt();
if (value < 1) throw new IllegalStateException("Party maximum must be positive");
return value;
}
public Optional<Party> create(UUID player) {
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
@@ -45,7 +54,7 @@ public final class PartyRoster implements AutoCloseable {
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
expire();
var group = managedBy(leader, party);
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
if (group == null || group.members().size() >= maximumSize() || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
if (existing.isPresent()) return existing;
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
@@ -57,7 +66,7 @@ public final class PartyRoster implements AutoCloseable {
var offer = invitations.get(invitation);
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
var group = membership.get(offer.leader());
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
var members = new ArrayList<>(group.members());
members.add(player);
var updated = new Party(group.id(), group.leader(), members);
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for later administrator revival selections. */
public final class AdminReviveSettings implements AutoCloseable {
public static final String KEY="admin-revive-selection-timeout-seconds";public static final String DEFAULT="60";
private static final int MAX_BYTES=1_048_576;private static final long MAX_SECONDS=365L*24*60*60;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,Duration timeout,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-admin-revive-configuration"));
private volatile Duration effective;private Duration persisted;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public AdminReviveSettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
AdminReviveSettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);persisted=effective;}
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(60);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be positive whole seconds",invalid);}}
private static Duration validate(Duration value){long seconds;try{seconds=value.toSeconds();}catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid admin revival timeout",invalid);}if(value.isZero()||value.isNegative()||value.getNano()!=0||seconds>MAX_SECONDS)throw new IllegalArgumentException(KEY+" must be positive whole seconds through 365 days");return value;}
public Duration timeout(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);persisted=accepted;return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Admin revival configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.timeout());publishedSequence=update.sequence();}
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,173 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ChainLightning;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable 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";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
private static final Map<String, String> DEFAULTS = Map.of(
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
@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) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-chain-lightning-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public ChainLightningSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ChainLightningSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public ChainLightning.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Chain Lightning configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
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))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.CombatRewardRules;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -16,7 +17,16 @@ public record CharacterRecord(
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
boolean dead, UUID deathId, Instant diedAt) {
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
boolean dead, UUID deathId, Instant diedAt) {
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
cooldowns, Map.of(), dead, deathId, diedAt);
}
public CharacterRecord {
Objects.requireNonNull(id);
Objects.requireNonNull(name);
@@ -28,6 +38,7 @@ public record CharacterRecord(
learned = Set.copyOf(learned);
loadout = List.copyOf(loadout);
cooldowns = Map.copyOf(cooldowns);
restrictedKills = Map.copyOf(restrictedKills);
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
@@ -35,6 +46,8 @@ public record CharacterRecord(
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|| restrictedKills.size() > 1024
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
throw new IllegalArgumentException("Invalid character record");
}
@@ -1,5 +1,7 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import java.io.IOException;
import java.math.BigInteger;
import java.time.Clock;
@@ -204,26 +206,262 @@ public final class CharacterStorage implements AutoCloseable {
if (old.name().equals(name)) return Optional.of(old);
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
});
}
public enum ExperienceAwardStatus { APPLIED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record ExperienceAward(ExperienceAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist configured transferable XP/level/point progression as one expected revision. */
public CompletableFuture<ExperienceAward> awardExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules rules) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward);
Objects.requireNonNull(rules);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new ExperienceAward(ExperienceAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new ExperienceAward(ExperienceAwardStatus.STALE, Optional.of(old), 0);
}
var cap = rules.xpPerLevel().multiply(BigInteger.valueOf(rules.maximumLevel() - 1L));
if (old.level() >= rules.maximumLevel() || old.experience().compareTo(cap) >= 0) {
return new ExperienceAward(ExperienceAwardStatus.AT_CAP, Optional.of(old), 0);
}
var experience = old.experience().add(reward).min(cap);
if (experience.equals(old.experience())) {
var status = old.level() >= rules.maximumLevel()
? ExperienceAwardStatus.AT_CAP : ExperienceAwardStatus.UNCHANGED;
return new ExperienceAward(status, Optional.of(old), 0);
}
int level = experience.divide(rules.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
});
}
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
/** Persist one combat share and its restricted-kill reference in the same character revision. */
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
BigInteger reward, ProgressionRules progression,
games.dmg.fantasystick.progression.CombatRewardRules rewards,
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
}
boolean eligible = restrictedKill == null || old.restrictedKills().get(restrictedKill.world()) == null
|| rewards.spaced(old.restrictedKills().get(restrictedKill.world()), restrictedKill);
var history = new java.util.HashMap<>(old.restrictedKills());
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
int gained = Math.max(0, level - old.level());
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
return new CombatAward(status, Optional.of(old), 0);
}
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new CombatAward(status, Optional.of(saved), gained);
});
}
public enum AbilityMutationStatus {
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
}
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
/** Unlock one validated registered ID and spend its points in the same expected revision. */
public CompletableFuture<AbilityMutation> unlockAbility(UUID characterId, BigInteger expectedRevision, String ability,
java.util.Map<Stat, BigInteger> requirements, BigInteger cost) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(requirements); Objects.requireNonNull(cost);
if (ability.isBlank() || cost.signum() <= 0 || requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)) {
throw new IllegalArgumentException("Invalid ability unlock");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.ALREADY_LEARNED, Optional.of(old));
if (requirements.entrySet().stream().anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
}
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
int slot = old.loadout().indexOf("");
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Refund one learned ability exactly once, clear every equipped reference and retain its deadline. */
public CompletableFuture<AbilityMutation> unlearnAbility(UUID characterId, BigInteger expectedRevision, String ability) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability refund");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
var learned = new java.util.HashSet<>(old.learned()); learned.remove(ability);
var loadout = old.loadout().stream().map(assigned -> assigned.equals(ability) ? "" : assigned).toList();
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Move one learned ability to one of the seven selectable slots without duplicating it. */
public CompletableFuture<AbilityMutation> assignAbility(UUID characterId, BigInteger expectedRevision,
String ability, int slot) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
if (ability.isBlank() || slot < 0 || slot >= 7) throw new IllegalArgumentException("Invalid ability assignment");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
var loadout = new java.util.ArrayList<>(old.loadout());
for (int index = 0; index < loadout.size(); index++) if (loadout.get(index).equals(ability)) loadout.set(index, "");
loadout.set(slot, ability);
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
/** Commit a real-time character/ability deadline before exposing the cast's native effect. */
public CompletableFuture<AbilityMutation> commitAbilityCooldown(UUID characterId, BigInteger expectedRevision,
String ability, Instant readyAt) {
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(ability);
Objects.requireNonNull(readyAt);
if (ability.isBlank()) throw new IllegalArgumentException("Invalid ability cooldown");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return new AbilityMutation(AbilityMutationStatus.UNAVAILABLE, Optional.empty());
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) return new AbilityMutation(AbilityMutationStatus.STALE, Optional.of(old));
if (!old.learned().contains(ability)) return new AbilityMutation(AbilityMutationStatus.NOT_LEARNED, Optional.of(old));
Instant previous = old.cooldowns().get(ability);
if (previous != null && previous.isAfter(clock.instant())) return new AbilityMutation(AbilityMutationStatus.COOLDOWN, Optional.of(old));
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
});
}
public enum StatPurchaseStatus { APPLIED, STALE, NO_POINTS, UNAVAILABLE }
public record StatPurchase(StatPurchaseStatus status, Optional<CharacterRecord> record) { }
/** Persist one active-session stat purchase and its current native health as one revision. */
public CompletableFuture<StatPurchase> purchaseStat(UUID characterId, BigInteger expectedRevision, Stat stat,
java.math.BigDecimal currentHealth,
java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(expectedRevision);
Objects.requireNonNull(stat);
Objects.requireNonNull(currentHealth);
Objects.requireNonNull(nativeLimit);
if (currentHealth.signum() < 0 || nativeLimit.signum() <= 0) {
throw new IllegalArgumentException("Invalid stat-purchase health bound");
}
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) {
return new StatPurchase(StatPurchaseStatus.UNAVAILABLE, Optional.empty());
}
var old = found.orElseThrow();
if (!old.revision().equals(expectedRevision)) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
if (old.unspentPoints().signum() == 0) {
return new StatPurchase(StatPurchaseStatus.NO_POINTS, Optional.of(old));
}
var oldMaximum = old.maximumHealth().min(nativeLimit);
if (currentHealth.compareTo(oldMaximum) > 0) {
return new StatPurchase(StatPurchaseStatus.STALE, Optional.of(old));
}
var stats = new java.util.EnumMap<Stat, BigInteger>(Stat.class);
stats.putAll(old.stats());
stats.put(stat, stats.get(stat).add(BigInteger.ONE));
var rawMaximum = new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
});
}
/** Session-owned remaining health; retain all other current fields and reject concurrent revision changes. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, java.math.BigDecimal health,
java.math.BigDecimal nativeLimit) {
return saveHealth(characterId, null, health, nativeLimit);
}
/** Ability-owned health mutation that refuses a changed character revision rather than healing stale state. */
public CompletableFuture<Optional<CharacterRecord>> saveHealth(UUID characterId, BigInteger expectedRevision,
java.math.BigDecimal health, java.math.BigDecimal nativeLimit) {
Objects.requireNonNull(characterId);
if (health.signum() < 0 || nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid health bound");
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
var old = found.orElseThrow();
if (expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
false, old.deathId(), old.diedAt());
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(saved, old.revision());
return Optional.of(saved);
});
@@ -254,27 +492,44 @@ public final class CharacterStorage implements AutoCloseable {
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
old.loadout(), old.cooldowns(), true, deathId, diedAt);
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
repository.replace(dead, old.revision());
return Optional.of(dead);
});
}
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(deathId);
return restoreDeathRecord(characterId, deathId, null);
}
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
java.math.BigDecimal nativeLimit) {
return restore(characterId, deathId, nativeLimit, true);
}
/** Administrator revival retains death identity/consume-once checks but deliberately ignores death age. */
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(
UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) {
return restore(characterId, deathId, nativeLimit, false);
}
private CompletableFuture<Optional<CharacterRecord>> restore(UUID characterId, UUID deathId,
java.math.BigDecimal nativeLimit, boolean ageLimited) {
Objects.requireNonNull(characterId); Objects.requireNonNull(deathId);
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
Instant selectedAt = clock.instant();
return submit(() -> {
Optional<CharacterRecord> found = repository.load(characterId);
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
CharacterRecord old = found.orElseThrow();
Duration age = Duration.between(old.diedAt(), selectedAt);
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
return Optional.empty();
}
if (!deathId.equals(old.deathId()) || age.isNegative()
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt());
repository.replace(restored, old.revision());
return Optional.of(restored);
});
@@ -46,6 +46,13 @@ final class CharacterYaml {
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
root.put("cooldowns", cooldowns);
List<Map<String, String>> restrictedKills = new ArrayList<>();
record.restrictedKills().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
var position = entry.getValue();
restrictedKills.add(Map.of("world", entry.getKey().toString(), "x", position.x().toString(),
"y", position.y().toString(), "z", position.z().toString()));
});
root.put("restricted-kills", restrictedKills);
root.put("dead", record.dead());
if (record.deathId() != null) {
root.put("death-id", record.deathId().toString());
@@ -74,11 +81,19 @@ final class CharacterYaml {
throw new IllegalArgumentException("Duplicate cooldown");
}
}
Map<UUID, games.dmg.fantasystick.progression.CombatRewardRules.KillPosition> restrictedKills = new LinkedHashMap<>();
for (Object value : root.containsKey("restricted-kills") ? list(root.get("restricted-kills")) : List.of()) {
Map<?, ?> row = object(value);
UUID world = UUID.fromString(text(row.get("world")));
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(world,
new BigDecimal(text(row.get("x"))), new BigDecimal(text(row.get("y"))), new BigDecimal(text(row.get("z"))));
if (restrictedKills.putIfAbsent(world, position) != null) throw new IllegalArgumentException("Duplicate restricted-kill world");
}
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
number(root.get("experience")), number(root.get("unspent-points")),
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
}
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.FrostNova;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Frost Nova settings; publication remains a server-thread caller decision. */
public final class FrostNovaSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "frost-nova-base-damage";
public static final String COOLDOWN = "frost-nova-cooldown-seconds";
public static final String RADIUS = "frost-nova-radius-blocks";
public static final String SLOWNESS = "frost-nova-slowness-amplifier";
public static final String DURATION = "frost-nova-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RADIUS, SLOWNESS, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(
BASE, "5", COOLDOWN, "12", RADIUS, "5", SLOWNESS, "1", DURATION, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostNova.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-frost-nova-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FrostNovaSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FrostNovaSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public FrostNova.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Frost Nova configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static FrostNova.Settings parse(Map<String, String> values) {
try { return new FrostNova.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
new BigDecimal(values.get(SLOWNESS)).intValueExact(), seconds(values.get(DURATION))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Frost Nova settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.FrostShard;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Frost Shard settings; publication remains a server-thread caller decision. */
public final class FrostShardSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "frost-shard-base-damage";
public static final String COOLDOWN = "frost-shard-cooldown-seconds";
public static final String RANGE = "frost-shard-range-blocks";
public static final String LIFETIME = "frost-shard-lifetime-seconds";
public static final String SPEED = "frost-shard-projectile-speed-blocks-per-tick";
public static final String AMPLIFIER = "frost-shard-slowness-amplifier";
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-frost-shard-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FrostShardSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FrostShardSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public FrostShard.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Frost Shard configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(AMPLIFIER)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static FrostShard.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(SPEED));
int amplifier = new java.math.BigInteger(values.get(AMPLIFIER)).intValueExact();
var slowness = seconds(values.get(SLOWNESS));
return new FrostShard.Settings(base, cooldown, range, lifetime, speed, amplifier, slowness);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Frost Shard settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,170 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.GuardianBond;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Guardian Bond settings; publication remains a server-thread caller decision. */
public final class GuardianBondSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "guardian-bond-cooldown-seconds";
public static final String RANGE = "guardian-bond-range-blocks";
public static final String DURATION = "guardian-bond-duration-seconds";
public static final String REDIRECT = "guardian-bond-redirect-percent";
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, RANGE, REDIRECT);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "20", RANGE, "10", DURATION, "6", REDIRECT, "30");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, GuardianBond.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-guardian-bond-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public GuardianBondSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
GuardianBondSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public GuardianBond.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Guardian Bond configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static GuardianBond.Settings parse(Map<String, String> values) {
try { return new GuardianBond.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE)),
seconds(values.get(DURATION)), new BigDecimal(values.get(REDIRECT))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Guardian Bond settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,190 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Hex;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Hex settings; publication remains a server-thread caller decision. */
public final class HexSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "hex-cooldown-seconds";
public static final String RANGE = "hex-range-blocks";
public static final String DURATION = "hex-duration-seconds";
public static final String REDUCTION = "hex-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RANGE, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "10", RANGE, "12", DURATION, "4", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Hex.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-hex-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public HexSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HexSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Hex.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Hex configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Hex.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new Hex.Settings(cooldown, range, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Hex settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,191 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Inspire;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Inspire settings; publication remains a server-thread caller decision. */
public final class InspireSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "inspire-base-temporary-health";
public static final String COOLDOWN = "inspire-cooldown-seconds";
public static final String RANGE = "inspire-range-blocks";
public static final String DURATION = "inspire-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RANGE, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(BASE, "4", COOLDOWN, "15", RANGE, "12", DURATION, "6");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Inspire.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-inspire-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public InspireSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
InspireSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Inspire configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Inspire.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE));
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
return new Inspire.Settings(base, cooldown, range, duration);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Inspire settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,201 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Longbow;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Longbow settings; publication remains a server-thread caller decision. */
public final class LongbowSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "longbow-base-damage";
public static final String RECOVERY = "longbow-recovery-seconds";
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
public static final String FULL_DRAW = "longbow-full-draw-ticks";
public static final String RANGE = "longbow-range-blocks";
public static final String LIFETIME = "longbow-lifetime-seconds";
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
LIFETIME, FULL_SPEED);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Longbow.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-longbow-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public LongbowSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
LongbowSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Longbow.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Longbow configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
return new java.math.BigInteger(raw).toString();
}
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Longbow.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var recovery = seconds(values.get(RECOVERY));
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(FULL_SPEED));
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Longbow settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.MendingLight;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Mending Light settings; publication remains a server-thread caller decision. */
public final class MendingLightSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "mending-light-base-healing";
public static final String COOLDOWN = "mending-light-cooldown-seconds";
public static final String RANGE = "mending-light-range-blocks";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "8", RANGE, "12");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, MendingLight.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-mending-light-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public MendingLightSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
MendingLightSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public MendingLight.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Mending Light configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
return new BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static MendingLight.Settings parse(Map<String, String> values) {
try {
return new MendingLight.Settings(new java.math.BigInteger(values.get(BASE_HEALING)),
seconds(values.get(COOLDOWN)), finite(values.get(RANGE)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Mending Light settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,176 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Meteor;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Meteor settings; publication remains a server-thread caller decision. */
public final class MeteorSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "meteor-base-damage";
public static final String COOLDOWN = "meteor-cooldown-seconds";
public static final String TARGET_RANGE = "meteor-target-range-blocks";
public static final String WARNING = "meteor-warning-seconds";
public static final String RADIUS = "meteor-radius-blocks";
public static final String VERTICAL = "meteor-vertical-range-blocks";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, TARGET_RANGE, WARNING, RADIUS, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(
BASE_DAMAGE, "14", COOLDOWN, "45", TARGET_RANGE, "24", WARNING, "2", RADIUS, "5", VERTICAL, "4");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Meteor.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-meteor-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public MeteorSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
MeteorSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Meteor.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Meteor configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Meteor.Settings parse(Map<String, String> values) {
try { return new Meteor.Settings(new BigDecimal(values.get(BASE_DAMAGE)).toBigIntegerExact(),
seconds(values.get(COOLDOWN)), finite(values.get(TARGET_RANGE)), seconds(values.get(WARNING)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Meteor settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,43 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for party capacity checks and book presentation. */
public final class PartySettings implements AutoCloseable {
public static final String KEY="party-max-size";public static final String DEFAULT="6";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,int maximum,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,166 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Purify;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Purify settings; publication remains a server-thread caller decision. */
public final class PurifySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "purify-cooldown-seconds";
public static final String RANGE = "purify-range-blocks";
private static final List<String> KEYS = List.of(COOLDOWN, RANGE);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "15", RANGE, "12");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Purify.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-purify-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public PurifySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
PurifySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Purify.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Purify configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Purify.Settings parse(Map<String, String> values) {
try { return new Purify.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Purify settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Quickstep;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Quickstep settings; publication remains a server-thread caller decision. */
public final class QuickstepSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "quickstep-cooldown-seconds";
public static final String DISTANCE = "quickstep-distance-blocks";
public static final String SPEED = "quickstep-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Quickstep.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-quickstep-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public QuickstepSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickstepSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Quickstep.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Quickstep configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Quickstep.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
double distance = finite(values.get(DISTANCE));
double speed = finite(values.get(SPEED));
return new Quickstep.Settings(cooldown, distance, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,172 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.RallyingCry;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Rallying Cry settings; publication remains a server-thread caller decision. */
public final class RallyingCrySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "rallying-cry-cooldown-seconds";
public static final String RADIUS = "rallying-cry-radius-blocks";
public static final String DURATION = "rallying-cry-duration-seconds";
public static final String SPEED = "rallying-cry-speed-amplifier";
public static final String BONUS = "rallying-cry-damage-bonus-percent";
private static final List<String> KEYS = List.of(COOLDOWN, BONUS, DURATION, RADIUS, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "25", RADIUS, "8", DURATION, "6", SPEED, "0", BONUS, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, RallyingCry.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-rallying-cry-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public RallyingCrySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
RallyingCrySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public RallyingCry.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Rallying Cry configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static RallyingCry.Settings parse(Map<String, String> values) {
try { return new RallyingCry.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
seconds(values.get(DURATION)), new BigDecimal(values.get(SPEED)).intValueExact(),
new BigDecimal(values.get(BONUS))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Rallying Cry settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,167 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
public final class RevivalStore implements AutoCloseable {
private static final int MAX_FILE_BYTES = 32_768;
private static final int MAX_FILES = 4096;
private static final Set<String> KEYS_V1 = Set.of("schema-version", "reservation-id", "caster-character-id",
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
private static final Set<String> KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative"))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false);
}
public Reservation {
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
throw new IllegalArgumentException("Invalid revival reservation");
}
}
}
private final Path directory;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-revival-persistence"));
public RevivalStore(Path pluginDataDirectory) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
}
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
Objects.requireNonNull(reservation);
return submit(() -> {
Path file = file(reservation.character());
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
safeDirectory();
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
try {
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
} finally { Files.deleteIfExists(temporary); }
return Optional.of(reservation);
});
}
public CompletableFuture<Optional<Reservation>> find(UUID character) {
Objects.requireNonNull(character);
return submit(() -> load(file(character)));
}
public CompletableFuture<List<Reservation>> target(UUID player) {
Objects.requireNonNull(player);
return submit(() -> {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
var result = new ArrayList<Reservation>();
try (var files = Files.list(directory)) {
for (var path : files.limit(MAX_FILES + 1).toList()) {
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
if (!path.getFileName().toString().endsWith(".yml")) continue;
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
if (reservation.targetPlayer().equals(player)) result.add(reservation);
}
}
return List.copyOf(result);
});
}
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
return submit(() -> {
var found = load(file(character));
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
return Files.deleteIfExists(file(character));
});
}
private void safeDirectory() throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
} else Files.createDirectories(directory);
}
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
private Optional<Reservation> load(Path file) throws IOException {
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
Object decoded;
try {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys");
try {
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))throw new IllegalArgumentException("schema");
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative"));
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
return Optional.of(value);
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
}
private static String encode(Reservation value) {
var map = new LinkedHashMap<String, Object>();
map.put("schema-version", "2"); map.put("reservation-id", value.id().toString());
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
map.put("administrative", Boolean.toString(value.administrative()));
return new Yaml().dump(map);
}
private static String text(Map<?, ?> map, String key) {
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
}
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
private static boolean bool(Map<?,?> map,String key){String value=text(map,key);if(!value.equals("true")&&!value.equals("false"))throw new IllegalArgumentException(key);return Boolean.parseBoolean(value);}
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
private <T> CompletableFuture<T> submit(Work<T> work) {
try {
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
}
@Override public void close() { worker.shutdown(); }
}
@@ -0,0 +1,152 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for roleplay availability and coordinated exits. */
public final class RoleplaySettings implements AutoCloseable {
public static final String KEY = "roleplay-disabled";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(boolean disabled, long sequence) { }
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-roleplay-configuration"));
private volatile boolean disabled;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public RoleplaySettings(Path directory, boolean initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
RoleplaySettings(Path directory, boolean initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
disabled = initial;
}
public static boolean parse(Object value) {
if (value == null) return false;
if (value instanceof Boolean parsed) return parsed;
throw new IllegalArgumentException(KEY + " must be true or false");
}
public boolean disabled() { return disabled; }
public String value() { return Boolean.toString(disabled); }
public CompletionStage<Update> set(String raw) {
if (!"true".equals(raw) && !"false".equals(raw)) {
throw new IllegalArgumentException(KEY + " must be true or false");
}
boolean accepted = Boolean.parseBoolean(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(accepted);
return new Update(accepted, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Roleplay configuration save refused or failed; effective setting unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
disabled = update.disabled();
publishedSequence = update.sequence();
return true;
}
private void save(boolean value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
values.put(key, entry.getValue());
}
}
}
values.put(KEY, value);
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true;
worker.shutdown();
if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,177 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Sanctuary;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Sanctuary settings; publication remains a server-thread caller decision. */
public final class SanctuarySettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "sanctuary-cooldown-seconds";
public static final String RADIUS = "sanctuary-radius-blocks";
public static final String VERTICAL = "sanctuary-vertical-range-blocks";
public static final String DURATION = "sanctuary-duration-seconds";
public static final String INTERVAL = "sanctuary-pulse-interval-seconds";
public static final String HEALING = "sanctuary-healing-per-pulse";
public static final String REDUCTION = "sanctuary-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RADIUS, VERTICAL, DURATION, INTERVAL, HEALING, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", RADIUS, "6", VERTICAL, "3", DURATION, "6", INTERVAL, "1", HEALING, "2", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Sanctuary.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-sanctuary-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SanctuarySettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SanctuarySettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Sanctuary.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Sanctuary configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Sanctuary.Settings parse(Map<String, String> values) {
try { return new Sanctuary.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)), seconds(values.get(INTERVAL)),
new BigDecimal(values.get(HEALING)), new BigDecimal(values.get(REDUCTION))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Sanctuary settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,187 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.SecondWind;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable SecondWind settings; publication remains a server-thread caller decision. */
public final class SecondWindSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "second-wind-base-healing";
public static final String COOLDOWN = "second-wind-cooldown-seconds";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, SecondWind.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-second-wind-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SecondWindSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SecondWindSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public SecondWind.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("SecondWind configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static SecondWind.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_HEALING));
var cooldown = seconds(values.get(COOLDOWN));
return new SecondWind.Settings(base, cooldown);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,209 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable paired resource-pack overrides; each publication carries the latest complete saved snapshot. */
public final class SheetResourcePackSettings implements AutoCloseable {
public static final String URL_KEY = "sheet-resource-pack-url";
public static final String SHA1_KEY = "sheet-resource-pack-sha1";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Defaults(String url, String sha1) {
public Defaults { url = effectiveUrl(url, new DefaultsMarker()); sha1 = normalizeHash(sha1, false); }
}
private static final class DefaultsMarker { }
public record Snapshot(String configuredUrl, String configuredSha1, String url, String sha1) { }
public record Update(Snapshot snapshot, long sequence) { }
private final Path directory;
private final Defaults defaults;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker;
private volatile Snapshot effective;
private String persistedUrl;
private String persistedSha1;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SheetResourcePackSettings(Path directory, Object configuredUrl, Object configuredSha1, Defaults defaults) {
this(directory, raw(configuredUrl, URL_KEY), raw(configuredSha1, SHA1_KEY), defaults,
(from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SheetResourcePackSettings(Path directory, String configuredUrl, String configuredSha1, Defaults defaults, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.defaults = Objects.requireNonNull(defaults);
this.replacement = Objects.requireNonNull(replacement);
effective = snapshot(configuredUrl, configuredSha1);
worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "fantasy-sheet-resource-pack-configuration"));
persistedUrl = configuredUrl;
persistedSha1 = configuredSha1;
}
public static Defaults packagedDefaults() {
try (var input = SheetResourcePackSettings.class.getResourceAsStream("/sheet-resource-pack.properties")) {
if (input == null) throw new IOException("Missing packaged sheet resource-pack metadata");
var values = new Properties(); values.load(input);
return new Defaults(values.getProperty("url"), values.getProperty("sha1"));
} catch (IOException | IllegalArgumentException failure) {
throw new IllegalStateException("Invalid packaged sheet resource-pack metadata", failure);
}
}
private static String raw(Object value, String key) {
if (value == null) return "";
if (value instanceof String text) return text;
throw new IllegalArgumentException(key + " must be a string");
}
public static String effectiveUrl(String configured, Defaults defaults) {
Objects.requireNonNull(defaults);
return configured == null || configured.isEmpty() ? defaults.url() : validateUrl(configured);
}
private static String effectiveUrl(String value, DefaultsMarker ignored) { return validateUrl(value); }
private static String validateUrl(String value) {
if (value == null || value.length() > 2048 || value.chars().anyMatch(Character::isWhitespace)) {
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL");
}
try {
var uri = URI.create(value);
if (!"https".equals(uri.getScheme()) || uri.getHost() == null || uri.getHost().isBlank()
|| uri.getRawUserInfo() != null || uri.getRawFragment() != null
|| uri.getRawPath() == null || uri.getRawPath().isBlank()) {
throw new IllegalArgumentException(URL_KEY + " must be an absolute HTTPS asset URL without credentials or fragment");
}
return uri.toASCIIString();
} catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL", invalid);
}
}
public static String effectiveSha1(String configured, Defaults defaults) {
Objects.requireNonNull(defaults);
return configured == null || configured.isEmpty() ? defaults.sha1() : normalizeHash(configured, false);
}
private static String normalizeHash(String value, boolean allowEmpty) {
if (allowEmpty && (value == null || value.isEmpty())) return "";
if (value == null || !value.matches("[a-fA-F0-9]{40}")) {
throw new IllegalArgumentException(SHA1_KEY + " must be exactly 40 hexadecimal characters");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private Snapshot snapshot(String configuredUrl, String configuredSha1) {
String url = configuredUrl == null ? "" : configuredUrl;
String sha1 = configuredSha1 == null ? "" : normalizeHash(configuredSha1, true);
return new Snapshot(url, sha1, effectiveUrl(url, defaults), effectiveSha1(sha1, defaults));
}
public String url() { return effective.url(); }
public String sha1() { return effective.sha1(); }
public String value(String key) {
var state = effective;
return switch (key) {
case URL_KEY -> state.url();
case SHA1_KEY -> state.sha1();
default -> throw new IllegalArgumentException("Unknown sheet resource-pack key");
};
}
public CompletionStage<Update> set(String key, String raw) {
if (!URL_KEY.equals(key) && !SHA1_KEY.equals(key)) throw new IllegalArgumentException("Unknown sheet resource-pack key");
if (raw == null) throw new IllegalArgumentException(key + " requires a value");
if (URL_KEY.equals(key) && !raw.isEmpty()) validateUrl(raw);
if (SHA1_KEY.equals(key)) raw = normalizeHash(raw, true);
final String accepted = raw;
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(key, accepted);
if (URL_KEY.equals(key)) persistedUrl = accepted; else persistedSha1 = accepted;
return new Update(snapshot(persistedUrl, persistedSha1), ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Sheet resource-pack configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
effective = update.snapshot();
publishedSequence = update.sequence();
return true;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String name)) throw new IOException("Invalid configuration key");
values.put(name, entry.getValue());
}
}
}
values.put(key, value);
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,192 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ShieldBash;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Shield Bash settings; publication remains a server-thread caller decision. */
public final class ShieldBashSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "shield-bash-base-damage";
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
public static final String RANGE = "shield-bash-range-blocks";
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ShieldBash.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-shield-bash-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public ShieldBashSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ShieldBashSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public ShieldBash.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Shield Bash configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static ShieldBash.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Shield Bash settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.StoneSkin;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Stone Skin settings; publication remains a server-thread caller decision. */
public final class StoneSkinSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
public static final String DURATION = "stone-skin-duration-seconds";
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, StoneSkin.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-stone-skin-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public StoneSkinSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
StoneSkinSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public StoneSkin.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Stone Skin configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static StoneSkin.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new StoneSkin.Settings(cooldown, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for character-switch delay checks and later exits. */
public final class SwitchDelaySettings implements AutoCloseable {
public static final String KEY="character-swap-cooldown-seconds";public static final String DEFAULT="900";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,Duration delay,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-switch-delay-configuration"));
private volatile Duration effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public SwitchDelaySettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
SwitchDelaySettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(900);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds",invalid);}}
private static Duration validate(Duration value){if(value.isNegative()||value.getNano()!=0)throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds");try{var deadline=java.time.Instant.now().plus(value);if(deadline.atOffset(java.time.ZoneOffset.UTC).getYear()>9999)throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range");return value;}catch(java.time.DateTimeException|ArithmeticException invalid){throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range",invalid);}}
public Duration delay(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Character switch configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.delay());publishedSequence=update.sequence();}
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,179 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.WarBanner;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable WarBanner settings; publication remains a server-thread caller decision. */
public final class WarBannerSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "war-banner-cooldown-seconds";
public static final String PLACEMENT_RANGE = "war-banner-placement-range-blocks";
public static final String RADIUS = "war-banner-radius-blocks";
public static final String VERTICAL = "war-banner-vertical-range-blocks";
public static final String DURATION = "war-banner-duration-seconds";
public static final String DAMAGE_BONUS = "war-banner-damage-bonus-percent";
public static final String KNOCKBACK_RESISTANCE = "war-banner-knockback-resistance-percent";
private static final List<String> KEYS = List.of(COOLDOWN, PLACEMENT_RANGE, RADIUS, VERTICAL, DURATION,
DAMAGE_BONUS, KNOCKBACK_RESISTANCE);
private static final Map<String, String> DEFAULTS = Map.of(
COOLDOWN, "45", PLACEMENT_RANGE, "12", RADIUS, "8", VERTICAL, "4", DURATION, "10",
DAMAGE_BONUS, "25", KNOCKBACK_RESISTANCE, "50");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, WarBanner.Settings settings, Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-war-banner-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public WarBannerSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
WarBannerSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public WarBanner.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
preview.put(key, accepted); parse(preview);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("WarBanner configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String raw) {
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static WarBanner.Settings parse(Map<String, String> values) {
try { return new WarBanner.Settings(seconds(values.get(COOLDOWN)), finite(values.get(PLACEMENT_RANGE)),
finite(values.get(RADIUS)), finiteNonnegative(values.get(VERTICAL)), seconds(values.get(DURATION)),
new BigDecimal(values.get(DAMAGE_BONUS)), new BigDecimal(values.get(KNOCKBACK_RESISTANCE))); }
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("WarBanner settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private static double finiteNonnegative(String value) {
double result = finite(value); if (result < 0) throw new IllegalArgumentException("Setting is negative"); return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe lock");
}
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,59 @@
package games.dmg.fantasystick.presentation;
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
import java.util.Collection;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiConsumer;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
public final class SheetResourcePack implements Listener, AutoCloseable {
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
private final SheetResourcePackSettings settings;
private final BiConsumer<UUID, Throwable> failures;
private boolean closed;
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
this.settings = Objects.requireNonNull(settings);
this.failures = Objects.requireNonNull(failures);
}
public void deliverAll(Collection<? extends Player> players) {
if (closed) return;
for (var player : players) if (player != null && player.isOnline()) deliver(player);
}
public void deliver(Player player) {
if (closed || !player.isOnline()) return;
try {
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
"Optional Fantasy Stick first-person sheet presentation", false);
} catch (RuntimeException failure) {
report(player.getUniqueId(), failure);
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
}
}
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
if (closed || !ID.equals(event.getID())) return;
switch (event.getStatus()) {
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
}
}
private void report(UUID player, Throwable failure) {
try { failures.accept(player, failure); }
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
}
@Override public void close() { closed = true; }
}
@@ -0,0 +1,93 @@
package games.dmg.fantasystick.progression;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Server-thread bounded contribution ledger keyed by target and originating character. */
public final class CombatContributions implements AutoCloseable {
private static final java.time.Duration SUPPORT_LIFETIME = java.time.Duration.ofSeconds(10);
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
private final Map<UUID, Map<UUID, Instant>> effectiveHealing = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
public void accept(UUID target, UUID character, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(character); Objects.requireNonNull(acceptedAt);
if (closed) return;
targets.computeIfAbsent(target, ignored -> new HashMap<>()).merge(character, acceptedAt,
(previous, next) -> previous.isAfter(next) ? previous : next);
}
public void support(UUID target, UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(aidedCharacter);
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
if (closed) return;
supports.computeIfAbsent(target, ignored -> new HashMap<>())
.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
/** Records effective healing that may qualify through any target the aided character directly contributes to. */
public void support(UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
Objects.requireNonNull(aidedCharacter); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
if (closed) return;
effectiveHealing.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
var contributions = targets.remove(target);
var assistance = supports.remove(target);
if (contributions == null) return Set.of();
var eligible = new LinkedHashSet<UUID>();
contributions.forEach((character, acceptedAt) -> {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
for (UUID aided : Set.copyOf(eligible)) {
var targeted = assistance == null ? null : assistance.get(aided);
if (targeted != null) targeted.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
var healing = effectiveHealing.get(aided);
if (healing != null) healing.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
}
return Set.copyOf(eligible);
}
public void invalidate(UUID target) {
if (!closed) { target = Objects.requireNonNull(target); targets.remove(target); supports.remove(target); }
}
public void sweep(Instant now) {
Objects.requireNonNull(now);
if (closed) return;
targets.values().forEach(contributions -> contributions.entrySet().removeIf(entry -> !rules.contributes(entry.getValue(), now)));
targets.values().removeIf(Map::isEmpty);
supports.values().forEach(byAlly -> {
byAlly.values().forEach(bySupporter -> bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
byAlly.values().removeIf(Map::isEmpty);
});
supports.values().removeIf(Map::isEmpty);
effectiveHealing.values().forEach(bySupporter ->
bySupporter.entrySet().removeIf(entry -> !supports(entry.getValue(), now)));
effectiveHealing.values().removeIf(Map::isEmpty);
}
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
}
@Override public void close() { closed = true; targets.clear(); supports.clear(); effectiveHealing.clear(); }
}
@@ -0,0 +1,76 @@
package games.dmg.fantasystick.progression;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/** Validated combat reward arithmetic and location policy; native classification lives at the adapter. */
public record CombatRewardRules(BigInteger passivePool, BigInteger ordinaryPool, BigInteger bossPool,
BigDecimal restrictedSpacing) {
public enum Target { PASSIVE, ORDINARY, BOSS, FANTASY_CHARACTER }
public static final Duration CONTRIBUTION_LIFETIME = Duration.ofMinutes(5);
public record KillPosition(UUID world, BigDecimal x, BigDecimal y, BigDecimal z) {
public KillPosition {
Objects.requireNonNull(world);
x = coordinate(x); y = coordinate(y); z = coordinate(z);
}
private static BigDecimal coordinate(BigDecimal value) {
Objects.requireNonNull(value);
if (!Double.isFinite(value.doubleValue())) throw new IllegalArgumentException("Kill coordinate is not finite");
return value;
}
}
public CombatRewardRules {
Objects.requireNonNull(passivePool); Objects.requireNonNull(ordinaryPool);
Objects.requireNonNull(bossPool); Objects.requireNonNull(restrictedSpacing);
if (passivePool.signum() < 0 || ordinaryPool.signum() < 0 || bossPool.signum() < 0
|| restrictedSpacing.signum() < 0 || !Double.isFinite(restrictedSpacing.doubleValue())) {
throw new IllegalArgumentException("Combat reward values must be finite and nonnegative");
}
}
public static CombatRewardRules defaults() {
return new CombatRewardRules(BigInteger.ONE, BigInteger.TEN, BigInteger.valueOf(100), BigDecimal.valueOf(3));
}
public BigInteger pool(Target target, int defeatedLevel) {
Objects.requireNonNull(target);
if (target == Target.FANTASY_CHARACTER) {
if (defeatedLevel < 1) throw new IllegalArgumentException("Fantasy character level must be positive");
return BigInteger.TEN.multiply(BigInteger.valueOf(defeatedLevel));
}
return switch (target) {
case PASSIVE -> passivePool;
case ORDINARY -> ordinaryPool;
case BOSS -> bossPool;
case FANTASY_CHARACTER -> throw new IllegalStateException("Handled fantasy character");
};
}
public BigInteger share(BigInteger pool, int contributors) {
Objects.requireNonNull(pool);
if (pool.signum() < 0 || contributors < 1) throw new IllegalArgumentException("Invalid reward share");
var count = BigInteger.valueOf(contributors);
return pool.add(count).subtract(BigInteger.ONE).divide(count);
}
public boolean contributes(Instant acceptedAt, Instant deathAt) {
Objects.requireNonNull(acceptedAt); Objects.requireNonNull(deathAt);
return !acceptedAt.isAfter(deathAt) && !deathAt.isAfter(acceptedAt.plus(CONTRIBUTION_LIFETIME));
}
public boolean spaced(KillPosition previous, KillPosition current) {
Objects.requireNonNull(previous); Objects.requireNonNull(current);
if (!previous.world().equals(current.world())) return true;
BigDecimal dx = current.x().subtract(previous.x());
BigDecimal dy = current.y().subtract(previous.y());
BigDecimal dz = current.z().subtract(previous.z());
BigDecimal squared = dx.multiply(dx).add(dy.multiply(dy)).add(dz.multiply(dz));
return squared.compareTo(restrictedSpacing.multiply(restrictedSpacing)) >= 0;
}
}
@@ -0,0 +1,121 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
public final class AdminCharacterIssuer implements AutoCloseable {
public enum Outcome { DELIVERED, FULL, BUSY, STALE, SAVE_FAILED, RETAINED }
public record Failure(UUID player, UUID character, String operation, Throwable cause) { }
private final CharacterStorage storage;
private final Supplier<CharacterRecord> allocate;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
this.storage = Objects.requireNonNull(storage);
this.allocate = Objects.requireNonNull(allocate);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
/** Must be entered on the server thread. Completion is delivered on that thread. */
public void issue(Player player, Consumer<Outcome> response) {
Objects.requireNonNull(player); Objects.requireNonNull(response);
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
int slot = player.getInventory().firstEmpty();
if (slot < 0) { response.accept(Outcome.FULL); return; }
var attempt = new Attempt(player, slot, response);
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.playerId, attempt);
abandon(attempt);
report(attempt, "prepare", failure);
response.accept(Outcome.SAVE_FAILED);
}
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.playerId) != attempt) { abandon(attempt); return; }
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
catch (RuntimeException rejected) {
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
}
}
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
if (failure != null) {
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
}
var occupying = attempt.player.getInventory().getItem(attempt.slot);
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|| occupying != null && !occupying.getType().isAir()) {
abandon(attempt); respond(attempt, Outcome.STALE); return;
}
var claimed = batch.claimForDelivery();
if (claimed.isEmpty() || claimed.orElseThrow().size() != 1
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
}
try {
var sheet = new CharacterSheets().create(attempt.record);
attempt.player.getInventory().setItem(attempt.slot, sheet);
attempt.delivered = true;
} catch (RuntimeException uncertain) {
report(attempt, "delivery", uncertain);
respond(attempt, Outcome.RETAINED);
return;
}
try { attempt.player.updateInventory(); }
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
respond(attempt, Outcome.DELIVERED);
}
private void respond(Attempt attempt, Outcome outcome) {
try { attempt.response.accept(outcome); }
catch (RuntimeException failure) { report(attempt, "response", failure); }
}
private void abandon(Attempt attempt) {
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
attempt.preparation.abandon().thenAccept(cleanup -> {
if (!cleanup.retained().isEmpty()) report(attempt, "cleanup-retained", null);
});
}
private void report(Attempt attempt, String operation, Throwable cause) {
try { failures.accept(new Failure(attempt.playerId, attempt.record == null ? null : attempt.record.id(), operation, cause)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
closed = true;
for (var attempt : List.copyOf(pending.values())) {
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
}
}
private static final class Attempt {
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import java.math.BigInteger;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Color;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
/** Acknowledged administrator XP mutation captured against one exact active character/session. */
public final class AdminExperienceGrant implements AutoCloseable {
public enum Status { APPLIED, AT_CAP, STALE, UNAVAILABLE, FAILED }
public record Result(Status status, String name, BigInteger requested, BigInteger applied,
int level, int levels) { }
public record Failure(UUID character, Throwable cause) { }
private final CharacterStorage characters;
private final ProgressionRules progression;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Function<UUID, Player> players;
private final Consumer<Failure> failures;
private volatile boolean closed;
public AdminExperienceGrant(CharacterStorage characters, ProgressionRules progression, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Function<UUID, Player> players, Consumer<Failure> failures) {
this.characters = Objects.requireNonNull(characters);
this.progression = Objects.requireNonNull(progression);
this.sessions = Objects.requireNonNull(sessions);
this.nextTick = Objects.requireNonNull(nextTick);
this.players = Objects.requireNonNull(players);
this.failures = Objects.requireNonNull(failures);
}
/** Returns false before persistence when the captured active identity is no longer authoritative. */
public boolean grant(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion) {
Objects.requireNonNull(active); Objects.requireNonNull(captured);
Objects.requireNonNull(amount); Objects.requireNonNull(completion);
if (amount.signum() <= 0) throw new IllegalArgumentException("Experience grant must be positive");
if (closed || !sessions.current(active)
|| sessions.currentRecord(active).filter(captured::equals).isEmpty()) return false;
characters.awardExperience(captured.id(), captured.revision(), amount, progression)
.whenComplete((award, failure) -> schedule(active, captured, amount, completion, award, failure));
return true;
}
private void schedule(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (failure != null) report(captured.id(), failure);
if (closed) return;
try {
nextTick.accept(() -> complete(active, captured, amount, completion, award, failure));
} catch (RuntimeException rejected) { report(captured.id(), rejected); }
}
private void complete(EquipmentSessions.Active active, CharacterRecord captured, BigInteger amount,
Consumer<Result> completion, CharacterStorage.ExperienceAward award, Throwable failure) {
if (closed) return;
if (failure != null) {
completion.accept(new Result(Status.FAILED, captured.name(), amount, BigInteger.ZERO,
captured.level(), 0));
return;
}
var saved = award.record().orElse(captured);
Status status = switch (award.status()) {
case APPLIED -> Status.APPLIED;
case AT_CAP, UNCHANGED -> Status.AT_CAP;
case STALE -> Status.STALE;
case UNAVAILABLE -> Status.UNAVAILABLE;
};
BigInteger applied = status == Status.APPLIED
? saved.experience().subtract(captured.experience()) : BigInteger.ZERO;
if (status == Status.APPLIED && sessions.current(active)) {
boolean synchronizedSession = sessions.acknowledgeProgress(active, captured.revision(), saved);
if (!synchronizedSession && sessions.current(active)
&& sessions.currentRecord(active).filter(saved::equals).isEmpty()) {
report(captured.id(), new IllegalStateException(
"Saved administrator XP could not synchronize with its captured active session"));
}
if (synchronizedSession && sessions.current(active)) feedback(active, saved, applied, award.levels());
}
completion.accept(new Result(status, captured.name(), amount, applied, saved.level(), award.levels()));
}
private void feedback(EquipmentSessions.Active active, CharacterRecord saved, BigInteger applied, int levels) {
Player player;
try { player = players.apply(active.player()); }
catch (RuntimeException failure) { report(saved.id(), failure); return; }
if (player == null || !player.isOnline() || !sessions.current(active)
|| sessions.currentRecord(active).filter(saved::equals).isEmpty()) return;
try {
player.sendMessage("Received " + applied + " fantasy XP from an administrator.");
for (int gained = 1; gained <= levels; gained++) {
int level = saved.level() - levels + gained;
player.sendMessage("Level " + level + "! Gained 1 character point.");
SessionVisuals.burst(player, Color.BLUE, 12);
player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1, 1);
}
} catch (RuntimeException failure) { report(saved.id(), failure); }
}
private void report(UUID character, Throwable failure) {
try { failures.accept(new Failure(character, failure)); }
catch (RuntimeException ignored) { /* Diagnostics do not replay a grant. */ }
}
@Override public void close() { closed = true; }
}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.TailBooks;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Admin-owned, expiring Tail selection with authoritative consume-once restoration. */
public final class AdminRevival implements Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
public interface Scheduler { AutoCloseable later(Duration delay, Runnable action); }
private record Candidate(int slot, ItemStack item, TailBooks.Identity identity) { }
private record Choice(Candidate candidate, CharacterRecord record) { }
private final CharacterStorage characters; private final RevivalStore reservations; private final Clock clock;
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
private final class View implements AutoCloseable {
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);}
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}}
}
public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);}
public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout);}));}
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())continue;var identity=tails.identify(item);if(identity.isPresent())result.add(new Candidate(slot,item.clone(),identity.orElseThrow()));}return List.copyOf(result);}
private boolean eligible(CharacterRecord record,TailBooks.Identity id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());}
private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);}
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout));views.put(inventory,view);admin.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);}
@EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&event.getRawSlots().stream().anyMatch(slot->slot<view.inventory.getSize()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR) public void close(org.bukkit.event.inventory.InventoryCloseEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();}
}
File diff suppressed because it is too large. Load diff
@@ -40,7 +40,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<EquipmentSessions.Failure> diagnostics;
private final CharacterSheets sheets = new CharacterSheets();
private final SessionControls controls = new SessionControls();
private SessionControls controls = new SessionControls();
private static final class Intent { boolean started; }
private final Map<UUID, Intent> swaps = new HashMap<>();
private final Map<UUID, UUID> observed = new HashMap<>();
@@ -88,6 +88,12 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
sessions.attachCursorCustody(this::cancelCustody);
}
/** Install before registration so extensible controls keep the same structural containment catalogue. */
public void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (closed || !draws.isEmpty()) throw new IllegalStateException("Input handling already started");
controls = new SessionControls(Objects.requireNonNull(registry));
}
/** The input recipient belongs to this plugin; it does not authorize vanilla item use. */
public EquipmentSessionEvents(EquipmentSessions sessions, Consumer<Runnable> nextTick,
Consumer<EquipmentSessions.Failure> diagnostics, Consumer<ControlInput> inputs) {
@@ -337,8 +343,16 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (allowed && event.getHand() == EquipmentSlot.HAND && (event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_AIR
|| event.getAction() == org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK)) {
var selected = sessions.selectedControl(event.getPlayer(), event.getItem()).orElse(null);
if (selected != null && useGesture(selected)) emit(event.getPlayer(), event.getItem(), selected,
ControlInput.Gesture.USE, null, 0, 0);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("hex")
|| selected.ability().equals("mending_light")
|| selected.ability().equals("guardian_bond")
? event.getPlayer().getTargetEntity(64)
: selected.ability().equals("inspire") || selected.ability().equals("purify")
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
: null;
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
}
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
}
}
@@ -410,12 +424,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
private void emit(Player player, ItemStack item, EquipmentSessions.SelectedControl selected,
ControlInput.Gesture gesture, org.bukkit.entity.Entity target, int heldTicks, float strength) {
var captured = item.clone();
var kind = selected.slot() == 0 ? ControlInput.Kind.BOOK
: selected.slot() == 1 ? ControlInput.Kind.BASIC_ATTACK : ControlInput.Kind.ABILITY;
var input = new ControlInput(player, selected.session(), selected.slot(), selected.ability(), kind,
gesture, target, heldTicks, strength,
() -> !closed && sessions.selectedControl(player, captured).filter(selected::equals).isPresent());
() -> !closed && sessions.currentControl(player, selected));
if (!input.stillCurrent()) return;
try { inputs.accept(input); }
catch (RuntimeException failure) { report(player.getUniqueId(), selected.session().session(), failure); }
@@ -428,8 +441,11 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (event.getHand() == EquipmentSlot.HAND) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && useGesture(selected)) emit(player, item, selected,
ControlInput.Gesture.USE, event.getRightClicked(), 0, 0);
if (selected != null && useGesture(selected)) {
org.bukkit.entity.Entity target = selected.ability().equals("inspire") && player.isSneaking()
? player : event.getRightClicked();
emit(player, item, selected, ControlInput.Gesture.USE, target, 0, 0);
}
}
}
@@ -443,7 +459,8 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
if (special(item)) event.setCancelled(true);
if (willAttack) {
var selected = sessions.selectedControl(player, item).orElse(null);
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword"))) {
if (selected != null && (selected.slot() == 1 || selected.ability().equals("great_sword")
|| selected.ability().equals("shield_bash"))) {
emit(player, item, selected, ControlInput.Gesture.ATTACK, event.getAttacked(), 0, player.getAttackCooldown());
}
}
@@ -6,11 +6,13 @@ import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -33,11 +35,13 @@ public final class EquipmentSessions implements AutoCloseable {
public record Notice(UUID player, String code, String message) { }
public record Failure(UUID player, UUID session, Throwable cause) { }
public record Active(UUID player, UUID session, UUID character) { }
public record ActiveCharacter(Active session, CharacterRecord record) { }
public record SelectedControl(Active session, int slot, String ability) { }
private final CharacterStorage characters;
private final EquipmentSessionStore receipts;
private final Duration switchDelay;
private Supplier<Duration> switchDelay;
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final Consumer<Notice> notices;
@@ -45,11 +49,13 @@ public final class EquipmentSessions implements AutoCloseable {
private games.dmg.fantasystick.presentation.SessionPresentation presentation = games.dmg.fantasystick.presentation.SessionPresentation.NONE;
private final SessionClaims claims = new SessionClaims();
private final EquipmentStorage equipment = new EquipmentStorage();
private final SessionControls controls = new SessionControls();
private SessionControls controls;
private final CharacterSheets sheets = new CharacterSheets();
private final Map<UUID, Attempt> pending = new HashMap<>();
private final Map<UUID, Attempt> owned = new HashMap<>();
private final Map<UUID, Attempt> active = new HashMap<>();
private final java.util.ArrayDeque<UUID> cooldownRefreshOrder = new java.util.ArrayDeque<>();
private final java.util.Set<UUID> cooldownRefreshQueued = new java.util.HashSet<>();
// Covers reentrant inventory setters and client sync, not just asynchronous work.
private final Map<UUID, Attempt> transitioning = new HashMap<>();
private Consumer<UUID> cursorCustody = ignored -> { };
@@ -78,6 +84,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean effectsStarted;
String previousGameMode;
boolean modeStarted;
Instant nextCooldownVisualChange;
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
boolean vitalsStarted;
boolean healthSaveRequested;
@@ -90,6 +97,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
org.bukkit.Location entryLocation;
@@ -117,8 +125,10 @@ public final class EquipmentSessions implements AutoCloseable {
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
this.characters = Objects.requireNonNull(characters);
this.receipts = Objects.requireNonNull(receipts);
this.switchDelay = Objects.requireNonNull(switchDelay);
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
this.switchDelay = () -> initialSwitchDelay;
this.clock = Objects.requireNonNull(clock);
this.controls = new SessionControls(new games.dmg.fantasystick.ability.AbilityRegistry(), clock);
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
this.nextTick = Objects.requireNonNull(nextTick);
this.notices = Objects.requireNonNull(notices);
@@ -141,6 +151,25 @@ public final class EquipmentSessions implements AutoCloseable {
this.gameModes = Objects.requireNonNull(gameModes);
}
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
switchDelay = Objects.requireNonNull(policy);
if (switchDelay.get().isNegative()) throw new IllegalArgumentException("Negative switch delay");
}
/** Install before exposing entry so pending and new activation work revalidates availability. */
public synchronized void attachRoleplayDisabled(java.util.function.BooleanSupplier policy) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
roleplayDisabled = Objects.requireNonNull(policy);
}
/** Install before exposing entry so registered definitions drive delivered control metadata. */
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
controls = new SessionControls(Objects.requireNonNull(registry), clock);
}
/** Install before exposing entry; equipment-only adapter tests can omit the health overlay. */
public synchronized void attachVitals(SessionVitals value) {
if (vitals != null || !owned.isEmpty()) throw new IllegalStateException("Vitals already attached or sessions started");
@@ -201,7 +230,103 @@ public final class EquipmentSessions implements AutoCloseable {
&& sheets.identify(attempt.player.getInventory().getItemInOffHand()).filter(attempt.character::equals).isPresent();
}
/** Side-effect-free server-thread snapshots for exact active-character command resolution/autocomplete. */
public synchronized List<ActiveCharacter> activeCharacters() {
if (closed) return List.of();
return active.values().stream().filter(attempt -> attempt.record != null)
.map(attempt -> new ActiveCharacter(new Active(attempt.owner, attempt.session, attempt.character), attempt.record))
.filter(snapshot -> current(snapshot.session())).toList();
}
/** Current active holder for a transferable identity, used only to synchronize acknowledged character writes. */
public synchronized Optional<Active> activeCharacter(UUID character) {
Objects.requireNonNull(character);
return active.values().stream().filter(attempt -> attempt.record != null && attempt.character.equals(character))
.filter(attempt -> current(new Active(attempt.owner, attempt.session, attempt.character)))
.map(attempt -> new Active(attempt.owner, attempt.session, attempt.character)).findFirst();
}
/** Current transferable character snapshot for a still-authorized server-thread action. */
public synchronized Optional<CharacterRecord> currentRecord(Active session) {
var attempt = active.get(session.player());
return current(session) && attempt != null && attempt.record != null
? Optional.of(attempt.record) : Optional.empty();
}
/** Apply a persisted progression revision to the still-current active session. */
public synchronized boolean acknowledgeProgress(Active session, BigInteger expectedRevision, CharacterRecord saved) {
var attempt = active.get(session.player());
if (closed || attempt == null || attempt.session == null || attempt.record == null
|| !attempt.session.equals(session.session()) || !attempt.character.equals(session.character())
|| !attempt.record.revision().equals(expectedRevision) || !saved.id().equals(attempt.character)
|| !saved.revision().equals(expectedRevision.add(BigInteger.ONE)) || !current(session)) return false;
var previous = attempt.record;
if (vitals != null) vitals.updateProgress(attempt.player, attempt.session, previous, saved);
attempt.record = saved; // Persisted truth precedes any reentrant native presentation setter.
if ((!previous.loadout().equals(saved.loadout()) || !previous.cooldowns().equals(saved.cooldowns()))
&& !refreshControls(attempt, session)) return false;
return true;
}
/** Bounded server-tick refresh of active cooldown controls; persisted deadlines are not mutated. */
public synchronized void refreshCooldownControls(int maximumSessions) {
if (maximumSessions <= 0) throw new IllegalArgumentException("Cooldown refresh bound must be positive");
int checks = Math.min(maximumSessions, cooldownRefreshOrder.size());
for (int checked = 0; checked < checks; checked++) {
UUID owner = cooldownRefreshOrder.removeFirst();
var attempt = active.get(owner);
if (attempt == null || attempt.record == null || attempt.session == null) {
cooldownRefreshQueued.remove(owner);
continue;
}
cooldownRefreshOrder.addLast(owner);
if (attempt.nextCooldownVisualChange != null && !clock.instant().isBefore(attempt.nextCooldownVisualChange)) {
refreshControls(attempt, new Active(attempt.owner, attempt.session, attempt.character));
}
}
}
private void queueCooldownRefresh(Attempt attempt) {
if (cooldownRefreshQueued.add(attempt.owner)) cooldownRefreshOrder.addLast(attempt.owner);
}
private boolean refreshControls(Attempt attempt, Active session) {
var refreshed = controls.create(attempt.record, attempt.owner, attempt.session);
boolean changed = false;
for (int slot = 2; slot < 9; slot++) {
if (active.get(attempt.owner) != attempt || !current(session)) return false;
if (Objects.equals(attempt.player.getInventory().getItem(slot), refreshed[slot])) continue;
attempt.player.getInventory().setItem(slot, refreshed[slot]);
changed = true;
}
if (changed) attempt.player.updateInventory(); // No planned native writes after this reentrant boundary.
if (active.get(attempt.owner) != attempt || !current(session)) return false;
attempt.nextCooldownVisualChange = nextCooldownVisualChange(attempt.record);
return true;
}
private Instant nextCooldownVisualChange(CharacterRecord record) {
var now = clock.instant();
return record.loadout().stream().filter(ability -> !ability.isEmpty())
.map(record.cooldowns()::get).filter(Objects::nonNull).filter(deadline -> now.isBefore(deadline))
.min(Instant::compareTo).orElse(null);
}
/** Server-thread authorization for the currently held, issued control and saved loadout. */
public synchronized boolean currentControl(Player player, SelectedControl selected) {
if (selected == null) return false;
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || attempt.record == null
|| !current(selected.session()) || selected.slot() != player.getInventory().getHeldItemSlot()) return false;
var actual = player.getInventory().getItem(selected.slot());
var identity = controls.identify(actual).orElse(null);
if (identity == null || identity.slot() != selected.slot() || !identity.owner().equals(attempt.owner)
|| !identity.session().equals(attempt.session)) return false;
String ability = selected.slot() < 2 ? "" : controls.ability(actual).orElse("");
return ability.equals(selected.ability()) && (selected.slot() < 2
|| ability.equals(attempt.record.loadout().get(selected.slot() - 2)));
}
public synchronized Optional<SelectedControl> selectedControl(Player player, ItemStack reported) {
var attempt = active.get(player.getUniqueId());
if (closed || attempt == null || attempt.player != player || !player.isOnline() || attempt.record == null
@@ -224,6 +349,10 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void enter(Player player) {
UUID owner = player.getUniqueId();
if (closed) return;
if (roleplayDisabled.getAsBoolean()) {
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
return;
}
if (transitioning.containsKey(owner)) {
notice(owner, "BUSY", "An equipment transition is still completing; retry afterwards."); return;
}
@@ -283,6 +412,10 @@ public final class EquipmentSessions implements AutoCloseable {
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
if (!entryCurrent(attempt) || attempt.claim != null) return;
if (roleplayDisabled.getAsBoolean()) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
return;
}
if (failure != null) { fail(attempt, failure); return; }
var record = loaded.character().orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
@@ -294,7 +427,7 @@ public final class EquipmentSessions implements AutoCloseable {
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
}
Instant now = clock.instant();
if (!state.canEnter(attempt.character, switchDelay, now)) {
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
@@ -333,6 +466,10 @@ public final class EquipmentSessions implements AutoCloseable {
private synchronized void complete(Attempt attempt, Optional<CharacterRecord> fresh, Throwable failure) {
if (!entryCurrent(attempt)) return;
if (roleplayDisabled.getAsBoolean()) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
return;
}
if (failure != null) { fail(attempt, failure); return; }
var record = fresh.orElse(null);
if (record == null || record.dead() || record.health().signum() <= 0) {
@@ -358,6 +495,8 @@ public final class EquipmentSessions implements AutoCloseable {
refuse(attempt, result.name(), "Equipment entry refused: " + result); return;
}
packed = true;
try { sheets.applyModel(attempt.player.getInventory().getItemInOffHand()); }
catch (RuntimeException cosmeticFailure) { diagnostic(attempt, cosmeticFailure); }
if (interrupted(attempt)) return;
for (int slot = 0; slot < 9; slot++) {
attempt.player.getInventory().setItem(slot, items[slot]);
@@ -373,7 +512,9 @@ public final class EquipmentSessions implements AutoCloseable {
pending.remove(attempt.owner, attempt);
if (!attempt.exitRequested) {
attempt.record = record;
attempt.nextCooldownVisualChange = nextCooldownVisualChange(record);
active.put(attempt.owner, attempt);
queueCooldownRefresh(attempt);
attempt.presentationInstalled = true;
try { presentation.activate(attempt.player, attempt.session, record); }
catch (RuntimeException | LinkageError error) { withdrawPresentation(attempt); diagnostic(attempt, error); }
@@ -411,6 +552,7 @@ public final class EquipmentSessions implements AutoCloseable {
UUID owner = player.getUniqueId();
var transition = transitioning.get(owner);
if (transition != null) {
captureExitDelay(transition);
active.remove(owner, transition);
withdrawPresentation(transition);
transition.exitRequested = true;
@@ -420,6 +562,7 @@ public final class EquipmentSessions implements AutoCloseable {
cancelEntry(owner);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
captureExitDelay(attempt);
return restoreExit(attempt);
}
@@ -564,6 +707,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
captureExitDelay(attempt);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
@@ -660,19 +804,35 @@ public final class EquipmentSessions implements AutoCloseable {
return false;
}
private void captureExitDelay(Attempt attempt) {
if (attempt.exitDelay != null) return;
var delay = Objects.requireNonNull(switchDelay.get());
if (delay.isNegative()) throw new IllegalStateException("Negative switch delay policy");
attempt.exitDelay = delay;
}
private void persistExit(Attempt attempt, boolean restored) {
captureExitDelay(attempt); // Recovery attempts have no process-local exit request to capture earlier.
attempt.nativeRestored = restored;
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
attempt.saving = true;
persist(() -> complete
? receipts.completeExit(attempt.owner, attempt.session, switchDelay)
: receipts.recordExit(attempt.owner, attempt.session, switchDelay), (state, failure) -> {
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
attempt.saving = false;
if (failure != null) diagnostic(attempt, failure);
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
});
}
/** Cancel pending activation and route every active player through ordinary restoration. */
public synchronized void disableRoleplay() {
for (var attempt : java.util.List.copyOf(pending.values())) {
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
}
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
}
public synchronized void cancelEntry(UUID player) {
var attempt = pending.remove(player);
if (attempt != null) abandon(attempt);
@@ -0,0 +1,48 @@
package games.dmg.fantasystick.session;
import java.util.Objects;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/** Active-session-only hunger/exhaustion suppression; explicit non-food healing remains native. */
public final class NutritionPolicy implements Listener, AutoCloseable {
private final EquipmentSessions sessions;
private boolean closed;
public NutritionPolicy(EquipmentSessions sessions) {
this.sessions = Objects.requireNonNull(sessions);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void food(org.bukkit.event.entity.FoodLevelChangeEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void exhaustion(org.bukkit.event.entity.EntityExhaustionEvent event) {
if (!active(event.getEntity())) return;
event.setCancelled(true);
var player = (Player) event.getEntity();
player.setFoodLevel(20);
player.setExhaustion(0);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void healing(org.bukkit.event.entity.EntityRegainHealthEvent event) {
if (event.getRegainReason() == org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason.SATIATED
&& active(event.getEntity())) event.setCancelled(true);
}
private boolean active(org.bukkit.entity.Entity entity) {
return !closed && entity instanceof Player player
&& sessions.active(player.getUniqueId()).isPresent();
}
@Override public void close() { closed = true; }
}
@@ -57,10 +57,15 @@ public final class SessionVitals implements AutoCloseable {
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setHealthScale(maximum);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
player.setFoodLevel(20);
player.setExhaustion(0);
if (closed) throw new IllegalStateException("Vitals lifetime ended during entry");
write(player, new Mark(session, Phase.APPLIED, maximum, ordinary));
} catch (RuntimeException | LinkageError failure) {
try { releaseMaximum(player, session); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreNutrition(player, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
try { restoreScale(player, maximum, ordinary); }
catch (RuntimeException | LinkageError cleanup) { failure.addSuppressed(cleanup); }
// Leave APPLYING proof and the acknowledged receipt: an uncertain health setter
@@ -69,6 +74,30 @@ public final class SessionVitals implements AutoCloseable {
}
}
/** Apply an acknowledged stat revision to the live native health pool exactly once. */
public void updateProgress(Player player, UUID session, CharacterRecord previous, CharacterRecord saved) {
Objects.requireNonNull(previous);
Objects.requireNonNull(saved);
var mark = read(player);
var lease = leases.get(player.getUniqueId());
if (closed || mark == null || mark.phase() != Phase.APPLIED || !mark.session().equals(session)
|| lease == null || !lease.session().equals(session) || !previous.id().equals(saved.id())
|| !previous.revision().add(java.math.BigInteger.ONE).equals(saved.revision())) {
throw new IllegalStateException("No matching active fantasy vitals revision");
}
double nextMaximum = saved.maximumHealth().min(NativeHealthLimit.read()).doubleValue();
double gain = Math.max(0, nextMaximum - mark.maximum());
if (gain == 0 && nextMaximum == mark.maximum()) return;
var nativeAttribute = Objects.requireNonNull(((CraftPlayer) player).getHandle()
.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH));
lease.maximum().close();
var replacement = new NativeMaximumHealth(nativeAttribute, nextMaximum);
leases.put(player.getUniqueId(), new Lease(session, replacement));
player.setHealth(Math.min(nextMaximum, player.getHealth() + gain));
player.setHealthScale(nextMaximum);
write(player, new Mark(session, Phase.APPLIED, nextMaximum, mark.ordinary()));
}
public BigDecimal remaining(Player player, UUID session, OrdinaryVitals ordinary) {
var mark = read(player);
if (mark == null || !mark.session().equals(session) || !mark.ordinary().equals(ordinary) || mark.phase() != Phase.APPLIED) {
@@ -97,7 +126,7 @@ public final class SessionVitals implements AutoCloseable {
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
restoreNutrition(player, ordinary);
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
@@ -111,6 +140,12 @@ public final class SessionVitals implements AutoCloseable {
}
}
private static void restoreNutrition(Player player, OrdinaryVitals ordinary) {
player.setFoodLevel(ordinary.food());
player.setSaturation(ordinary.saturation());
player.setExhaustion(ordinary.exhaustion());
}
private static void restoreScale(Player player, double maximum, OrdinaryVitals ordinary) {
if (player.isHealthScaled() && player.getHealthScale() == maximum) {
player.setHealthScale(ordinary.scale());
+160
View File
@@ -1,3 +1,163 @@
# Captured when a new fantasy session starts; changing this does not alter active sessions.
# Admin command: /fantasystick config adventure-mode [true|false]
adventure-mode: true
admin-revive-selection-timeout-seconds: 60
# Real-time delay before this player may enter a different character after exit.
character-swap-cooldown-seconds: 900
roleplay-disabled: false
party-max-size: 6
sheet-resource-pack-url: ''
sheet-resource-pack-sha1: ''
# New-character and transferable fantasy progression defaults.
starting-points: 40
xp-per-level: 100
maximum-level: 30
# Character-bound combat rewards. Restricted hostile/neutral/boss history uses mob death positions.
passive-xp: 1
ordinary-xp: 10
boss-xp: 100
restricted-kill-spacing-blocks: 3
# Fireball defaults from the reviewed starter catalogue.
fireball-base-damage: 6
fireball-cooldown-seconds: 4
fireball-range-blocks: 32
fireball-lifetime-seconds: 5
# Great Sword defaults from the reviewed starter catalogue.
great-sword-base-damage: 7
great-sword-recovery-seconds: 1.2
# Frost Shard defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
frost-shard-base-damage: 4
frost-shard-cooldown-seconds: 5
frost-shard-range-blocks: 32
frost-shard-lifetime-seconds: 5
frost-shard-projectile-speed-blocks-per-tick: 1
frost-shard-slowness-amplifier: 0
frost-shard-slowness-duration-seconds: 2
# Longbow defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
longbow-base-damage: 5
longbow-recovery-seconds: 1
longbow-minimum-draw-ticks: 3
longbow-full-draw-ticks: 20
longbow-range-blocks: 64
longbow-lifetime-seconds: 10
longbow-full-projectile-speed-blocks-per-tick: 3
# Quickstep defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
quickstep-cooldown-seconds: 5
quickstep-distance-blocks: 4
quickstep-speed-blocks-per-tick: 1
# Second Wind defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
second-wind-base-healing: 6
second-wind-cooldown-seconds: 15
# Stone Skin defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
stone-skin-cooldown-seconds: 14
stone-skin-duration-seconds: 4
stone-skin-damage-reduction-percent: 25
# Shield Bash defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
shield-bash-base-damage: 4
shield-bash-cooldown-seconds: 6
shield-bash-range-blocks: 3
shield-bash-horizontal-knockback: 0.8
shield-bash-minimum-vertical-knockback: 0.35
# Hex defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
hex-cooldown-seconds: 10
hex-range-blocks: 12
hex-duration-seconds: 4
hex-damage-reduction-percent: 20
# Inspire defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
inspire-base-temporary-health: 4
inspire-cooldown-seconds: 15
inspire-range-blocks: 12
inspire-duration-seconds: 6
# Mending Light defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
mending-light-base-healing: 6
mending-light-cooldown-seconds: 8
mending-light-range-blocks: 12
# Purify defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
purify-cooldown-seconds: 15
purify-range-blocks: 12
# Guardian Bond defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
guardian-bond-cooldown-seconds: 20
guardian-bond-range-blocks: 10
guardian-bond-duration-seconds: 6
guardian-bond-redirect-percent: 30
# Rallying Cry defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
rallying-cry-cooldown-seconds: 25
rallying-cry-radius-blocks: 8
rallying-cry-duration-seconds: 6
rallying-cry-speed-amplifier: 0
rallying-cry-damage-bonus-percent: 20
# Frost Nova defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
frost-nova-base-damage: 5
frost-nova-cooldown-seconds: 12
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.
# Admin command: /fantasystick 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: 3
chain-lightning-jump-multiplier: 0.75
# Sanctuary defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
sanctuary-cooldown-seconds: 45
sanctuary-radius-blocks: 6
sanctuary-vertical-range-blocks: 3
sanctuary-duration-seconds: 6
sanctuary-pulse-interval-seconds: 1
sanctuary-healing-per-pulse: 2
sanctuary-damage-reduction-percent: 20
# War Banner defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
war-banner-cooldown-seconds: 45
war-banner-placement-range-blocks: 12
war-banner-radius-blocks: 8
war-banner-vertical-range-blocks: 4
war-banner-duration-seconds: 10
war-banner-damage-bonus-percent: 25
war-banner-knockback-resistance-percent: 50
# Meteor defaults from the reviewed remaining-development packet.
# Admin command: /fantasystick config <key> [value]
meteor-base-damage: 14
meteor-cooldown-seconds: 45
meteor-target-range-blocks: 24
meteor-warning-seconds: 2
meteor-radius-blocks: 5
meteor-vertical-range-blocks: 4
+3 -3
View File
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
author: dmg.games
commands:
fantasystick:
description: Inspect/recover equipment sessions or configure fantasy mode.
usage: /fantasystick <inspect|recover> <player-uuid> | config adventure-mode [true|false]
description: Issue or revive sheets, grant fantasy experience, recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | experience give <active-character-name...> <amount> | <inspect|recover> <player-uuid> | config <setting> [value]
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Inspect/recover equipment sessions and configure fantasy mode.
description: Issue/revive sheets, grant fantasy experience, recover sessions and configure fantasy mode.
default: op
@@ -0,0 +1,229 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Material;
import org.bukkit.event.block.Action;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AbilityEngineNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void executionContractsRevalidateTargetsBoundAreasReportOnlyEffectsAndCleanOwnedWork() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = new AbilityRegistry();
var reports = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityReport>();
var delayed = new java.util.ArrayList<Runnable>();
var closedResources = new AtomicInteger();
var delayedEffects = new AtomicInteger();
var selectionEffects = new AtomicInteger();
var allyEffects = new AtomicInteger();
var exceptionalEffects = new AtomicInteger();
var areaRecipients = new java.util.ArrayList<org.bukkit.entity.Entity>();
var areaCandidates = new java.util.concurrent.atomic.AtomicReference<List<? extends org.bukkit.entity.Entity>>(List.of());
registry.register(definition("report_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> {
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
execution.report(type, execution.input().target(), BigDecimal.valueOf(2));
}
}));
registry.register(definition("area_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.AREA, execution -> areaRecipients.addAll(execution.eligible(areaCandidates.get(), 2))));
registry.register(definition("delayed_test", AbilityDefinition.Activation.DELAYED,
AbilityDefinition.TargetPolicy.PARTY, execution -> execution.later(Duration.ofSeconds(1), () -> {
delayedEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("selection_test", AbilityDefinition.Activation.SELECTION,
AbilityDefinition.TargetPolicy.SELECTION, execution -> execution.later(Duration.ofSeconds(1), () -> {
selectionEffects.incrementAndGet(); execution.finish();
})));
registry.register(definition("projectile_test", AbilityDefinition.Activation.PROJECTILE,
AbilityDefinition.TargetPolicy.HOSTILE, execution -> execution.own(closedResources::incrementAndGet)));
registry.register(definition("ally_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.ALLY, execution -> allyEffects.incrementAndGet()));
registry.register(definition("exception_test", AbilityDefinition.Activation.IMMEDIATE,
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT, execution -> exceptionalEffects.incrementAndGet()));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
prepare(data, a, List.of("report_test", "area_test", "delayed_test", "selection_test", "projectile_test", "ally_test", "exception_test"));
f.activate(a); f.activate(b);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
games.dmg.fantasystick.ability.AbilityScheduler scheduler = (delay, action) -> {
delayed.add(action); return closedResources::incrementAndGet;
};
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry,
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(engine, f, a, active, 2, "report_test", hostile);
assertEquals(java.util.Set.copyOf(java.util.Arrays.asList(games.dmg.fantasystick.ability.AbilityReport.Type.values())),
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
areaCandidates.set(List.of(hostile, ordinary, b.player));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 3, "area_test", null);
assertEquals(List.of(hostile, b.player), areaRecipients);
assertEquals(5, reports.size(), "Casting and no-op/recipient discovery do not manufacture reports");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 7, "ally_test", b.player);
assertEquals(1, allyEffects.get());
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 8, "exception_test", ordinary);
assertEquals(1, exceptionalEffects.get(), "Explicit non-combat policy may select an ordinary player");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 4, "delayed_test", b.player);
assertEquals(1, delayed.size());
assertTrue(parties.leave(a.core.id, party.id()));
delayed.removeFirst().run();
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
assertEquals(1, delayed.size());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 6, "projectile_test", hostile);
int beforeExit = closedResources.get();
PartyCombatNativeTest.exit(f, a);
engine.sweep();
assertEquals(beforeExit + 1, closedResources.get(), "Session end cleans the projectile definition's owned resource");
engine.close();
assertEquals(beforeExit + 1, closedResources.get(), "Repeated engine cleanup cannot close owned state twice");
}
}
@Test void registeredTestDefinitionUsesRealBookPersistenceControlAndCastRoutes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_focus", "Test Focus", Material.AMETHYST_SHARD,
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String available = PartyBookNativeTest.text(a);
String compactAvailable = available.replaceAll("\\s+", "");
assertTrue(compactAvailable.contains("TestFocus"), available);
assertFalse(compactAvailable.contains("Icon:"), available);
assertFalse(compactAvailable.contains("Cost:"), available);
assertTrue(compactAvailable.contains("Cooldown:4s"), available);
var learn = PartyBookNativeTest.button(a, "[Learn]");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var learned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(java.util.Set.of("test_focus"), learned.learned());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints(), "Repeated stale book action cannot spend twice");
assertEquals("test_focus", learned.loadout().getFirst());
var control = a.core.inventory.getItem(2);
assertEquals(Material.AMETHYST_SHARD, control.getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(control).orElseThrow());
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, control,
null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
assertEquals(1, effects.get());
var cooled = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(cooled.cooldowns().get("test_focus").isAfter(java.time.Instant.now()));
var cooling = a.core.inventory.getItem(2);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, cooling.getType());
assertEquals(net.kyori.adventure.text.Component.text("Test Focus — Cooling Down"), cooling.getItemMeta().displayName());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry).ability(cooling).orElseThrow());
var repeated = new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
cooling, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(repeated);
assertEquals(org.bukkit.event.Event.Result.DENY, repeated.useItemInHand(), "The pane remains a contained control");
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "The persisted character deadline refuses an immediate repeated cast");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
a.core.inventory.setItemInOffHand(sheet); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(2);
assertEquals(Material.GRAY_STAINED_GLASS_PANE, a.core.inventory.getItem(2).getType());
assertEquals("test_focus", new games.dmg.fantasystick.items.SessionControls(registry)
.ability(a.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
AbilityDefinition.TargetPolicy targetPolicy, games.dmg.fantasystick.ability.AbilityEffect effect) {
return new AbilityDefinition(id, id, Material.AMETHYST_SHARD, List.of("Test definition"), "Use test input",
Map.of(), BigInteger.ONE, activation, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), targetPolicy, effect);
}
private static void prepare(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> loadout) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var learned = loadout.stream().filter(id -> !id.isEmpty()).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
old.stats(), old.level(), old.experience(), old.unspentPoints(), old.health(), learned, loadout,
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
private static void cast(games.dmg.fantasystick.ability.AbilityEngine engine,
FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int slot, String id,
org.bukkit.entity.Entity target) throws Exception {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
engine.accept(new ControlInput(actor.player, active, slot, id, ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, target, 0, 0, () -> sessions.current(active)));
PartyBookNativeTest.drain(fixture);
}
}
@@ -0,0 +1,90 @@
package games.dmg.fantasystick;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.ability.AbilityRegistry;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
class AbilityRegistryNativeTest {
@org.junit.jupiter.api.BeforeAll static void bootstrap() throws Exception {
games.dmg.fantasystick.NativeRuntime.bootstrap();
}
@Test void registersCompleteStableDefinitionsAndRejectsDuplicateIds() {
var registry = new AbilityRegistry();
var definition = definition("test_bolt");
registry.register(definition);
assertSame(definition, registry.find("test_bolt").orElseThrow());
assertEquals(List.of(definition), registry.definitions());
assertThrows(IllegalArgumentException.class, () -> registry.register(definition("test_bolt")));
assertTrue(registry.find("unknown_saved_id").isEmpty());
assertTrue(registry.find("basic_attack").isEmpty(), "The built-in stick is not a learned/refundable definition");
}
@Test void rejectsMalformedMetadataRequirementsAndNumericParametersBeforeRegistration() {
var valid = definition("valid");
assertAll(
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "UPPER", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "", valid.requirements(), valid.parameters(), valid.cooldown())),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("x", " ", Material.STONE,
List.of("Lore"), "Use", Map.of(Stat.INT, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ZERO,
Map.of(), AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {})),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "low", Map.of(Stat.INT, BigInteger.valueOf(9)), Map.of(), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "negative", valid.requirements(), Map.of("range", BigDecimal.valueOf(-1)), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "infinite", valid.requirements(), Map.of("range", new BigDecimal("1E1000000")), Duration.ZERO)),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "cooldown", valid.requirements(), Map.of(), Duration.ofSeconds(-1))),
() -> assertThrows(IllegalArgumentException.class, () -> copy(valid, "long_cooldown", valid.requirements(), Map.of(), Duration.ofDays(366))),
() -> assertThrows(IllegalArgumentException.class, () -> new AbilityDefinition("air", "Air", Material.AIR,
List.of("Lore"), "Use", Map.of(), BigInteger.ONE, AbilityDefinition.Activation.IMMEDIATE,
ControlInput.Gesture.USE, Duration.ZERO, Map.of(), AbilityDefinition.TargetPolicy.SELF, ignored -> {}))
);
}
@Test void effectReportsRequireActualPositiveFiniteEvidence() {
var character = java.util.UUID.randomUUID(); var session = java.util.UUID.randomUUID(); var target = java.util.UUID.randomUUID();
var now = java.time.Instant.parse("2026-09-19T20:00:00Z");
for (var type : games.dmg.fantasystick.ability.AbilityReport.Type.values()) {
assertDoesNotThrow(() -> new games.dmg.fantasystick.ability.AbilityReport(type, character, session, target,
BigDecimal.ONE, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, BigDecimal.ZERO, now));
assertThrows(IllegalArgumentException.class, () -> new games.dmg.fantasystick.ability.AbilityReport(type,
character, session, target, new BigDecimal("1E1000000"), now));
}
}
@Test void definitionsSupportEveryBoundedExecutionStyleAndTargetBoundary() {
for (var activation : AbilityDefinition.Activation.values()) {
for (var policy : AbilityDefinition.TargetPolicy.values()) {
var base = definition("x");
assertDoesNotThrow(() -> new AbilityDefinition("a_" + activation.name().toLowerCase() + "_" + policy.name().toLowerCase(),
base.name(), base.icon(), base.lore(), base.instructions(), base.requirements(), base.pointCost(), activation,
base.gesture(), base.cooldown(), base.parameters(), policy, base.effect()));
}
}
}
static AbilityDefinition definition(String id) {
return new AbilityDefinition(id, "Test Bolt", Material.AMETHYST_SHARD, List.of("A focused test effect"),
"Use the equipped control", Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12), "duration", BigDecimal.valueOf(5)),
AbilityDefinition.TargetPolicy.HOSTILE, ignored -> {});
}
private static AbilityDefinition copy(AbilityDefinition value, String id, Map<Stat, BigInteger> requirements,
Map<String, BigDecimal> parameters, Duration cooldown) {
return new AbilityDefinition(id, value.name(), value.icon(), value.lore(), value.instructions(), requirements,
value.pointCost(), value.activation(), value.gesture(), cooldown, parameters, value.targetPolicy(), value.effect());
}
}
@@ -0,0 +1,73 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.crafting.CharacterFactory;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.session.AdminExperienceGrant;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
class AdminExperienceGrantNativeTest {
@Test void acceptedPersistenceCannotRedirectFeedbackAfterCharacterSwitch() {
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
var current = new AtomicBoolean(true);
when(sessions.current(active)).thenAnswer(call -> current.get());
when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
var future = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TEN, rules)).thenReturn(future);
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
var playerLookups = new java.util.concurrent.atomic.AtomicInteger();
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add,
ignored -> { playerLookups.incrementAndGet(); return null; }, failure -> fail(failure.cause()));
assertTrue(grant.grant(active, captured, BigInteger.TEN, results::add));
current.set(false);
var saved = new CharacterRecord(captured.id(), captured.name(), BigInteger.ONE, captured.stats(), 1,
BigInteger.TEN, captured.unspentPoints(), captured.health(), captured.learned(), captured.loadout(),
captured.cooldowns(), captured.restrictedKills(), false, captured.deathId(), captured.diedAt());
future.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.APPLIED,
Optional.of(saved), 0));
assertEquals(1, tasks.size()); tasks.getFirst().run();
assertEquals(AdminExperienceGrant.Status.APPLIED, results.getFirst().status());
assertEquals(BigInteger.TEN, results.getFirst().applied());
verify(sessions, never()).acknowledgeProgress(any(), any(), any());
assertEquals(0, playerLookups.get(), "A replacement holder/session cannot receive captured feedback");
}
@Test void persistenceFailureReportsOnceAndClosedLifetimePublishesNoStaleCallback() {
var storage = mock(CharacterStorage.class); var sessions = mock(EquipmentSessions.class);
var rules = ProgressionRules.defaults(); CharacterRecord captured = new CharacterFactory().create(rules);
var active = new EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), captured.id());
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(Optional.of(captured));
var failed = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.ONE, rules)).thenReturn(failed);
var tasks = new ArrayList<Runnable>(); var results = new ArrayList<AdminExperienceGrant.Result>();
var failures = new ArrayList<AdminExperienceGrant.Failure>();
var grant = new AdminExperienceGrant(storage, rules, sessions, tasks::add, ignored -> null, failures::add);
assertTrue(grant.grant(active, captured, BigInteger.ONE, results::add));
failed.completeExceptionally(new java.io.IOException("save failed"));
assertEquals(1, failures.size()); assertEquals(1, tasks.size()); tasks.removeFirst().run();
assertEquals(List.of(AdminExperienceGrant.Status.FAILED), results.stream().map(AdminExperienceGrant.Result::status).toList());
var pending = new CompletableFuture<CharacterStorage.ExperienceAward>();
when(storage.awardExperience(captured.id(), captured.revision(), BigInteger.TWO, rules)).thenReturn(pending);
assertTrue(grant.grant(active, captured, BigInteger.TWO, results::add)); grant.close();
pending.complete(new CharacterStorage.ExperienceAward(CharacterStorage.ExperienceAwardStatus.AT_CAP,
Optional.of(captured), 0));
assertTrue(tasks.isEmpty()); assertEquals(1, results.size());
}
}
@@ -0,0 +1,212 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.math.BigInteger;
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 AdminExperienceNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredCommandResolvesExactActiveCharacterPersistsThenSynchronizesFeedbackAndBook() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
var storage = storage(f);
storage.renameCharacter(target.core.record.id(), "Mira 雪").get();
f.players.put(target.core.id, target.player); f.activate(target);
var sender = new EquipmentAdminCommandNativeTest.Sender();
var before = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(List.of("experience"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}));
assertEquals(List.of("give"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "g"}));
assertEquals(List.of("Mira"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mi"}));
assertEquals(List.of("雪"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", ""}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", ""}).isEmpty());
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Mira", "雪", "250"}));
storage.find(target.core.record.id()).get(); f.tick();
var saved = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(250), saved.experience());
assertEquals(3, saved.level());
assertEquals(before.unspentPoints().add(BigInteger.TWO), saved.unspentPoints());
assertEquals(before.stats(), saved.stats()); assertEquals(before.health(), saved.health());
assertEquals(before.learned(), saved.learned()); assertEquals(before.loadout(), saved.loadout());
assertEquals(before.cooldowns(), saved.cooldowns()); assertEquals(before.restrictedKills(), saved.restrictedKills());
assertTrue(sender.messages.getLast().contains("Mira 雪"));
assertTrue(sender.messages.getLast().contains("250")); assertTrue(sender.messages.getLast().contains("level 3"));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("250 fantasy XP")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(target.messages.stream().anyMatch(message -> message.contains("Level 3")));
assertEquals(2, target.sounds.size());
assertEquals(2, org.mockito.Mockito.mockingDetails(target.world).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("spawnParticle")).count());
PartyBookNativeTest.open(f, target);
String text = bookText(target.books.getLast());
assertTrue(text.contains("Level: 3"), text); assertTrue(text.contains("XP: 50/100"), text);
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(BigInteger.valueOf(250), storage(f).find(target.core.record.id()).get().orElseThrow().experience());
}
}
@Test void commandValidationAmbiguityExactCaseAndAutocompleteFailClosedWithoutMutation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var first = active(f, data, "Twin Hero"); var second = active(f, data, "Twin Hero");
var unicode = active(f, data, "Árvíztűrő Tükörfúrógép");
var inactive = new FantasyStickLifecycleNativeTest.Actor(data);
storage(f).renameCharacter(inactive.core.record.id(), "Hidden Hero").get();
var sender = new EquipmentAdminCommandNativeTest.Sender();
var firstBefore = storage(f).find(first.core.record.id()).get().orElseThrow();
var secondBefore = storage(f).find(second.core.record.id()).get().orElseThrow();
assertEquals(List.of("Twin"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Tw"}));
assertEquals(List.of("Hero"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Twin", "H"}));
assertEquals(List.of("Árvíztűrő"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Á"}));
assertEquals(List.of("Tükörfúrógép"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "T"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"experience", "give", "Hidden", ""}).isEmpty());
int queued = f.tasks.size();
for (var args : List.of(
new String[]{"experience", "give", "Twin", "Hero", "5"},
new String[]{"experience", "give", "twin", "Hero", "5"},
new String[]{"experience", "give", "Missing", "5"},
new String[]{"experience", "give", "Hidden", "Hero", "5"},
new String[]{"experience", "give", "Account", "5"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "0"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "-1"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "1.5"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5", "extra"},
new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép"},
new String[]{"experience", "extra", "Árvíztűrő", "Tükörfúrógép", "5"})) {
f.command.execute(sender.port, "fantasystick", args);
}
assertEquals(queued, f.tasks.size());
assertEquals(firstBefore, storage(f).find(first.core.record.id()).get().orElseThrow());
assertEquals(secondBefore, storage(f).find(second.core.record.id()).get().orElseThrow());
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("ambiguous") && message.contains("Twin Hero")));
assertTrue(sender.messages.stream().anyMatch(message -> message.contains("not active") && message.contains("Missing")));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"exp"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Árvíztűrő", "Tükörfúrógép", "5"});
assertTrue(sender.messages.getLast().contains("Permission denied"));
assertEquals(BigInteger.ZERO, storage(f).find(unicode.core.record.id()).get().orElseThrow().experience());
}
}
@Test void arbitraryPrecisionGrantClampsAtCapPreservesCombatHistoryAndNeverTouchesNativeXp() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data); var storage = storage(f);
var position = new games.dmg.fantasystick.progression.CombatRewardRules.KillPosition(java.util.UUID.randomUUID(),
java.math.BigDecimal.ONE, java.math.BigDecimal.TWO, java.math.BigDecimal.TEN);
var progressed = storage.awardCombatExperience(target.core.record.id(), target.core.record.revision(), BigInteger.TEN,
new games.dmg.fantasystick.progression.ProgressionRules(BigInteger.valueOf(40), BigInteger.valueOf(100), 30),
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), position).get().record().orElseThrow();
storage.renameCharacter(target.core.record.id(), "Cap Hero").get();
f.players.put(target.core.id, target.player); f.activate(target);
var sender = new EquipmentAdminCommandNativeTest.Sender(); String huge = "9".repeat(1000);
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", huge});
storage.find(target.core.record.id()).get(); f.tick();
var capped = storage.find(target.core.record.id()).get().orElseThrow();
assertEquals(30, capped.level()); assertEquals(BigInteger.valueOf(2900), capped.experience());
assertEquals(progressed.restrictedKills(), capped.restrictedKills());
assertTrue(sender.messages.getLast().contains("applied 2890"));
int feedback = target.messages.size(); int sounds = target.sounds.size();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Cap", "Hero", "1"});
storage.find(target.core.record.id()).get(); f.tick();
assertTrue(sender.messages.getLast().contains("Granted 0"));
assertEquals(feedback, target.messages.size()); assertEquals(sounds, target.sounds.size());
assertEquals(capped, storage.find(target.core.record.id()).get().orElseThrow());
}
}
@Test void revisionLogoutFailureAndRepeatedGrantRacesNeverRedirectDuplicateOrClaimFalseSuccess() throws Exception {
var staleDir = data.resolve("stale");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(staleDir)) {
f.plugin.setEnabled(true); var target = active(f, staleDir, "Before Rename"); var storage = storage(f);
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(storage, "worker");
var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1);
var gate = worker.submit(() -> { entered.countDown(); try { assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); }
catch (InterruptedException interrupted) { throw new AssertionError(interrupted); } });
var sender = new EquipmentAdminCommandNativeTest.Sender(); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Before", "Rename", "75"});
var old = new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).load(target.core.record.id()).orElseThrow();
var renamed = copy(old, "After Rename", old.revision().add(BigInteger.ONE));
new games.dmg.fantasystick.persistence.YamlCharacterRepository(staleDir).replace(renamed, old.revision());
release.countDown(); gate.get(); storage.find(old.id()).get(); f.tick();
assertTrue(sender.messages.getLast().contains("changed concurrently"));
assertEquals(BigInteger.ZERO, storage.find(old.id()).get().orElseThrow().experience());
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
}
var logoutDir = data.resolve("logout");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(logoutDir)) {
f.plugin.setEnabled(true); var target = active(f, logoutDir, "Departing Hero"); var storage = storage(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Departing", "Hero", "75"});
target.core.online = false;
storage.find(target.core.record.id()).get(); f.tick();
assertEquals(BigInteger.valueOf(75), storage.find(target.core.record.id()).get().orElseThrow().experience());
assertTrue(sender.messages.getLast().contains("applied 75"));
assertTrue(target.messages.stream().noneMatch(message -> message.contains("administrator")));
}
var repeatDir = data.resolve("repeat");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(repeatDir)) {
f.plugin.setEnabled(true); var target = active(f, repeatDir, "One Award"); var storage = storage(f);
var first = new EquipmentAdminCommandNativeTest.Sender(); var second = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(first.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
f.command.execute(second.port, "fantasystick", new String[]{"experience", "give", "One", "Award", "25"});
storage.find(target.core.record.id()).get(); f.tick(); f.tick();
assertEquals(BigInteger.valueOf(25), storage.find(target.core.record.id()).get().orElseThrow().experience());
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
.filter(message -> message.contains("applied 25")).count());
assertEquals(1, List.of(first.messages.getLast(), second.messages.getLast()).stream()
.filter(message -> message.contains("changed concurrently")).count());
}
}
@Test void realFilesystemSaveFailureReportsNoSuccessOrPlayerFeedback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = active(f, data, "Broken Save");
var sender = new EquipmentAdminCommandNativeTest.Sender();
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
java.nio.file.Files.delete(path); java.nio.file.Files.createDirectory(path);
int feedback = target.messages.size();
f.command.execute(sender.port, "fantasystick", new String[]{"experience", "give", "Broken", "Save", "20"});
f.tick();
assertTrue(sender.messages.getLast().contains("failed before acknowledgment"));
assertEquals(feedback, target.messages.size());
assertTrue(sender.messages.stream().noneMatch(message -> message.startsWith("Granted 20")));
}
}
private static CharacterRecord copy(CharacterRecord old, String name, BigInteger revision) {
return new CharacterRecord(old.id(), name, revision, old.stats(), old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt());
}
private static FantasyStickLifecycleNativeTest.Actor active(FantasyStickLifecycleNativeTest.Fixture f,
Path data, String name) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
storage(f).renameCharacter(actor.core.record.id(), name).get();
f.players.put(actor.core.id, actor.player); f.activate(actor); return actor;
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static String bookText(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw().getString()).collect(java.util.stream.Collectors.joining("\n"));
}
}
@@ -0,0 +1,28 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class AdminRevivalNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void commandCompletionConfigurationAndValidationArePermissionAwareAndDurable()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);assertEquals(java.util.List.of("revive"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"rev"}));assertEquals(java.util.List.of("Target"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Tar"}));assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Target",""}).isEmpty());doReturn(false).when(admin.player).hasPermission("fantasystick.admin");assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive",""}).isEmpty());doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.command.execute(admin.player,"fantasystick",new String[]{"revive"});assertTrue(admin.messages.getLast().contains("Usage"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Missing"});assertTrue(admin.messages.getLast().contains("target must name"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertEquals(java.util.List.of("admin-revive-selection-timeout-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}));assertEquals(java.util.List.of("60"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));for(String invalid:java.util.List.of("0","-1","1.5","bad")){f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",invalid});assertTrue(sender.messages.getLast().contains("invalid"));}var pendingDead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(pendingDead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1200L,f.delayedTicks.getLast());int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertEquals(before,sender.messages.size());var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("=90"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1800L,f.delayedTicks.getLast());assertTrue(f.delayedTicks.contains(1200L));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=90"));}
var failed=data.resolve("failed-write");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));}
for(String invalid:java.util.List.of("0","-1","1.5","bad")){var dir=data.resolve(invalid.replace('.','_'));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),"admin-revive-selection-timeout-seconds: '"+invalid+"'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
@Test void registeredAdminSelectsAnExpiredDirectTailAndRestoresSameCharacterWithoutChangingTargetState()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);f.activate(target);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);tail.setAmount(2);target.core.inventory.setItem(10,tail);var beforeHealth=target.core.health;var beforeMode=target.core.gameMode;var beforeLocation=target.core.location.clone();
assertTrue(f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"}));var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.find(dead.id()).get();f.tick();assertEquals(1,admin.inventories.size());assertTrue(target.inventories.isEmpty());var popup=admin.inventories.getFirst();assertEquals(1,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);var click=new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(click);assertTrue(click.isCancelled());PartyBookNativeTest.drain(f);((games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals")).find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var restored=storage.find(dead.id()).get().orElseThrow();assertFalse(restored.dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(dead.deathId(),new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow().deathId());assertEquals(restored.maximumHealth(),restored.health());assertEquals(dead.stats(),restored.stats());assertEquals(dead.level(),restored.level());assertEquals(dead.experience(),restored.experience());assertEquals(dead.unspentPoints(),restored.unspentPoints());assertEquals(dead.learned(),restored.learned());assertEquals(dead.loadout(),restored.loadout());assertEquals(dead.cooldowns(),restored.cooldowns());f.dispatcher.accept(click);assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(beforeHealth,target.core.health);assertEquals(beforeMode,target.core.gameMode);assertEquals(beforeLocation,target.core.location);assertEquals(target.core.record.id(),((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).orElseThrow().character());}}
@Test void failedCharacterRestoreRetainsOneSheetAndReservationWithoutFalseSuccess()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);var popup=admin.inventories.getFirst();var corrupted=new java.util.concurrent.atomic.AtomicBoolean();target.core.mutation=()->{if(!corrupted.compareAndSet(false,true))return;try{var path=data.resolve("characters").resolve(dead.id()+".yml");java.nio.file.Files.delete(path);java.nio.file.Files.createDirectory(path);}catch(Exception e){throw new RuntimeException(e);}};var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertTrue(revival.find(dead.id()).join().isPresent());assertFalse(admin.messages.stream().anyMatch(message->message.startsWith("Administratively restored")));target.core.mutation=()->{};}}
@Test void competingAdministratorsCanConsumeOneDeathOnlyOnce()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("AdminA").when(a.player).getName();doReturn("AdminB").when(b.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(a.player).hasPermission("fantasystick.admin");doReturn(true).when(b.player).hasPermission("fantasystick.admin");f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(a.player,"fantasystick",new String[]{"revive","Target"});f.command.execute(b.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);assertEquals(1,a.inventories.size());assertEquals(1,b.inventories.size());for(var admin:java.util.List.of(a,b)){var popup=admin.inventories.getFirst();var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));}PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());}}
@Test void counterfeitLivingAndLifecycleCancelledSelectionsNeverChangeItems()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);target.core.inventory.setItem(10,new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITTEN_BOOK));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});assertTrue(admin.messages.getLast().contains("no authentic"));var livingTailRecord=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var livingTail=new games.dmg.fantasystick.items.TailBooks().create(livingTailRecord);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.restoreDeathRecordAdministratively(livingTailRecord.id(),livingTailRecord.deathId(),new java.math.BigDecimal("1024")).get();target.core.inventory.setItem(10,livingTail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(livingTailRecord.id()).get();f.tick();assertTrue(admin.messages.getLast().contains("no current"));var dead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);target.core.inventory.setItem(10,tail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(dead.id()).get();f.tick();var popup=admin.inventories.getFirst();f.plugin.setEnabled(false);assertNull(popup.getItem(0));assertEquals(tail,target.core.inventory.getItem(10));}}
@Test void administrativeReservationResumesAfterRestartWithoutWeakeningDeathIdentity()throws Exception{var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Target").when(target.player).getName();var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var provisional=new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());target.core.inventory.setItem(10,new games.dmg.fantasystick.items.CharacterSheets().create(provisional));var reservation=new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),target.core.id,dead.id(),dead.deathId(),Instant.now().truncatedTo(ChronoUnit.SECONDS),10,1,-1,true);try(var store=new games.dmg.fantasystick.persistence.RevivalStore(data)){assertTrue(store.reserve(reservation).join().isPresent());}try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.players.put(target.core.id,target.player);f.plugin.setEnabled(true);var revivals=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revivals.target(target.core.id).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());assertTrue(revivals.find(dead.id()).join().isEmpty());assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());}}
@Test void multipleChoicesSelectExactItemWhilePermissionLossMovementAndTimeoutCancelWithoutMutation()throws Exception{for(String route:java.util.List.of("select-recent","select-second","permission","movement","timeout")){var dir=data.resolve(route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(dir);var target=new FantasyStickLifecycleNativeTest.Actor(dir);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var first=dead(dir,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var second=dead(dir,Instant.now().minusSeconds(86400).truncatedTo(ChronoUnit.SECONDS));var firstTail=new games.dmg.fantasystick.items.TailBooks().create(first);var secondTail=new games.dmg.fantasystick.items.TailBooks().create(second);target.core.inventory.setItem(10,firstTail);target.core.inventory.setItem(11,secondTail);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(first.id()).get();f.tick();var popup=admin.inventories.getFirst();assertEquals(2,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());assertTrue(f.delayedTicks.contains(1200L));if(route.equals("timeout")){f.delayed.getLast().run();assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(secondTail,target.core.inventory.getItem(11));continue;}var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);if(route.equals("permission"))doReturn(false).when(admin.player).hasPermission("fantasystick.admin");if(route.equals("movement"))target.core.inventory.setItem(11,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));int selected=route.equals("select-recent")?0:1;f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,selected,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);if(route.startsWith("select-")){var selectedRecord=route.equals("select-recent")?first:second;var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(selectedRecord.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(!route.equals("select-recent"),storage.find(first.id()).get().orElseThrow().dead());assertEquals(route.equals("select-recent"),storage.find(second.id()).get().orElseThrow().dead());if(route.equals("select-recent")){assertEquals(first.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertEquals(secondTail,target.core.inventory.getItem(11));}else{assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(second.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(11)).orElseThrow());}}else{assertTrue(storage.find(first.id()).get().orElseThrow().dead());assertTrue(storage.find(second.id()).get().orElseThrow().dead());assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(route.equals("movement")?org.bukkit.Material.DIAMOND:org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItem(11).getType());}}}}
static games.dmg.fantasystick.persistence.CharacterRecord dead(Path data,Instant died)throws Exception{var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var record=new games.dmg.fantasystick.persistence.CharacterRecord(base.id(),"Ancient Mira",base.revision(),base.stats(),7,BigInteger.valueOf(642),BigInteger.valueOf(11),java.math.BigDecimal.ZERO,java.util.Set.of("fireball"),java.util.List.of("fireball","","","","","",""),java.util.Map.of("fireball",Instant.now().plusSeconds(30)),base.restrictedKills(),true,java.util.UUID.randomUUID(),died);new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);return record;}
}
@@ -0,0 +1,114 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class BasicAttackNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void successfulHitsStartAnExactOneSecondCadenceButCancelledDamageDoesNot() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var clock = new MutableClock(Instant.parse("2026-09-19T00:00:00Z"));
var basic = new games.dmg.fantasystick.combat.BasicAttack(sessions, clock);
var target = mock(org.bukkit.entity.LivingEntity.class);
var cancelled = new java.util.concurrent.atomic.AtomicBoolean(true);
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
event.setCancelled(cancelled.get());
basic.acceptedDamage(event);
return null;
}).when(target).damage(anyDouble(), same(a.player));
var input = new games.dmg.fantasystick.session.ControlInput(a.player, active, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1,
() -> sessions.current(active));
basic.accept(input); // Cancelled: no cadence spent.
cancelled.set(false);
basic.accept(input); // Accepted at t=0.
clock.advance(java.time.Duration.ofMillis(999)); basic.accept(input);
verify(target, times(2)).damage(1.0, a.player);
clock.advance(java.time.Duration.ofMillis(1)); basic.accept(input);
verify(target, times(3)).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a);
clock.advance(java.time.Duration.ofSeconds(1)); basic.accept(input);
verifyNoMoreInteractions(target);
}
}
@Test void partyCancellationPreventsDamageAndDoesNotSpendThePackagedAttackCadence() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var parties = PartyLifecycleNativeTest.parties(f);
var party = parties.create(a.core.id).orElseThrow();
assertTrue(parties.accept(b.core.id, parties.invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var events = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0));
events.add(event); f.dispatcher.accept(event); return null;
}).when(b.player).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(1, events.size()); assertTrue(events.getFirst().isCancelled());
assertTrue(parties.leave(a.core.id, party.id()));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size()); assertFalse(events.getLast().isCancelled());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
assertEquals(2, events.size(), "The accepted second hit starts cadence; the cancelled first hit did not");
}
}
@Test void packagedBasicStickInputDealsOneLowStrengthHitWithoutVanillaContinuation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The icon stick must never continue into vanilla damage");
verify(target).damage(1.0, a.player);
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.Material.DIAMOND_SWORD, a.core.inventory.getItem(0).getType());
assertTrue(a.core.inventory.getItem(1) == null || a.core.inventory.getItem(1).isEmpty(),
"Exit restores the ordinary item that occupied the basic-stick slot");
}
}
private static final class MutableClock extends Clock {
private Instant now;
MutableClock(Instant now) { this.now = now; }
void advance(java.time.Duration duration) { now = now.plus(duration); }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
@Override public Instant instant() { return now; }
}
}
@@ -0,0 +1,239 @@
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.ability.*;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.bukkit.Location;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChainLightningNativeTest {
@TempDir Path data;
@BeforeAll static void nativeRuntime() throws Exception { NativeRuntime.bootstrap(); }
@Test void characterBookPurchaseAssignmentAndEquippedControlExecuteNativeChain() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "chain-lightning-cooldown-seconds: 0\n");
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true); var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, false);
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
String text = PartyBookNativeTest.text(caster).replaceAll("\\s+", "");
assertTrue(text.contains("ChainLightning")); assertTrue(text.contains("INT20")); assertTrue(text.contains("DEX12"));
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "chain_lightning")); drain(fixture, 2);
var first = mock(LivingEntity.class); var second = mock(LivingEntity.class);
locate(first, caster.world, UUID.randomUUID(), 10); locate(second, caster.world, UUID.randomUUID(), 14);
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
doReturn(true).when(caster.player).hasLineOfSight(first);
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
doReturn(List.of(second)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble()); doReturn(true).when(first).hasLineOfSight(second);
var damage = new LinkedHashMap<UUID, Double>(); nativeDamage(fixture, first, damage); nativeDamage(fixture, second, damage);
use(fixture, caster); drain(fixture, 2);
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);
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"));
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
}
}
@Test void exactChainUsesNearestUuidTiesFourTargetLimitDecayAndCancellationContinuation() {
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));
var targets = new ArrayList<LivingEntity>();
for (int i = 0; i < 5; i++) { var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(new UUID(0, i + 1));
when(target.getLocation()).thenReturn(new Location(world, 4 + i * 2, 64, 0)); when(target.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true); targets.add(target); }
when(caster.hasLineOfSight(targets.getFirst())).thenReturn(true);
when(targets.get(0).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(2), targets.get(1), targets.get(1)));
when(targets.get(1).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(3), targets.get(2)));
when(targets.get(2).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4), targets.get(3)));
when(targets.get(3).getNearbyEntities(6, 6, 6)).thenReturn(List.of(targets.get(4)));
var active = new EquipmentSessions.Active(casterId, UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, targets.getFirst()); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition());
var attempted = new LinkedHashMap<UUID, Double>();
for (int i = 0; i < targets.size(); i++) { final int index = i; var target = targets.get(i);
doAnswer(call -> { double amount = call.getArgument(0); attempted.put(target.getUniqueId(), amount);
if (index != 1) chain.acceptedDamage(new EntityDamageEvent(target, EntityDamageEvent.DamageCause.LIGHTNING,
(DamageSource) call.getArgument(1), amount)); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class)); }
chain.activate(execution);
assertEquals(List.of(targets.get(0).getUniqueId(), targets.get(1).getUniqueId(), targets.get(2).getUniqueId(), targets.get(3).getUniqueId()),
List.copyOf(attempted.keySet()));
assertArrayEquals(new double[]{13, 9.75, 7.3125, 5.484375}, attempted.values().stream().mapToDouble(Double::doubleValue).toArray(), 1.0e-10);
verify(targets.get(4), never()).damage(anyDouble(), any(DamageSource.class));
verify(execution, times(3)).report(eq(AbilityReport.Type.DAMAGE), any(), any());
}
@Test void boundsVisibilityExclusionsAndEligibilityRaceStopInvalidStrikes() {
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 initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 16); when(caster.hasLineOfSight(initial)).thenReturn(true);
var blocked = mock(LivingEntity.class); locate(blocked, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(blocked)).thenReturn(false);
var outside = mock(LivingEntity.class); locate(outside, world, UUID.randomUUID(), 22.01); when(initial.hasLineOfSight(outside)).thenReturn(true);
var ordinary = mock(Player.class); locate(ordinary, world, UUID.randomUUID(), 18); when(initial.hasLineOfSight(ordinary)).thenReturn(true);
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(List.of(blocked, outside, ordinary));
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(blocked, never()).damage(anyDouble(), any(DamageSource.class));
verify(outside, never()).damage(anyDouble(), any(DamageSource.class)); verify(ordinary, never()).damage(anyDouble(), any(DamageSource.class));
when(execution.stillCurrent()).thenReturn(false); chain.activate(execution); verify(initial, times(1)).damage(anyDouble(), any(DamageSource.class));
}
@Test void jumpInspectionStopsAtSharedCandidateCeiling() {
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 initial = mock(LivingEntity.class); locate(initial, world, UUID.randomUUID(), 10); when(caster.hasLineOfSight(initial)).thenReturn(true);
when(initial.hasLineOfSight(any(org.bukkit.entity.Entity.class))).thenReturn(true);
var candidates = new ArrayList<org.bukkit.entity.Entity>(); LivingEntity beyond = null;
for (int index = 0; index < 257; index++) {
var target = mock(LivingEntity.class); locate(target, world, new UUID(1, index + 1), index == 256 ? 10.1 : 15);
candidates.add(target); if (index == 256) beyond = target;
}
when(initial.getNearbyEntities(6, 6, 6)).thenReturn(candidates);
var active = new EquipmentSessions.Active(caster.getUniqueId(), UUID.randomUUID(), UUID.randomUUID());
var execution = execution(caster, active, initial); var chain = new ChainLightning(ChainLightning.Settings.defaults(), sessions, parties);
when(execution.definition()).thenReturn(chain.definition()); chain.activate(execution);
verify(initial).damage(anyDouble(), any(DamageSource.class)); verify(beyond, never()).damage(anyDouble(), any(DamageSource.class));
}
@Test void definitionFormulaAndArbitraryPrecisionAreExact() {
var chain = new ChainLightning(ChainLightning.Settings.defaults(), mock(EquipmentSessions.class), mock(PartyRoster.class));
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));
}
@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");
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"));
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"));
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(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"),
fixture.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "chain-lightning-"}));
assertEquals(List.of("5", "6"), fixture.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "chain-lightning-jump-range-blocks", ""}));
fixture.command.execute(sender.port, "fantasystick", 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]});
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"));
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"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", 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(java.time.Duration.ofSeconds(3), saved.cooldown());
}
}
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
doAnswer(call -> { double amount = call.getArgument(0); DamageSource source = call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getCausingEntity(), target,
EntityDamageEvent.DamageCause.LIGHTNING, source, amount);
fixture.dispatcher.accept(event); if (!event.isCancelled()) damage.put(target.getUniqueId(), event.getFinalDamage()); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture fixture, int count) throws Exception {
for (int index = 0; index < count; index++) PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom button(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
var book = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(book);
for (var page : book.pages()) { var found = button(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom button(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& 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 {
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.INT, BigInteger.valueOf(20)); stats.put(Stat.DEX, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(learned ? 39 : 40), old.health(),
learned ? Set.of("chain_lightning") : old.learned(), learned ? List.of("chain_lightning", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static AbilityExecution execution(Player source, EquipmentSessions.Active active, LivingEntity target) {
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
when(input.player()).thenReturn(source); when(input.session()).thenReturn(active); when(input.target()).thenReturn(target);
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
var record = new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(record.stats()); stats.put(Stat.INT, BigInteger.valueOf(20));
when(execution.character()).thenReturn(new CharacterRecord(active.character(), record.name(), record.revision(), stats,
record.level(), record.experience(), record.unspentPoints(), record.health(), Set.of("chain_lightning"),
List.of("chain_lightning", "", "", "", "", "", ""), record.cooldowns(), record.restrictedKills(), false, null, null));
return execution;
}
private static void locate(LivingEntity entity, org.bukkit.World world, UUID id, double x) {
when(entity.getUniqueId()).thenReturn(id); when(entity.getLocation()).thenReturn(new Location(world, x, 64, 0));
}
}
@@ -72,11 +72,108 @@ class CharacterBookNativeTest {
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
"Revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void bookPurchasePersistsConAndPointsAndAppliesTheAcknowledgedNativeHealthGain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var purchase = PartyBookNativeTest.button(a, "[+1 CON]");
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
assertEquals(11, a.maximumHealth.getValue());
assertEquals(6, a.player.getHealth());
assertTrue(PartyBookNativeTest.text(a).contains("CON: 1"));
assertTrue(PartyBookNativeTest.text(a).contains("Unspent points: 39"));
PartyBookNativeTest.click(f, a, purchase);
PartyBookNativeTest.drain(f);
assertEquals(saved, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(),
"A rendered purchase action is single-use");
}
}
@Test void conPurchaseKeepsItsOwnMaximumAndOneTimeGainDespiteExternalModifiers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
var before = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "before_purchase"), 5,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addTransientModifier(before);
f.activate(a); assertEquals(10, a.maximumHealth.getValue());
var during = new net.minecraft.world.entity.ai.attributes.AttributeModifier(
net.minecraft.resources.Identifier.fromNamespaceAndPath("test", "during_purchase"), 100,
net.minecraft.world.entity.ai.attributes.AttributeModifier.Operation.ADD_VALUE);
a.maximumHealth.addPermanentModifier(during);
assertEquals(10, a.maximumHealth.getValue());
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 CON]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(11, a.maximumHealth.getValue()); assertEquals(6, a.player.getHealth());
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
assertEquals(java.util.Set.of(before, during), a.maximumHealth.getModifiers());
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(0, saved.health().compareTo(java.math.BigDecimal.valueOf(6)));
}
}
@Test void failedPurchaseChangesNothingAndAcknowledgedStatsSurviveReopenTransferAndReload() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
a.player.setHealth(5);
PartyBookNativeTest.open(f, a);
var rejected = PartyBookNativeTest.button(a, "[+1 STR]");
var before = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"), java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
PartyBookNativeTest.click(f, a, rejected);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
assertEquals(5, a.player.getHealth()); assertEquals(10, a.maximumHealth.getValue());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
PartyBookNativeTest.click(f, a, rejected); PartyBookNativeTest.drain(f);
assertEquals(before, characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow());
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 STR]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.ONE, saved.stats().get(games.dmg.fantasystick.progression.Stat.STR));
assertEquals(java.math.BigInteger.valueOf(39), saved.unspentPoints());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertTrue(PartyBookNativeTest.text(b).contains("Unspent points: 39"));
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true); f.players.put(b.core.id, b.player); f.reenter(b);
PartyBookNativeTest.open(f, b);
assertTrue(PartyBookNativeTest.text(b).contains("STR: 1"));
assertEquals(saved.stats(), characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow().stats());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -121,7 +218,7 @@ class CharacterBookNativeTest {
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
stats, old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
@@ -166,7 +263,7 @@ class CharacterBookNativeTest {
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
runQueued(f); assertTrue(a.books.isEmpty());
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
@@ -210,6 +307,123 @@ class CharacterBookNativeTest {
}
}
@Test void linkedBookOrdersOverviewActiveIndexAndConciseAbilityDetailsWithAutomaticAssignment() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.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));
var eligible = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(eligible, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
var pages = nativePages(a);
assertTrue(pages.size() <= 100, "Keep each native linked-book view bounded");
assertTrue(pages.get(0).getString().contains("STR: 0 [+1 STR]"));
assertTrue(pages.get(0).getString().contains("INT: 12 [+1 INT]"));
assertTrue(pages.get(1).getString().startsWith("Active abilities"));
assertTrue(pages.get(1).getString().contains("3: No Ability"));
int index = firstPageStarting(pages, "Ability index");
assertTrue(index > 1);
var indexedNames = new java.util.ArrayList<String>();
int partyPage = firstPageStarting(pages, "Party");
for (int page = index; page < pages.size() && pages.get(page).getString().startsWith("Ability index"); page++) {
for (String line : pages.get(page).getString().split("\\n")) if (!line.isBlank() && !line.equals("Ability index")) {
indexedNames.add(line);
var jump = changePage(pages.get(page), line);
assertNotNull(jump, line);
assertTrue(jump.page() - 1 < partyPage, "Ability details must precede party controls");
}
}
var sortedNames = indexedNames.stream().sorted().toList();
assertEquals(sortedNames, indexedNames);
assertEquals(20, indexedNames.size());
var fireballJump = changePage(pages.get(index), "Fireball");
assertNotNull(fireballJump);
var detail = pages.get(fireballJump.page() - 1);
assertTrue(detail.getString().startsWith("Fireball\nINT 12\n[Learn]"), detail.getString());
assertFalse(text(a.books.getLast()).contains("Cost:"));
assertFalse(text(a.books.getLast()).contains("Requires:"));
assertTrue(text(a.books.getLast()).contains("Shield Bash\nSTR 12, CON 12"));
assertFalse(detail.getString().contains("Icon:"));
assertFalse(text(a.books.getLast()).contains("Assign ability"));
assertFalse(text(a.books.getLast()).contains("[3]"));
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals("fireball", saved.loadout().get(0));
var refreshed = nativePages(a);
assertTrue(refreshed.get(1).getString().contains("3: Fireball"));
var activeJump = changePage(refreshed.get(1), "Fireball");
assertNotNull(activeJump);
assertTrue(refreshed.get(activeJump.page() - 1).getString().startsWith("Fireball\nINT 12\n[Unlearn]"));
}
}
@Test void unlearnAndRelearnReordersIntoLowestOpenSlotsWhileFullLoadoutsRefuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) stats.put(stat, java.math.BigInteger.valueOf(100));
var prepared = 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.TEN, old.health(),
java.util.Set.of("unknown_saved", "orphan_unknown"), java.util.List.of("unknown_saved", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(prepared, old.revision());
f.players.put(a.core.id, a.player); f.activate(a); PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).contains("orphan_unknown"));
mutate(f, a, "[Learn]", "fireball");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Unlearn]", "fireball");
mutate(f, a, "[Unlearn]", "frost_shard");
mutate(f, a, "[Learn]", "frost_shard");
mutate(f, a, "[Learn]", "fireball");
var reordered = characters(f).find(a.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(java.util.List.of("unknown_saved", "frost_shard", "fireball", "", "", "", ""), reordered.loadout());
assertTrue(reordered.learned().contains("orphan_unknown"));
var fullActor = new FantasyStickLifecycleNativeTest.Actor(data);
var fullOld = repository.load(fullActor.core.record.id()).orElseThrow();
var fullStats = new java.util.EnumMap<>(fullOld.stats());
for (var stat : games.dmg.fantasystick.progression.Stat.values()) fullStats.put(stat, java.math.BigInteger.valueOf(100));
var ids = java.util.List.of("unknown_0", "unknown_1", "unknown_2", "unknown_3", "unknown_4", "unknown_5", "unknown_6");
var full = new games.dmg.fantasystick.persistence.CharacterRecord(fullOld.id(), fullOld.name(), fullOld.revision().add(java.math.BigInteger.ONE),
fullStats, fullOld.level(), fullOld.experience(), java.math.BigInteger.TEN, fullOld.health(), java.util.Set.copyOf(ids), ids,
fullOld.cooldowns(), fullOld.restrictedKills(), false, null, null);
repository.replace(full, fullOld.revision());
f.players.put(fullActor.core.id, fullActor.player); f.activate(fullActor); PartyBookNativeTest.open(f, fullActor);
assertTrue(PartyBookNativeTest.text(fullActor).contains("Unavailable: slots"));
assertNull(abilityButtonOrNull(fullActor, "[Learn]", "fireball"));
assertEquals(full, characters(f).find(full.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void overviewHidesEveryStatActionWhenNoPointsRemain() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow();
var noPoints = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
old.stats(), old.level(), old.experience(), java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(noPoints, old.revision());
f.players.put(a.core.id, a.player); f.activate(a);
PartyBookNativeTest.open(f, a);
assertFalse(nativePages(a).get(0).getString().contains("[+1"));
}
}
@Test void bookAdapterIgnoresOtherControlsAndNativeItemVetoes() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -224,6 +438,51 @@ class CharacterBookNativeTest {
}
}
private static void mutate(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) throws Exception {
PartyBookNativeTest.click(fixture, actor, abilityButtonOrNull(actor, label, ability));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
FantasyStickLifecycleNativeTest.Actor actor, String label, String ability) {
for (var page : nativePages(actor)) {
var found = abilityButtonOrNull(page, label, ability);
if (found != null) return found;
}
return null;
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButtonOrNull(
net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.Custom custom
&& custom.payload().orElse(null) instanceof net.minecraft.nbt.StringTag payload
&& payload.value().contains(":" + ability)) return custom;
for (var child : component.getSiblings()) {
var found = abilityButtonOrNull(child, label, ability);
if (found != null) return found;
}
return null;
}
private static java.util.List<net.minecraft.network.chat.Component> nativePages(FantasyStickLifecycleNativeTest.Actor actor) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
return content.pages().stream().map(page -> page.raw()).toList();
}
private static int firstPageStarting(java.util.List<net.minecraft.network.chat.Component> pages, String prefix) {
for (int index = 0; index < pages.size(); index++) if (pages.get(index).getString().startsWith(prefix)) return index;
throw new AssertionError("Missing page " + prefix);
}
private static net.minecraft.network.chat.ClickEvent.ChangePage changePage(net.minecraft.network.chat.Component component,
String label) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof net.minecraft.network.chat.ClickEvent.ChangePage page) return page;
for (var child : component.getSiblings()) {
var found = changePage(child, label);
if (found != null) return found;
}
return null;
}
private static String text(org.bukkit.inventory.ItemStack book) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(book)
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
@@ -0,0 +1,154 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.items.CharacterSheets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.inventory.CraftItemStack;
import org.bukkit.craftbukkit.inventory.CraftRecipe;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.PluginManager;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Pinned Purpur recipe-book persistence and placement without starting a listening server/client. */
class CharacterSheetRecipeBookNativeTest {
private static Server previousServer;
private static Server server;
@BeforeAll static void bootstrap() throws Exception {
NativeRuntime.bootstrap(); previousServer = Bukkit.getServer(); server = mock(Server.class); setServer(server);
}
@BeforeEach void configure() {
reset(server);
when(server.getItemFactory()).thenAnswer(call -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.isPrimaryThread()).thenReturn(true);
}
@AfterAll static void restore() throws Exception { setServer(previousServer); }
private static void setServer(Server value) throws Exception {
var field = Bukkit.class.getDeclaredField("server"); field.setAccessible(true); field.set(null, value);
}
@Test void nativeBookAwardsOneMiscHighlightAndPreservesItWithUnrelatedState() {
var bukkit = new CharacterSheets().recipe(); var nativeRecipe = nativeRecipe(bukkit); var key = key();
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe);
var events = new ArrayList<org.bukkit.event.Event>(); var manager = mock(PluginManager.class);
doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(manager).callEvent(any());
when(server.getPluginManager()).thenReturn(manager);
var book = new Book((requested, consumer) -> consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
new net.minecraft.world.item.crafting.display.RecipeDisplayId(7), nativeRecipe.display().getFirst(),
OptionalInt.empty(), nativeRecipe.recipeBookCategory(), Optional.of(nativeRecipe.placementInfo().ingredients()))));
var player = mock(net.minecraft.server.level.ServerPlayer.class);
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
assertEquals(1, book.addRecipes(List.of(holder), player));
assertEquals(0, book.addRecipes(List.of(holder), player));
assertTrue(book.contains(key));
assertEquals(List.of(key), book.pack().highlight());
assertEquals(net.minecraft.world.item.crafting.CraftingBookCategory.MISC, nativeRecipe.category());
assertEquals(1, events.size());
var event = assertInstanceOf(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class, events.getFirst());
assertTrue(event.shouldShowNotification());
var unrelated = nativeKey(new org.bukkit.NamespacedKey("minecraft", "stick"));
book.add(unrelated); book.highlight(unrelated);
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
var tag = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
var packed = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, tag).getOrThrow();
var restored = new Book(); restored.loadUntrusted(packed, ignored -> true);
assertTrue(restored.contains(key)); assertTrue(restored.contains(unrelated));
assertEquals(new LinkedHashSet<>(packed.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
int known = restored.pack().known().size(); int highlighted = restored.pack().highlight().size();
restored.add(key);
assertEquals(known, restored.pack().known().size()); assertEquals(highlighted, restored.pack().highlight().size());
}
@Test void nativeAutofillPlacesExactIngredientsAndPreviewCannotMintACharacter() {
var sheets = new CharacterSheets(); var bukkit = sheets.recipe(); var nativeRecipe = nativeRecipe(bukkit);
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key(), nativeRecipe);
var player = mock(net.minecraft.world.entity.player.Player.class);
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
var inventory = new net.minecraft.world.entity.player.Inventory(player, new net.minecraft.world.entity.EntityEquipment());
when(player.getInventory()).thenReturn(inventory);
var ingredients = List.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
for (int index = 0; index < ingredients.size(); index++) inventory.setItem(index,
CraftItemStack.asNMSCopy(new ItemStack(ingredients.get(index))));
var unrelated = new ItemStack(Material.DIAMOND, 7); inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, player);
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
var slots = java.util.stream.IntStream.range(0, 9)
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0)).toList();
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<net.minecraft.world.item.crafting.ShapelessRecipe>() {
@Override public void fillCraftSlotsStackedContents(net.minecraft.world.entity.player.StackedItemContents contents) {
grid.fillStackedContents(contents);
}
@Override public void clearCraftingContent() { grid.clearContent(); }
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<net.minecraft.world.item.crafting.ShapelessRecipe> candidate) {
return candidate.value().matches(input(grid), mock(net.minecraft.world.level.Level.class));
}
};
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
access, 3, 3, slots, slots, inventory, holder, false, false);
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
assertTrue(nativeRecipe.matches(input(grid), mock(net.minecraft.world.level.Level.class)));
for (var material : ingredients) assertEquals(1, count(grid, material));
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
assertEquals(3, inventory.getNonEquipmentItems().stream().map(CraftItemStack::asBukkitCopy)
.filter(item -> item.getType() == Material.DIRT).mapToInt(ItemStack::getAmount).sum());
var before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
var preview = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input(grid)));
assertEquals(Material.PAPER, preview.getType()); assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
}
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(org.bukkit.inventory.ShapelessRecipe recipe) {
var ingredients = recipe.getChoiceList().stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
return new net.minecraft.world.item.crafting.ShapelessRecipe(
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key() {
return nativeKey(CharacterSheets.RECIPE);
}
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(org.bukkit.NamespacedKey key) {
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
}
private static net.minecraft.world.item.crafting.CraftingInput input(net.minecraft.world.Container grid) {
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem).toList());
}
private static long count(net.minecraft.world.Container grid, Material material) {
return java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem)
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material).mapToInt(ItemStack::getAmount).sum();
}
private static final class Book extends net.minecraft.stats.ServerRecipeBook {
Book() { this((key, consumer) -> { }); }
Book(DisplayResolver resolver) { super(resolver); }
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) { highlight.add(key); }
}
}
@@ -46,12 +46,16 @@ class CharacterSheetsNativeTest {
meta.displayName(Component.text("Different presentation"));
sheet.setItemMeta(meta);
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(CharacterSheets.MODEL, sheet.getItemMeta().getItemModel());
var legacy = sheet.clone(); var legacyMeta = legacy.getItemMeta(); legacyMeta.setItemModel(null); legacy.setItemMeta(legacyMeta);
assertTrue(sheets.applyModel(legacy)); assertEquals(CharacterSheets.MODEL, legacy.getItemMeta().getItemModel());
var fake = new ItemStack(Material.PAPER);
var fakeMeta = fake.getItemMeta();
fakeMeta.displayName(meta.displayName());
fakeMeta.lore(meta.lore());
fake.setItemMeta(fakeMeta);
assertTrue(sheets.identify(fake).isEmpty());
assertFalse(sheets.applyModel(fake)); assertFalse(fake.getItemMeta().hasItemModel());
assertTrue(sheets.identify(new ItemStack(Material.STICK)).isEmpty());
assertTrue(sheets.identify(null).isEmpty());
var version = new NamespacedKey("fantasystick", "sheet_version");
@@ -72,6 +76,7 @@ class CharacterSheetsNativeTest {
var sheets = new CharacterSheets();
var recipe = sheets.recipe();
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(4, recipe.getChoiceList().size());
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
@@ -84,6 +89,7 @@ class CharacterSheetsNativeTest {
assertEquals(1, preview.getAmount());
assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertFalse(preview.getItemMeta().hasItemModel());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), preview.getItemMeta().lore());
}
@@ -0,0 +1,263 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.EntityType;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CombatExperienceNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void packagedBasicAttackToBossDeathPersistsLevelAndUpdatesBookFeedbackExactlyOnce() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
assertTrue(f.order.contains("CombatExperience"));
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var world = mock(org.bukkit.World.class); var worldId = UUID.randomUUID(); when(world.getUID()).thenReturn(worldId);
var target = mob(EntityType.WARDEN, new Location(world, 10, 65, -4));
doAnswer(call -> {
var source = mock(org.bukkit.damage.DamageSource.class);
when(source.getCausingEntity()).thenReturn(a.player);
when(source.getDirectEntity()).thenReturn(a.player);
var event = new EntityDamageByEntityEvent(a.player, target, DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0));
f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), same(a.player));
a.core.inventory.getInventory().setSelectedSlot(1);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled());
var source = mock(org.bukkit.damage.DamageSource.class);
var death = new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17);
f.dispatcher.accept(death);
PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
PartyBookNativeTest.drain(f); // Deliver the post-commit main-thread acknowledgment and feedback.
assertEquals(java.math.BigInteger.valueOf(100), saved.experience());
assertEquals(2, saved.level());
assertEquals(java.math.BigInteger.valueOf(41), saved.unspentPoints());
assertEquals(17, death.getDroppedExp(), "Fantasy rewards never replace native Minecraft XP");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("100") && message.contains("XP")));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level 2")));
assertTrue(mockingDetails(a.world).getInvocations().stream().anyMatch(invocation ->
invocation.getMethod().getName().equals("spawnParticle")
&& java.util.Arrays.asList(invocation.getArguments()).contains(12)));
assertFalse(a.sounds.isEmpty());
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Level: 2")); assertTrue(book.contains("XP: 0/100"));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, source, new ArrayList<>(), 17));
PartyBookNativeTest.drain(f);
assertEquals(saved, storage.find(a.core.record.id()).get().orElseThrow(), "One defeated target awards only once");
}
}
@Test void sharingProjectileExitPassiveExemptionAndPerCharacterRestrictedSpacingUseNativeEvents() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
var c = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.players.put(c.core.id, c.player);
f.activate(a); f.activate(b); f.activate(c);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var first = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, first, false);
die(f, first); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience());
assertEquals(java.math.BigInteger.ZERO, record(f, b).experience());
var near = mob(EntityType.BEE, new Location(world, 2, 64, 0)); // Neutral, regardless of aggression.
hit(f, a.player, near, false);
var arrow = PartyCombatNativeTest.projectile(f, b.player);
hit(f, arrow, near, false);
var ordinary = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
hit(f, ordinary, near, false); // Ordinary sources never enter the fantasy denominator.
PartyCombatNativeTest.exit(f, b); // The arrow's originating character still owns its accepted contribution.
die(f, near); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Blocked share is lost rather than redistributed");
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience(), "First restricted kill is eligible for this character");
var passive = mob(EntityType.COW, new Location(world, 2, 64, 0));
hit(f, a.player, passive, false);
hit(f, c.player, passive, false);
// b is now ordinary; its direct damage cannot enter the fantasy denominator.
hit(f, b.player, passive, false);
die(f, passive); settle(f);
assertEquals(java.math.BigInteger.valueOf(11), record(f, a).experience());
assertEquals(java.math.BigInteger.valueOf(5), record(f, b).experience());
assertEquals(java.math.BigInteger.ONE, record(f, c).experience(),
"A one-XP passive pool rounds up to one for every fantasy contributor");
var exact = mob(EntityType.ZOMBIE, new Location(world, 5, 64, 0));
hit(f, a.player, exact, false);
hit(f, a.player, exact, true); // Cancelled repeats do not create another contributor.
die(f, exact); settle(f);
assertEquals(java.math.BigInteger.valueOf(21), record(f, a).experience(),
"Passive kill did not reset history; exact three-block distance from blocked kill is eligible");
assertEquals(new java.math.BigDecimal("5.0"), record(f, a).restrictedKills().values().iterator().next().x());
var repeatedPassive = mob(EntityType.SHEEP, new Location(world, 2, 64, 0));
hit(f, a.player, repeatedPassive, false); die(f, repeatedPassive); settle(f);
assertEquals(java.math.BigInteger.valueOf(22), record(f, a).experience(), "Same-location passive kills remain eligible");
}
}
@Test void failedRewardSaveRetainsOneRetryableOperationWithoutReplayingTheTarget() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var progression = games.dmg.fantasystick.progression.ProgressionRules.defaults();
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(progression);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(UUID.randomUUID(), UUID.randomUUID(), original.id());
var attacker = mock(org.bukkit.entity.Player.class);
when(attacker.getUniqueId()).thenReturn(active.player());
when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));
when(sessions.current(active)).thenReturn(true);
when(storage.find(original.id())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(java.util.Optional.of(original)));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.CombatAward>();
failed.completeExceptionally(new java.io.IOException("simulated write failure"));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(), java.math.BigInteger.ONE,
original.stats(), original.level(), java.math.BigInteger.TEN, original.unspentPoints(), original.health(),
original.learned(), original.loadout(), original.cooldowns(), original.restrictedKills(), false, null, null);
var success = java.util.concurrent.CompletableFuture.completedFuture(
new games.dmg.fantasystick.persistence.CharacterStorage.CombatAward(
games.dmg.fantasystick.persistence.CharacterStorage.CombatAwardStatus.APPLIED, java.util.Optional.of(saved), 0));
var writes = new java.util.concurrent.atomic.AtomicInteger();
when(storage.awardCombatExperience(eq(original.id()), any(), eq(java.math.BigInteger.TEN), same(progression), any(), any()))
.thenAnswer(ignored -> writes.getAndIncrement() == 0 ? failed : success);
var failures = new ArrayList<games.dmg.fantasystick.party.CombatExperience.Failure>();
var service = new games.dmg.fantasystick.party.CombatExperience(storage, progression,
games.dmg.fantasystick.progression.CombatRewardRules.defaults(), sessions,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC), Runnable::run,
ignored -> null, failures::add);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getDirectEntity()).thenReturn(attacker); when(damageSource.getCausingEntity()).thenReturn(attacker);
service.damage(new EntityDamageByEntityEvent(attacker, target, DamageCause.ENTITY_ATTACK, damageSource, 1));
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
verify(storage, times(1)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
assertEquals(1, failures.size());
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.mobDeath(new org.bukkit.event.entity.EntityDeathEvent(target, damageSource, new ArrayList<>(), 0));
service.retryPending();
verify(storage, times(2)).awardCombatExperience(eq(original.id()), any(), any(), any(), any(), any());
service.close();
}
@Test void validatedConfiguredPoolAndZeroSpacingReachTheRegisteredRewardPath() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"xp-per-level: 10\nordinary-xp: 25\nrestricted-kill-spacing-blocks: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
for (int index = 0; index < 2; index++) {
var target = mob(EntityType.ZOMBIE, new Location(world, 0, 64, 0));
hit(f, a.player, target, false); die(f, target); settle(f);
}
assertEquals(java.math.BigInteger.valueOf(50), record(f, a).experience());
assertEquals(6, record(f, a).level());
assertEquals(java.math.BigInteger.valueOf(45), record(f, a).unspentPoints());
assertEquals(5, a.sounds.size(), "Every gained level has its own feedback sound");
for (int level = 2; level <= 6; level++) {
int expected = level;
assertTrue(a.messages.stream().anyMatch(message -> message.contains("Level " + expected)));
}
}
}
@Test void interceptedFantasyDefeatUsesVictimLevelPoolAndCancelledDamageCreatesNoCredit() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, true);
var source = mock(org.bukkit.damage.DamageSource.class);
var cancelled = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(cancelled); settle(f);
assertTrue(cancelled.isCancelled());
assertEquals(java.math.BigInteger.ZERO, record(f, a).experience(), "Cancelled damage never contributes");
}
var second = data.resolve("accepted"); java.nio.file.Files.createDirectories(second);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(second)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(second);
var b = new FantasyStickLifecycleNativeTest.Actor(second);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
hit(f, a.player, b.player, false);
var source = mock(org.bukkit.damage.DamageSource.class);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(defeat); settle(f); settle(f);
assertTrue(defeat.isCancelled());
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Level-one fantasy defeat supplies a ten-XP pool");
assertTrue(record(f, b).dead());
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
org.bukkit.entity.Entity target, boolean cancelled) {
var damage = mock(org.bukkit.damage.DamageSource.class);
when(damage.getCausingEntity()).thenReturn(source);
when(damage.getDirectEntity()).thenReturn(source);
var event = new EntityDamageByEntityEvent(source, target, DamageCause.ENTITY_ATTACK, damage, 4);
event.setCancelled(cancelled); f.dispatcher.accept(event); return event;
}
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) {
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target,
mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0));
}
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static games.dmg.fantasystick.persistence.CharacterRecord record(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
return storage.find(actor.core.record.id()).get().orElseThrow();
}
private static LivingEntity mob(EntityType type, Location location) {
var entity = mock(LivingEntity.class);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
when(entity.getType()).thenReturn(type);
when(entity.getLocation()).thenAnswer(ignored -> location.clone());
when(entity.getWorld()).thenReturn(location.getWorld());
when(entity.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return entity;
}
}
@@ -17,6 +17,106 @@ class EquipmentAdminCommandNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id, target.player);
var before = target.core.snapshot(); var sender = new Sender();
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"give", "Account"}));
assertTrue(sender.messages.isEmpty(), "Queued persistence is not successful delivery");
((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run(); callback.run();
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
assertFalse(java.util.Arrays.equals(before, target.core.snapshot()));
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
var delivered = java.util.stream.IntStream.range(0, 36).mapToObj(target.core.inventory::getItem)
.filter(item -> sheets.identify(item).isPresent()).filter(item -> !sheets.identify(item).orElseThrow().equals(target.core.record.id())).toList();
assertEquals(1, delivered.size(), "Repeated callback must not duplicate delivery");
assertEquals(target.core.record.id(),sheets.identify(target.core.inventory.getItemInOffHand()).orElseThrow());
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).isEmpty());
var id = sheets.identify(delivered.getFirst()).orElseThrow();
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var record = storage.find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertEquals(java.math.BigInteger.valueOf(40), record.unspentPoints()); assertEquals(1, record.level());
assertEquals(java.math.BigInteger.ZERO, record.experience()); assertFalse(record.dead()); assertTrue(record.learned().isEmpty());
assertTrue(record.stats().values().stream().allMatch(java.math.BigInteger.ZERO::equals));assertEquals(new java.math.BigDecimal("10"),record.health());
assertEquals(java.util.Collections.nCopies(7,""),record.loadout());assertTrue(record.cooldowns().isEmpty());assertFalse(record.name().isBlank());
target.core.inventory.remove(delivered.getFirst()); target.core.inventory.setItemInOffHand(delivered.getFirst());
f.activate(target); assertEquals(id, ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).active(target.core.id).orElseThrow().character());
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertEquals(record, ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow());
}
}
@Test void giveCompletionAndRejectedExecutionArePermissionAwareStrictAndReadOnly() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
var sender=new Sender();
assertEquals(List.of("config","experience","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Account",""}).isEmpty());
sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}).isEmpty());
var original=target.core.snapshot();f.command.getExecutor().onCommand(sender.port,f.command,"fantasystick",new String[]{"give","Account"});
sender.allowed=true;for(String[] args:List.of(new String[]{"give"},new String[]{"give","Missing"},new String[]{"give","Account","extra"}))f.command.execute(sender.port,"fantasystick",args);
assertArrayEquals(original,target.core.snapshot());assertTrue(f.tasks.isEmpty());
assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
for(int slot=0;slot<36;slot++)target.core.inventory.setItem(slot,new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
var full=target.core.snapshot();f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});assertTrue(sender.messages.getLast().contains("no inventory space"));
assertArrayEquals(full,target.core.snapshot());assertTrue(f.tasks.isEmpty());assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
}
}
@Test void giveSaveFailureDisconnectSlotRaceAndLifecycleInterruptionNeverDuplicateOrOverwrite() throws Exception {
for(String route:List.of("save","disconnect","slot","close")){var dir=data.resolve(route);java.nio.file.Files.createDirectories(dir);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){
f.plugin.setEnabled(true);var target=new FantasyStickLifecycleNativeTest.Actor(dir);f.players.put(target.core.id,target.player);var sender=new Sender();var original=target.core.snapshot();
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(storage,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await(5,java.util.concurrent.TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));
f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});
if(route.equals("save")){var issuer=FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(),"characterIssuer");var pending=(java.util.Map<?,?>)FantasyStickLifecycleNativeTest.Fixture.field(issuer,"pending");var attempt=pending.values().iterator().next();var record=(games.dmg.fantasystick.persistence.CharacterRecord)FantasyStickLifecycleNativeTest.Fixture.field(attempt,"record");java.nio.file.Files.createDirectory(dir.resolve("characters").resolve(record.id()+".yml"));}
release.countDown();storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);
if(route.equals("disconnect"))target.core.online=false;if(route.equals("slot"))target.core.inventory.setItem(1,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));if(route.equals("close"))f.plugin.setEnabled(false);callback.run();
if(!route.equals("close"))storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);
assertFalse(sender.messages.stream().anyMatch(message->message.startsWith("Issued one")));assertEquals(route.equals("slot")?org.bukkit.Material.DIAMOND:original[1]==null?null:original[1].getType(),target.core.inventory.getItem(1)==null?null:target.core.inventory.getItem(1).getType());
if(!route.equals("save"))assertEquals(1,java.nio.file.Files.list(dir.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
release.countDown();}}
}
@Test void recoveryCompletionIsPermissionAwarePrefixFilteredUuidOnlyAndReadOnly() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
var offline = new FantasyStickLifecycleNativeTest.Actor(data);
offline.core.online = false;
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
var sender = new Sender();
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
assertEquals(List.of("config", "experience", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
String prefix = expected.getFirst().substring(0, 8);
assertEquals(expected.stream().filter(id -> id.startsWith(prefix)).toList(),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"recover", prefix}));
assertFalse(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}).contains("Account"));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", expected.getFirst(), ""}).isEmpty());
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown", ""}).isEmpty());
assertEquals(List.of("adventure-mode"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-m"}));
assertEquals(List.of("false", "true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", ""}));
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
sender.allowed = false;
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
new String[]{"config", "adventure-mode", ""})) {
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
}
assertTrue(f.tasks.isEmpty()); assertFalse(Files.exists(data.resolve("sessions")));
}
}
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -1285,10 +1285,10 @@ class EquipmentSessionEventsNativeTest {
@Test void selectedAbilityUseReachesItsHandlerWithoutVanillaThrowDrinkEquipBlockOrPlacement() throws Exception {
try (var f = new Fixture()) {
String[] abilities = {"fireball", "purify", "guardian_bond", "shield_bash", "rallying_cry", "war_banner", "revive"};
String[] abilities = {"chain_lightning", "purify", "guardian_bond", "shield_bash", "rallying_cry", "frost_nova", "revive"};
f.loadout(abilities);
var a = f.actor; f.core.activate(a);
var target = port(org.bukkit.entity.Player.class);
var target = port(org.bukkit.entity.Player.class); a.targetEntity = target;
for (int index = 0; index < abilities.length; index++) {
int slot = index + 2; a.inventory.getInventory().setSelectedSlot(slot);
var item = a.inventory.getItem(slot);
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more