Compare commits

...
20 Commits
Author SHA1 Message Date
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
96 changed files with 10090 additions and 102 deletions

No files matched your search

+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,6 +19,7 @@ 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;
@@ -27,6 +29,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private ManualCraftingController crafting;
private ManualCraftingGuard guard;
private CharacterAnvilNames anvilNames;
private RecipeDiscovery recipeDiscovery;
private boolean recipeRegistered;
private EquipmentSessionStore receipts;
private EquipmentSessions sessions;
@@ -49,6 +52,35 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.ability.AbilityRegistry abilityRegistry;
private games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.CombatMitigation combatMitigation;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.ability.OutgoingDamageMitigation outgoingDamageMitigation;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
private games.dmg.fantasystick.ability.Inspire inspire;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.GreatSword greatSword;
private games.dmg.fantasystick.ability.Revive revive;
private games.dmg.fantasystick.persistence.RevivalStore revivals;
private games.dmg.fantasystick.session.AdminRevival adminRevival;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -72,10 +104,38 @@ 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 switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
var rules = progressionRules();
var rewardRules = combatRewardRules();
var fireballSettings = fireballSettings();
var greatSwordSettings = greatSwordSettings();
var frostShardConfiguration = frostShardConfiguration();
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
var longbowConfiguration = longbowConfiguration();
var longbowInitial = games.dmg.fantasystick.persistence.LongbowSettings.parse(longbowConfiguration);
var quickstepConfiguration = quickstepConfiguration();
var quickstepInitial = games.dmg.fantasystick.persistence.QuickstepSettings.parse(quickstepConfiguration);
var secondWindConfiguration = secondWindConfiguration();
var secondWindInitial = games.dmg.fantasystick.persistence.SecondWindSettings.parse(secondWindConfiguration);
var stoneSkinConfiguration = stoneSkinConfiguration();
var stoneSkinInitial = games.dmg.fantasystick.persistence.StoneSkinSettings.parse(stoneSkinConfiguration);
var shieldBashConfiguration = shieldBashConfiguration();
var shieldBashInitial = games.dmg.fantasystick.persistence.ShieldBashSettings.parse(shieldBashConfiguration);
var hexConfiguration = hexConfiguration();
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
var inspireConfiguration = inspireConfiguration();
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
var factory = new CharacterFactory();
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
@@ -112,29 +172,86 @@ 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);
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
abilityRegistry.register(fireball.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
sessions.attachSwitchDelay(switchDelaySettings::delay);
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
var player = getServer().getPlayer(id);
return player != null && player.isOnline();
});
}, partySettings::maximum);
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
sessions, Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
getDataFolder().toPath(), frostShardConfiguration);
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
longbowSettings = new games.dmg.fantasystick.persistence.LongbowSettings(
getDataFolder().toPath(), longbowConfiguration);
longbow = new games.dmg.fantasystick.ability.Longbow(longbowInitial);
quickstepSettings = new games.dmg.fantasystick.persistence.QuickstepSettings(
getDataFolder().toPath(), quickstepConfiguration);
quickstep = new games.dmg.fantasystick.ability.Quickstep(quickstepInitial,
event -> getServer().getPluginManager().callEvent(event));
secondWindSettings = new games.dmg.fantasystick.persistence.SecondWindSettings(
getDataFolder().toPath(), secondWindConfiguration);
secondWind = new games.dmg.fantasystick.ability.SecondWind(secondWindInitial, characters, sessions, nextTick,
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read);
stoneSkinSettings = new games.dmg.fantasystick.persistence.StoneSkinSettings(
getDataFolder().toPath(), stoneSkinConfiguration);
combatMitigation = new games.dmg.fantasystick.ability.CombatMitigation();
stoneSkin = new games.dmg.fantasystick.ability.StoneSkin(stoneSkinInitial, combatMitigation);
shieldBashSettings = new games.dmg.fantasystick.persistence.ShieldBashSettings(
getDataFolder().toPath(), shieldBashConfiguration);
shieldBash = new games.dmg.fantasystick.ability.ShieldBash(
shieldBashInitial,
event -> getServer().getPluginManager().callEvent(event));
outgoingDamageMitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
hexSettings = new games.dmg.fantasystick.persistence.HexSettings(getDataFolder().toPath(), hexConfiguration);
hex = new games.dmg.fantasystick.ability.Hex(hexInitial, outgoingDamageMitigation);
temporaryHealth = new games.dmg.fantasystick.ability.TemporaryHealth(sessions, parties, nextTick,
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
(size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Revive transition failed", problem));
abilityRegistry.register(fireball.definition());
abilityRegistry.register(frostShard.definition());
abilityRegistry.register(longbow.definition());
abilityRegistry.register(quickstep.definition());
abilityRegistry.register(secondWind.definition());
abilityRegistry.register(stoneSkin.definition());
abilityRegistry.register(shieldBash.definition());
abilityRegistry.register(hex.definition());
abilityRegistry.register(inspire.definition());
abilityRegistry.register(greatSword.definition());
abilityRegistry.register(revive.definition());
sessions.attachAbilityRegistry(abilityRegistry);
vitals = new games.dmg.fantasystick.session.SessionVitals();
sessions.attachVitals(vitals);
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
games.dmg.fantasystick.ability.AbilityScheduler abilityScheduler = (delay, action) -> {
long ticks = Math.max(1, (delay.toNanos() + 49_999_999L) / 50_000_000L);
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
return task::cancel;
};
adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout);
adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick,
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
@@ -157,7 +274,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
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.attachAdminRevival(adminRevival, adminReviveSettings);
admin.attachSwitchDelay(switchDelaySettings);
admin.attachRoleplaySettings(roleplaySettings);
admin.attachPartySettings(partySettings);
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
"Admin character issue " + problem.operation() + " for player " + problem.player()
+ "; character " + problem.character(), problem.cause())));
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
command.setExecutor(admin);
command.setTabCompleter(admin);
@@ -172,16 +302,32 @@ public final class FantasyStickPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(combatExperience, this);
combatExperience.install(this);
getServer().getPluginManager().registerEvents(fireball, this);
getServer().getPluginManager().registerEvents(frostShard, this);
getServer().getPluginManager().registerEvents(longbow, this);
getServer().getPluginManager().registerEvents(quickstep, this);
getServer().getPluginManager().registerEvents(combatMitigation, this);
getServer().getPluginManager().registerEvents(shieldBash, this);
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
getServer().getPluginManager().registerEvents(temporaryHealth, this);
getServer().getPluginManager().registerEvents(greatSword, this);
getServer().getPluginManager().registerEvents(revive, this);
getServer().getPluginManager().registerEvents(adminRevival, this);
getServer().getPluginManager().registerEvents(localBasicAttack, this);
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
getServer().getPluginManager().registerEvents(books, this);
getServer().getPluginManager().registerEvents(gameModes, this);
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
sessionVisuals.install(this);
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
for (var player : getServer().getOnlinePlayers()) sessions.arrive(player);
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); }
@@ -204,6 +350,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;
@@ -215,20 +362,102 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingCombatExperience = combatExperience; combatExperience = null;
var closingAbilityEngine = abilityEngine; abilityEngine = null;
var closingFireball = fireball; fireball = null;
var closingFrostShard = frostShard; frostShard = null;
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
var closingLongbow = longbow; longbow = null;
var closingLongbowSettings = longbowSettings; longbowSettings = null;
var closingQuickstep = quickstep; quickstep = null;
var closingQuickstepSettings = quickstepSettings; quickstepSettings = null;
secondWind = null;
var closingStoneSkin = stoneSkin; stoneSkin = null;
var closingShieldBash = shieldBash; shieldBash = null;
var closingHex = hex; hex = null;
var closingInspire = inspire; inspire = null;
var closingInspireSettings = inspireSettings; inspireSettings = null;
var closingAdminRevival = adminRevival; adminRevival = null;
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
var closingPartySettings = partySettings; partySettings = null;
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
var closingHexSettings = hexSettings; hexSettings = null;
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
var closingShieldBashSettings = shieldBashSettings; shieldBashSettings = null;
var closingCombatMitigation = combatMitigation; combatMitigation = null;
var closingStoneSkinSettings = stoneSkinSettings; stoneSkinSettings = null;
var closingSecondWindSettings = secondWindSettings; secondWindSettings = null;
var closingGreatSword = greatSword; greatSword = null;
var closingRevive = revive; revive = null;
var closingRevivals = revivals; revivals = null;
abilityRegistry = null;
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
// Retire client resources independently of successful ordinary-equipment restoration.
if (closingPresentation != null) cleanup("presentation", closingPresentation::close);
if (closingProtocol != null) cleanup("protocol presentation", closingProtocol::close);
if (closingAdmin != null) cleanup("admin command", closingAdmin::close);
if (closingSettings != null) {
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 (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSettings.drained().toCompletableFuture(),
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingFrostSettings.drained().toCompletableFuture(),
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingLongbowSettings.drained().toCompletableFuture(),
closingQuickstepSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingQuickstepSettings.drained().toCompletableFuture(),
closingSecondWindSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSecondWindSettings.drained().toCompletableFuture(),
closingStoneSkinSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingStoneSkinSettings.drained().toCompletableFuture(),
closingShieldBashSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingShieldBashSettings.drained().toCompletableFuture(),
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingHexSettings.drained().toCompletableFuture(),
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingInspireSettings.drained().toCompletableFuture(),
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingAdminReviveSettings.drained().toCompletableFuture(),
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSwitchDelaySettings.drained().toCompletableFuture(),
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRoleplaySettings.drained().toCompletableFuture(),
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture(),
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingSheetResourcePackSettings.drained().toCompletableFuture());
if (closingBooks != null) cleanup("character book", closingBooks::close);
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
if (closingFrostShard != null) cleanup("Frost Shard", closingFrostShard::close);
if (closingLongbow != null) cleanup("Longbow", closingLongbow::close);
if (closingQuickstep != null) cleanup("Quickstep", closingQuickstep::close);
if (closingStoneSkin != null) cleanup("Stone Skin", closingStoneSkin::close);
if (closingShieldBash != null) cleanup("Shield Bash", closingShieldBash::close);
if (closingHex != null) cleanup("Hex", closingHex::close);
if (closingInspire != null) cleanup("Inspire", closingInspire::close);
if (closingTemporaryHealth != null) cleanup("temporary health", closingTemporaryHealth::close);
if (closingOutgoingDamageMitigation != null) cleanup("outgoing damage mitigation", closingOutgoingDamageMitigation::close);
if (closingCombatMitigation != null) cleanup("combat mitigation", closingCombatMitigation::close);
if (closingGreatSword != null) cleanup("Great Sword", closingGreatSword::close);
if (closingRevive != null) cleanup("Revive", closingRevive::close);
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
@@ -244,6 +473,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -251,11 +490,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
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();
@@ -313,21 +554,92 @@ 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 java.util.Map<String, String> frostShardConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.FrostShardSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.FrostShardSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> longbowConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.LongbowSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.LongbowSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> quickstepConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.QuickstepSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.QuickstepSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> secondWindConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.SecondWindSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.SecondWindSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> stoneSkinConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.StoneSkinSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.StoneSkinSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> shieldBashConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.ShieldBashSettings.keys()) {
values.put(key, getConfig().getString(key,
games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.ShieldBashSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> hexConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.HexSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.HexSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.HexSettings.parse(values);
return java.util.Map.copyOf(values);
}
private java.util.Map<String, String> inspireConfiguration() {
var values = new java.util.LinkedHashMap<String, String>();
for (String key : games.dmg.fantasystick.persistence.InspireSettings.keys()) {
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.InspireSettings.defaultValue(key)));
}
games.dmg.fantasystick.persistence.InspireSettings.parse(values);
return java.util.Map.copyOf(values);
}
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
try {
var 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 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);
}
}
@@ -17,7 +17,8 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
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 }
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}");
@@ -3,5 +3,7 @@ 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);
}
@@ -81,6 +81,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
() -> 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>();
@@ -123,7 +138,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
input.player().sendMessage("" + definition.name() + " is ready in "
+ Math.max(1, Duration.between(now, existing).toSeconds()) + " seconds."); return;
}
if (!eligible(definition.targetPolicy(), input, input.target())) {
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());
@@ -220,6 +241,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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();
};
}
@@ -305,6 +327,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
}
@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()));
}
@@ -17,6 +17,15 @@ public final class AbilityRegistry {
}
}
/** 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));
}
@@ -0,0 +1,77 @@
package games.dmg.fantasystick.ability;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent) {
this.execution = execution; this.player = player; this.percent = percent;
}
@Override public void close() {
if (closed) return; closed = true;
var owned = grants.get(player.getUniqueId());
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
var grant = new Grant(execution, player, percent);
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
return grant;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void reduce(EntityDamageByEntityEvent event) {
if (closed || !(event.getEntity() instanceof Player player)) return;
Grant strongest = null;
var owned = grants.get(player.getUniqueId());
if (owned == null) return;
for (var grant : List.copyOf(owned)) {
if (grant.closed || grant.player != player || !grant.execution.stillCurrent()) continue;
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
}
double original = event.getDamage();
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
double reduced = BigDecimal.valueOf(original)
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.execution.stillCurrent()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
}
@Override public void close() {
if (closed) return; closed = true;
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
grants.clear(); pending.clear();
}
}
@@ -0,0 +1,233 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Snowball;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.Vector;
/** Frost Shard's bounded direct projectile damage and owned Slowness adapter. */
public final class FrostShard implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime,
double speedPerTick, int slownessAmplifier, Duration slownessDuration) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
Objects.requireNonNull(slownessDuration);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 1024
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64
|| slownessAmplifier < 0 || slownessAmplifier > 255
|| slownessDuration.isZero() || slownessDuration.isNegative()
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("Invalid Frost Shard settings");
}
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
1, 0, Duration.ofSeconds(2));
}
}
private final class State implements AutoCloseable {
final Snowball projectile;
final AbilityExecution execution;
final Location origin;
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
boolean accepted;
boolean finished;
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
this.intelligence = intelligence; this.settings = settings;
}
@Override public void close() {
if (finished) return;
finished = true; projectiles.remove(projectile.getUniqueId(), this); projectile.remove();
}
}
private static final class SlowLease implements AutoCloseable {
private final LivingEntity target;
private final PotionEffect previous;
private final PotionEffect applied;
private final long started = System.nanoTime();
private boolean owned;
private boolean closed;
SlowLease(LivingEntity target, PotionEffect applied) {
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
}
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
@Override public void close() {
if (closed) return;
closed = true;
if (!owned) return;
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
if (current == null || current.getAmplifier() != applied.getAmplifier()
|| current.getDuration() > expected + 2) return;
target.removePotionEffect(PotionEffectType.SLOWNESS);
if (previous != null) {
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
}
}
}
private volatile Settings settings;
private final Map<UUID, State> projectiles = new HashMap<>();
private State damaging;
private boolean closed;
public FrostShard(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("frost_shard", "Frost Shard", Material.PRISMARINE_SHARD,
List.of("Launch a bounded frost projectile.",
"Deals " + current.baseDamage() + " + INT modifier damage and Slowness "
+ level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + "s."),
"Use the equipped shard; a miss still spends the cooldown.",
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE,
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()),
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"lifetime", seconds(current.lifetime()), "projectile-speed", BigDecimal.valueOf(current.speedPerTick()),
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
"slowness-duration", seconds(current.slownessDuration())),
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
}
public static BigInteger damage(BigInteger intelligence) {
return damage(BigInteger.valueOf(4), intelligence);
}
static BigInteger damage(BigInteger base, BigInteger intelligence) {
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Shard damage input");
BigInteger delta = intelligence.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = settings;
var player = execution.input().player();
Location origin = player.getEyeLocation().clone();
Vector direction = origin.getDirection();
if (!Double.isFinite(direction.getX()) || !Double.isFinite(direction.getY()) || !Double.isFinite(direction.getZ())
|| direction.lengthSquared() == 0) throw new IllegalStateException("Frost Shard direction is unavailable");
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(current.speedPerTick()));
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT), current);
if (projectiles.putIfAbsent(projectile.getUniqueId(), state) != null) {
projectile.remove(); throw new IllegalStateException("Duplicate Frost Shard projectile identity");
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
execution.later(current.lifetime(), () -> {
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
});
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@EventHandler(priority = EventPriority.MONITOR)
public void hit(ProjectileHitEvent event) {
var state = projectiles.remove(event.getEntity().getUniqueId());
if (state == null) return;
boolean retainExecution = false;
try {
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
var current = state.settings;
BigInteger amount = damage(current.baseDamage(), state.intelligence);
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target; damaging = state;
try { target.damage(nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
if (state.accepted && state.execution.stillCurrent()) {
int ticks = Math.toIntExact(Math.max(1,
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
current.slownessAmplifier(), false, true, true));
if (lease.apply()) {
state.execution.own(lease);
state.execution.later(current.slownessDuration(), state.execution::finish);
retainExecution = true;
}
}
} finally {
state.close();
if (!retainExecution) state.execution.finish();
}
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
state.accepted = true;
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
private static String level(int amplifier) {
return switch (amplifier + 1) {
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
default -> Integer.toString(amplifier + 1);
};
}
private static BigDecimal seconds(Duration duration) {
return BigDecimal.valueOf(duration.getSeconds()).add(BigDecimal.valueOf(duration.getNano(), 9))
.stripTrailingZeros();
}
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
projectiles.clear(); damaging = null;
}
}
@@ -0,0 +1,116 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Great Sword's charge-scaled direct melee damage, replacing the contained native weapon hit. */
public final class GreatSword implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration recovery) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(recovery);
if (baseDamage.signum() < 0 || recovery.isNegative()
|| recovery.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Great Sword settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
}
private static final class Attempt {
final AbilityExecution execution;
final LivingEntity target;
boolean reported;
Attempt(AbilityExecution execution, LivingEntity target) {
this.execution = execution; this.target = target;
}
}
private final Settings settings;
private Attempt damaging;
private boolean closed;
public GreatSword(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
return new AbilityDefinition("great_sword", "Great Sword", Material.WOODEN_SWORD,
List.of("Charge a contained melee strike.",
"Fully charged: " + settings.baseDamage() + " + STR modifier damage."),
"Attack with the equipped sword; partial charge follows the native attack curve.",
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
public static BigDecimal damage(BigInteger strength, float charge) {
return damage(BigInteger.valueOf(7), strength, charge);
}
static BigDecimal damage(BigInteger base, BigInteger strength, float charge) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0 || !Float.isFinite(charge) || charge < 0 || charge > 1) {
throw new IllegalArgumentException("Invalid Great Sword damage input");
}
BigInteger delta = strength.subtract(BigInteger.TEN);
BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
BigDecimal full = new BigDecimal(base.add(modifier));
BigDecimal nativeCharge = new BigDecimal(Float.toString(charge));
BigDecimal curve = new BigDecimal("0.2").add(new BigDecimal("0.8").multiply(nativeCharge.multiply(nativeCharge)));
return full.multiply(curve).stripTrailingZeros();
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
BigDecimal amount = damage(settings.baseDamage(), execution.character().stats().get(Stat.STR),
execution.input().attackStrength());
double nativeDamage = amount.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
? Double.MAX_VALUE : amount.doubleValue();
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
throw new IllegalStateException("Great Sword damage is outside the native finite range");
}
var player = execution.input().player();
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player)
.withDirectEntity(player).build();
var attempt = new Attempt(execution, target);
damaging = attempt;
try { target.damage(nativeDamage, source); }
finally { if (damaging == attempt) damaging = null; }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.reported || event.isCancelled()
|| event.getDamager() != attempt.execution.input().player() || event.getEntity() != attempt.target
|| (event.getDamageSource().getCausingEntity() != null
&& event.getDamageSource().getCausingEntity() != attempt.execution.input().player())
|| (event.getDamageSource().getDirectEntity() != null
&& event.getDamageSource().getDirectEntity() != attempt.execution.input().player())
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.reported = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,49 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.LivingEntity;
/** Hex's aimed, owned outgoing combat-damage penalty. */
public final class Hex implements AbilityEffect,AutoCloseable{
public record Settings(Duration cooldown,double range,Duration duration,BigDecimal reductionPercent){
public Settings{Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);Objects.requireNonNull(reductionPercent);
if(cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64
||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0
||reductionPercent.signum()<0||reductionPercent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid Hex settings");}
public static Settings defaults(){return new Settings(Duration.ofSeconds(10),12,Duration.ofSeconds(4),BigDecimal.valueOf(20));}}
private final class State implements AutoCloseable{final AbilityExecution execution;final LivingEntity target;final AutoCloseable grant;boolean closed;
State(AbilityExecution execution,LivingEntity target,AutoCloseable grant){this.execution=execution;this.target=target;this.grant=grant;}
@Override public void close(){if(closed)return;closed=true;hexes.remove(target.getUniqueId(),this);try{grant.close();}catch(Exception failure){throw new IllegalStateException(failure);}}}
private volatile Settings settings;private final OutgoingDamageMitigation mitigation;private final Map<UUID,State> hexes=new java.util.HashMap<>();private boolean closed;
public Hex(Settings settings,OutgoingDamageMitigation mitigation){this.settings=Objects.requireNonNull(settings);this.mitigation=Objects.requireNonNull(mitigation);}
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("hex","Hex",Material.SPIDER_EYE,
List.of("Reduce an aimed hostile's combat damage by "+current.reductionPercent().stripTrailingZeros().toPlainString()+"%.",
"Range "+BigDecimal.valueOf(current.range()).stripTrailingZeros().toPlainString()+" blocks; lasts "+seconds(current.duration())+" seconds."),
"Use while aiming at the first visible hostile; there is no fallback target.",Map.of(Stat.CHR,BigInteger.valueOf(14),Stat.WIS,BigInteger.valueOf(12)),BigInteger.ONE,
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("range",BigDecimal.valueOf(current.range()).stripTrailingZeros(),
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
AbilityDefinition.TargetPolicy.HOSTILE,this);}
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())){execution.finish();return;}
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
}
@@ -0,0 +1,16 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;import games.dmg.fantasystick.session.ControlInput;import java.math.*;import java.time.Duration;import java.util.*;import org.bukkit.*;import org.bukkit.entity.Player;
/** Inspire's self-or-aimed fantasy-player temporary health grant. */
public final class Inspire implements AbilityEffect,AutoCloseable{
public record Settings(BigInteger baseTemporaryHealth,Duration cooldown,double range,Duration duration){public Settings{Objects.requireNonNull(baseTemporaryHealth);Objects.requireNonNull(cooldown);Objects.requireNonNull(duration);if(baseTemporaryHealth.signum()<0||baseTemporaryHealth.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger())>0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0||!Double.isFinite(range)||range<=0||range>64||duration.isZero()||duration.isNegative()||duration.compareTo(Duration.ofDays(1))>0)throw new IllegalArgumentException("Invalid Inspire settings");}public static Settings defaults(){return new Settings(BigInteger.valueOf(4),Duration.ofSeconds(15),12,Duration.ofSeconds(6));}}
private volatile Settings settings;private final TemporaryHealth temporaryHealth;private boolean closed;
public Inspire(Settings settings,TemporaryHealth temporaryHealth){this.settings=Objects.requireNonNull(settings);this.temporaryHealth=Objects.requireNonNull(temporaryHealth);}public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&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,78 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.projectiles.ProjectileSource;
/** Internal pre-recipient strongest outgoing-damage reduction stage. */
public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
private final class Grant implements AutoCloseable {
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent; boolean closed;
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
}
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
}
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
private long sequences; private boolean closed;
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
public void reduce(EntityDamageByEntityEvent event){
if(closed)return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
}
@EventHandler(priority=EventPriority.MONITOR)
public void accepted(EntityDamageEvent event){
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
if(!(prevention.recipient instanceof Player player))return;
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
}
private static Entity attacker(EntityDamageByEntityEvent event){
Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;
if(event.getDamager() instanceof Projectile projectile){ProjectileSource shooter=projectile.getShooter();if(shooter instanceof Entity entity)return entity;}
return event.getDamager();
}
@Override public void close(){if(closed)return;closed=true;for(var owned:List.copyOf(grants.values()))for(var grant:List.copyOf(owned))grant.close();grants.clear();pending.clear();}
}
@@ -0,0 +1,153 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Quickstep's grounded, collision-respecting, owned horizontal velocity adapter. */
public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
public record Settings(Duration cooldown, double distance, double speedPerTick) {
public Settings {
Objects.requireNonNull(cooldown);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(distance) || distance <= 0 || distance > 64
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 8) {
throw new IllegalArgumentException("Invalid Quickstep settings");
}
}
public static Settings defaults() { return new Settings(Duration.ofSeconds(5), 4, 1); }
}
private final class State implements AutoCloseable {
final AbilityExecution execution;
final Player player;
final Location origin;
final Vector horizontal;
final Settings settings;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
double magnitude = horizontalCurrent.length();
if (magnitude < 1.0e-6) return true;
return magnitude <= horizontal.length() * 1.05 + 1.0e-6
&& horizontalCurrent.clone().normalize().dot(horizontal.clone().normalize()) >= 0.99;
}
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
player.setVelocity(new Vector(0, current.getY(), 0));
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private final Map<UUID, State> dashes = new HashMap<>();
private boolean closed;
public Quickstep(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("quickstep", "Quickstep", Material.RABBIT_FOOT,
List.of("Dash forward with native horizontal movement.",
"Travel up to " + decimal(current.distance()) + " blocks while obstacles remain authoritative."),
"Use while grounded, on foot, and not flying or gliding.",
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
var player = input.player();
if (!player.isOnGround() || player.isFlying() || player.isGliding() || player.isInsideVehicle()) return false;
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0);
return finite(direction) && direction.lengthSquared() > 1.0e-12;
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void moved(PlayerMoveEvent event) {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
}
}
private static boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(), definition.parameters().get("distance").doubleValue(),
definition.parameters().get("speed").doubleValue());
}
private static BigDecimal decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(dashes.values())) state.execution.finish();
dashes.clear();
}
}
@@ -0,0 +1,376 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.TailBooks;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.RevivalStore;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Revive target selection, contained popup and proof-based Tail conversion. */
public final class Revive implements AbilityEffect, Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
private static final Duration DEATH_AGE = Duration.ofHours(24);
private static final Duration VIEW_TIME = Duration.ofMinutes(5);
private static final double RANGE = 12;
private record Candidate(int slot, ItemStack captured, TailBooks.Identity identity) { }
private record Choice(Candidate candidate, CharacterRecord record) { }
private final CharacterStorage characters;
private final RevivalStore reservations;
private final Clock clock;
private final Consumer<Runnable> nextTick;
private final PopupFactory popups;
private final Consumer<Throwable> failures;
private final TailBooks tails = new TailBooks();
private final CharacterSheets sheets = new CharacterSheets();
private final Map<Inventory, View> inventories = new IdentityHashMap<>();
private final Map<UUID, View> targets = new LinkedHashMap<>();
private boolean closed;
private final class View implements AutoCloseable {
final AbilityExecution execution;
final Player target;
final Inventory inventory;
final Map<Integer, Choice> choices;
boolean selecting;
boolean finished;
View(AbilityExecution execution, Player target, Inventory inventory, Map<Integer, Choice> choices) {
this.execution = execution; this.target = target; this.inventory = inventory; this.choices = choices;
}
@Override public void close() {
if (finished) return;
finished = true;
inventories.remove(inventory, this); targets.remove(target.getUniqueId(), this);
inventory.clear();
try { target.closeInventory(); } catch (RuntimeException failure) { report(failure); }
}
}
public Revive(CharacterStorage characters, RevivalStore reservations, Clock clock, Consumer<Runnable> nextTick,
PopupFactory popups, Consumer<Throwable> failures) {
this.characters = Objects.requireNonNull(characters); this.reservations = Objects.requireNonNull(reservations);
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
this.popups = Objects.requireNonNull(popups); this.failures = Objects.requireNonNull(failures);
}
public AbilityDefinition definition() {
return new AbilityDefinition("revive", "Revive", Material.ECHO_SHARD,
List.of("Let another player continue one fallen character.", "A valid cast spends the daily opportunity."),
"Use the equipped shard while aiming at another player within 12 blocks.",
Map.of(Stat.WIS, BigInteger.valueOf(30), Stat.CHR, BigInteger.valueOf(24), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.SELECTION, ControlInput.Gesture.USE, Duration.ofHours(24),
Map.of("range", BigDecimal.valueOf(12), "selection-time", BigDecimal.valueOf(300),
"death-age", BigDecimal.valueOf(86400)),
AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION, this);
}
@Override public boolean accepts(ControlInput input) { return !closed && target(input) != null; }
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
Player target = target(execution.input());
if (target == null) {
execution.input().player().sendMessage("Revive found no eligible player within 12 blocks and line of sight.");
execution.finish(); return;
}
var captured = candidates(target);
if (captured.isEmpty()) {
execution.input().player().sendMessage("That player has no direct-inventory Tail eligible for Revive.");
execution.finish(); return;
}
var loads = captured.stream().map(candidate -> characters.find(candidate.identity().characterId())).toList();
CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> schedule(() -> {
if (failure != null) { report(failure); execution.input().player().sendMessage("Revive could not inspect those Tails safely."); execution.finish(); return; }
if (closed || !execution.stillCurrent() || !target.isOnline()) { execution.finish(); return; }
var eligible = new ArrayList<Choice>();
Instant now = clock.instant();
for (int index = 0; index < captured.size(); index++) {
var found = loads.get(index).join();
if (found.isEmpty()) continue;
var record = found.orElseThrow();
var candidate = captured.get(index);
if (eligible(record, candidate.identity(), now) && unchanged(target, candidate)) eligible.add(new Choice(candidate, record));
}
if (eligible.isEmpty()) {
execution.input().player().sendMessage("That player has no current Tail younger than 24 hours.");
execution.finish(); return;
}
open(execution, target, eligible, now);
}));
}
private Player target(ControlInput input) {
Player caster = input.player();
Entity supplied = input.target();
Player selected = supplied instanceof Player player ? player : null;
if (selected == null) {
Location eye = caster.getEyeLocation();
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), RANGE,
entity -> entity instanceof Player && entity != caster);
if (hit != null && hit.getHitEntity() instanceof Player player) selected = player;
}
if (selected == null || selected == caster || !selected.isOnline() || selected.getWorld() != caster.getWorld()
|| selected.getLocation().distanceSquared(caster.getLocation()) > RANGE * RANGE
|| !caster.hasLineOfSight(selected)) return null;
return selected;
}
private List<Candidate> candidates(Player target) {
var result = new ArrayList<Candidate>();
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
ItemStack item = target.getInventory().getItem(slot);
if (item == null || item.isEmpty()) continue;
var identity = tails.identify(item);
if (identity.isPresent()) result.add(new Candidate(slot, item.clone(), identity.orElseThrow()));
}
return List.copyOf(result);
}
private boolean eligible(CharacterRecord record, TailBooks.Identity identity, Instant now) {
if (!record.dead() || !record.id().equals(identity.characterId()) || !Objects.equals(record.deathId(), identity.deathId())) return false;
Duration age = Duration.between(record.diedAt(), now);
return !age.isNegative() && age.compareTo(DEATH_AGE) < 0;
}
private boolean unchanged(Player target, Candidate candidate) {
ItemStack current = target.getInventory().getItem(candidate.slot());
return current != null && current.equals(candidate.captured());
}
private void open(AbilityExecution execution, Player target, List<Choice> eligible, Instant now) {
View old = targets.get(target.getUniqueId()); if (old != null) old.execution.finish();
int size = Math.min(54, Math.max(9, ((eligible.size() + 8) / 9) * 9));
Inventory inventory = popups.create(size, Component.text("Choose a Tail to Revive"));
var choices = new LinkedHashMap<Integer, Choice>();
for (int index = 0; index < eligible.size() && index < size; index++) {
Choice choice = eligible.get(index); ItemStack display = choice.candidate().captured().clone(); display.setAmount(1);
var meta = display.getItemMeta();
long seconds = Math.max(0, Duration.between(now, choice.record().diedAt().plus(DEATH_AGE)).toSeconds());
meta.lore(List.of(Component.text("Level " + choice.record().level()),
Component.text("Eligible for " + seconds + " more seconds"), Component.text("Click to continue this character")));
display.setItemMeta(meta); inventory.setItem(index, display); choices.put(index, choice);
}
var view = new View(execution, target, inventory, Map.copyOf(choices));
inventories.put(inventory, view); targets.put(target.getUniqueId(), view); execution.own(view);
target.openInventory(inventory);
execution.later(VIEW_TIME, execution::finish);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void click(org.bukkit.event.inventory.InventoryClickEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view == null) return;
event.setCancelled(true);
if (event.getWhoClicked() != view.target || event.getRawSlot() < 0 || event.getRawSlot() >= view.inventory.getSize()) return;
Choice choice = view.choices.get(event.getRawSlot());
if (choice == null || view.selecting) return;
view.selecting = true; inventories.remove(view.inventory, view); targets.remove(view.target.getUniqueId(), view);
view.inventory.clear(); view.target.closeInventory();
confirm(view, choice);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void drag(org.bukkit.event.inventory.InventoryDragEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && event.getRawSlots().stream().anyMatch(slot -> slot < view.inventory.getSize())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR)
public void close(org.bukkit.event.inventory.InventoryCloseEvent event) {
var view = inventories.get(event.getView().getTopInventory());
if (view != null && !view.selecting) view.execution.finish();
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var targetView = targets.get(event.getPlayer().getUniqueId()); if (targetView != null) targetView.execution.finish();
for (var view : List.copyOf(inventories.values())) {
if (view.execution.input().player() == event.getPlayer()) view.execution.finish();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void join(org.bukkit.event.player.PlayerJoinEvent event) { reconcile(event.getPlayer()); }
private void confirm(View view, Choice choice) {
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || !unchanged(view.target, choice.candidate())) {
view.execution.finish(); return;
}
characters.find(choice.record().id()).whenComplete((found, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive could not recheck that Tail; no item changed."); view.execution.finish(); return; }
if (closed || !view.execution.stillCurrent() || !view.target.isOnline() || found.isEmpty()
|| !eligible(found.orElseThrow(), choice.candidate().identity(), clock.instant())
|| !unchanged(view.target, choice.candidate())) {
view.execution.input().player().sendMessage("That Tail is no longer eligible; no item changed.");
view.execution.finish(); return;
}
reserve(view, choice.candidate(), found.orElseThrow());
}));
}
private void reserve(View view, Candidate candidate, CharacterRecord dead) {
ItemStack source = view.target.getInventory().getItem(candidate.slot());
int destination = source.getAmount() == 1 ? -1 : emptyDirectSlot(view.target, candidate.slot());
if (source.getAmount() > 1 && destination < 0) {
view.target.sendMessage("Revive needs one empty direct inventory slot to preserve the copied Tails.");
view.execution.finish(); return;
}
var reservation = new RevivalStore.Reservation(UUID.randomUUID(), view.execution.character().id(),
view.target.getUniqueId(), dead.id(), dead.deathId(), clock.instant().truncatedTo(ChronoUnit.SECONDS),
candidate.slot(), source.getAmount(), destination);
reservations.reserve(reservation).whenComplete((reserved, failure) -> schedule(() -> {
if (failure != null) { report(failure); view.execution.input().player().sendMessage("Revive reservation could not be saved; no Tail changed."); view.execution.finish(); return; }
if (reserved.isEmpty() || closed || !view.execution.stillCurrent() || !view.target.isOnline()
|| !unchanged(view.target, candidate)) {
view.execution.input().player().sendMessage("Revive selection became stale after reservation; no Tail was changed.");
view.execution.finish(); return;
}
deliver(view, dead, reservation);
}));
}
private int emptyDirectSlot(Player target, int source) {
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40 || slot == source) continue;
ItemStack item = target.getInventory().getItem(slot);
if (item == null || item.isEmpty()) return slot;
}
return -1;
}
private void deliver(View view, CharacterRecord dead, RevivalStore.Reservation reservation) {
try {
var provisional = new CharacterRecord(dead.id(), dead.name(), dead.revision(), dead.stats(), dead.level(), dead.experience(),
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.cooldowns(), dead.restrictedKills(),
false, dead.deathId(), dead.diedAt());
ItemStack sheet = sheets.create(provisional);
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
if (reservation.sourceAmount() == 1) view.target.getInventory().setItem(reservation.sourceSlot(), sheet);
else {
view.target.getInventory().setItem(reservation.destinationSlot(), sheet);
var remaining = source.clone(); remaining.setAmount(reservation.sourceAmount() - 1);
view.target.getInventory().setItem(reservation.sourceSlot(), remaining);
}
view.target.updateInventory();
} catch (RuntimeException uncertain) {
report(uncertain); view.target.sendMessage("Revive item delivery is uncertain; retained proof requires recovery.");
view.execution.finish(); return;
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null || restored.isEmpty()) {
if (failure != null) report(failure);
view.target.sendMessage("The Tail changed, but character restoration is pending recovery; do not move the sheet.");
view.execution.finish(); return;
}
if (installFinalSheet(view.target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id()).exceptionally(failure2 -> { report(failure2); return false; });
view.target.sendMessage("Restored character sheet: " + restored.orElseThrow().name() + ".");
view.execution.input().player().sendMessage("Revive restored " + restored.orElseThrow().name() + ".");
} else view.target.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");
view.execution.finish();
}));
}
private boolean installFinalSheet(Player target, CharacterRecord record) {
int slot = uniqueSheetSlot(target, record.id());
if (slot < 0) return false;
target.getInventory().setItem(slot, sheets.create(record)); target.updateInventory(); return true;
}
private int uniqueSheetSlot(Player target, UUID character) {
int found = -1;
for (int slot = 0; slot <= 40; slot++) {
if (slot > 35 && slot != 40) continue;
if (sheets.identify(target.getInventory().getItem(slot)).filter(character::equals).isEmpty()) continue;
if (found >= 0) return -2;
found = slot;
}
return found;
}
public void reconcile(Player target) {
if (closed || !target.isOnline()) return;
reservations.target(target.getUniqueId()).whenComplete((found, failure) -> {
if (failure != null) { report(failure); return; }
for (var reservation : found) characters.find(reservation.character()).whenComplete((record, loadFailure) -> schedule(() -> {
if (loadFailure != null) { report(loadFailure); return; }
if (closed || !target.isOnline() || record.isEmpty()) return;
reconcile(target, reservation, record.orElseThrow());
}));
});
}
private void reconcile(Player target, RevivalStore.Reservation reservation, CharacterRecord record) {
int foundSheet = uniqueSheetSlot(target, reservation.character());
boolean hasSheet = foundSheet >= 0;
ItemStack source = target.getInventory().getItem(reservation.sourceSlot());
boolean unchangedTail = source != null && source.getAmount() == reservation.sourceAmount()
&& tails.identify(source).filter(id -> id.characterId().equals(reservation.character()) && id.deathId().equals(reservation.death())).isPresent();
boolean consumedTail = reservation.sourceAmount() == 1 ? hasSheet
: source != null && source.getAmount() == reservation.sourceAmount() - 1
&& tails.identify(source).filter(id -> id.deathId().equals(reservation.death())).isPresent();
if (!record.dead() && hasSheet && installFinalSheet(target, record)) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && unchangedTail && !hasSheet) {
reservations.clear(reservation.character(), reservation.id()); return;
}
if (record.dead() && hasSheet && (consumedTail || unchangedTail && reservation.sourceAmount() > 1)) {
if (unchangedTail && reservation.sourceAmount() > 1) {
var remaining = source.clone(); remaining.setAmount(source.getAmount() - 1);
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
}
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
(reservation.administrative()
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit)
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit))
.whenComplete((restored, failure) -> schedule(() -> {
if (failure != null) { report(failure); return; }
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
reservations.clear(reservation.character(), reservation.id());
}
}));
}
}
private void schedule(Runnable action) {
try { nextTick.accept(action); } catch (RuntimeException rejected) { report(rejected); }
}
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
@Override public void close() {
if (closed) return; closed = true;
for (var view : List.copyOf(inventories.values())) view.execution.finish();
inventories.clear(); targets.clear();
}
}
@@ -0,0 +1,115 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.attribute.Attribute;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Second Wind's validated persist-before-publication self-heal. */
public final class SecondWind implements AbilityEffect {
public record Settings(BigInteger baseHealing, Duration cooldown) {
public Settings {
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
if (baseHealing.signum() < 0 || baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0) {
throw new IllegalArgumentException("Invalid Second Wind settings");
}
}
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(15)); }
}
private final CharacterStorage characters;
private final EquipmentSessions sessions;
private final Consumer<Runnable> nextTick;
private final Consumer<EntityRegainHealthEvent> healingEvents;
private final Supplier<BigDecimal> nativeLimit;
private volatile Settings settings;
public SecondWind(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
Supplier<BigDecimal> nativeLimit) {
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("second_wind", "Second Wind", Material.GHAST_TEAR,
List.of("Restore missing fantasy health.",
"Heals " + current.baseHealing() + " + WIS modifier, capped at maximum health."),
"Use while injured; full health refuses without spending cooldown.",
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("base-healing", new BigDecimal(current.baseHealing())),
AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public boolean accepts(ControlInput input) {
double health = input.player().getHealth();
var attribute = input.player().getAttribute(Attribute.MAX_HEALTH);
return attribute != null && Double.isFinite(health) && health >= 0
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
}
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
static BigInteger healing(BigInteger base, BigInteger wisdom) {
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Second Wind healing input");
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
BigInteger modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var definition = execution.definition();
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
var player = execution.input().player();
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
double current = player.getHealth(); double maximum = attribute.getValue();
double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(missing, event.getAmount());
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
var record = execution.character(); var expected = record.revision();
BigDecimal target = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> {
try {
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
}
double publish = Math.min(attribute.getValue(), Math.max(player.getHealth(), persisted.health().doubleValue()));
player.setHealth(publish);
} finally { execution.finish(); }
});
} catch (RuntimeException rejected) { execution.finish(); }
});
}
}
@@ -0,0 +1,144 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
/** Shield Bash's contained short-range custom strike and post-damage knockback. */
public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable {
public record Settings(BigInteger baseDamage, Duration cooldown, double range,
double horizontalKnockback, double minimumVerticalKnockback) {
public Settings {
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown);
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| !Double.isFinite(range) || range <= 0 || range > 64
|| !Double.isFinite(horizontalKnockback) || horizontalKnockback < 0 || horizontalKnockback > 8
|| !Double.isFinite(minimumVerticalKnockback) || minimumVerticalKnockback < 0
|| minimumVerticalKnockback > 8) throw new IllegalArgumentException("Invalid Shield Bash settings");
}
public static Settings defaults() {
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(6), 3, 0.8, 0.35);
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
this.execution = execution; this.target = target; this.settings = settings;
}
}
private volatile Settings settings;
private final Consumer<PlayerVelocityEvent> velocityEvents;
private Attempt damaging;
private boolean closed;
public ShieldBash(Settings settings, Consumer<PlayerVelocityEvent> velocityEvents) {
this.settings = Objects.requireNonNull(settings); this.velocityEvents = Objects.requireNonNull(velocityEvents);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("shield_bash", "Shield Bash", Material.SHIELD,
List.of("Strike within " + decimal(current.range()) + " blocks for " + current.baseDamage() + " + STR modifier damage.",
"Accepted damage adds knockback; this shield never enables ordinary blocking."),
"Attack a visible hostile at short range.", Map.of(Stat.STR, BigInteger.valueOf(12), Stat.CON, BigInteger.valueOf(12)),
BigInteger.ONE, AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.ATTACK, current.cooldown(),
Map.of("base-damage", new BigDecimal(current.baseDamage()), "range", decimalValue(current.range()),
"horizontal-knockback", decimalValue(current.horizontalKnockback()),
"minimum-vertical-knockback", decimalValue(current.minimumVerticalKnockback())),
AbilityDefinition.TargetPolicy.HOSTILE, this);
}
@Override public boolean accepts(ControlInput input) {
return !closed && input.target() instanceof LivingEntity target && inRange(input.player(), target, settings.range());
}
public static BigInteger damage(BigInteger strength) { return damage(BigInteger.valueOf(4), strength); }
static BigInteger damage(BigInteger base, BigInteger strength) {
Objects.requireNonNull(base); Objects.requireNonNull(strength);
if (base.signum() < 0 || strength.signum() < 0) throw new IllegalArgumentException("Invalid Shield Bash damage input");
var delta = strength.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
var modifier = divided[0];
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
return base.add(modifier);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { target.damage(amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
}
private static boolean inRange(Player player, LivingEntity target, double range) {
try {
Location source = player.getLocation(); Location destination = target.getLocation();
return source.getWorld() == destination.getWorld() && source.distanceSquared(destination) <= range * range
&& player.hasLineOfSight(target);
} catch (RuntimeException invalid) { return false; }
}
private static boolean finite(Vector value) {
return Double.isFinite(value.getX()) && Double.isFinite(value.getY()) && Double.isFinite(value.getZ());
}
private static Settings from(AbilityDefinition definition) {
var p = definition.parameters();
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("range").doubleValue(),
p.get("horizontal-knockback").doubleValue(), p.get("minimum-vertical-knockback").doubleValue());
}
private static BigDecimal decimalValue(double value) { return BigDecimal.valueOf(value).stripTrailingZeros(); }
private static String decimal(double value) { return decimalValue(value).toPlainString(); }
@Override public void close() { closed = true; damaging = null; }
}
@@ -0,0 +1,84 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Player;
/** Stone Skin's captured, owned grant into the shared strongest-reduction stage. */
public final class StoneSkin implements AbilityEffect, AutoCloseable {
public record Settings(Duration cooldown, Duration duration, BigDecimal reductionPercent) {
public Settings {
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(reductionPercent);
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|| reductionPercent.signum() < 0 || reductionPercent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid Stone Skin settings");
}
public static Settings defaults() {
return new Settings(Duration.ofSeconds(14), Duration.ofSeconds(4), BigDecimal.valueOf(25));
}
}
private final class State implements AutoCloseable {
final AbilityExecution execution; final Player player; final AutoCloseable grant; boolean closed;
State(AbilityExecution execution, Player player, AutoCloseable grant) {
this.execution = execution; this.player = player; this.grant = grant;
}
@Override public void close() {
if (closed) return; closed = true; protections.remove(player.getUniqueId(), this);
try { grant.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
}
}
private volatile Settings settings;
private final CombatMitigation mitigation;
private final Map<UUID, State> protections = new java.util.HashMap<>();
private boolean closed;
public StoneSkin(Settings settings, CombatMitigation mitigation) {
this.settings = Objects.requireNonNull(settings); this.mitigation = Objects.requireNonNull(mitigation);
}
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
public AbilityDefinition definition() {
var current = settings;
return new AbilityDefinition("stone_skin", "Stone Skin", Material.BRICK,
List.of("Reduce entity and projectile combat damage by " + decimal(current.reductionPercent()) + "%.",
"Lasts " + decimal(BigDecimal.valueOf(current.duration().toMillis()).movePointLeft(3)) + " seconds without stacking."),
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE,
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
Map.of("duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
"damage-reduction-percent", current.reductionPercent()), AbilityDefinition.TargetPolicy.SELF, this);
}
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
}
private static Settings from(AbilityDefinition definition) {
return new Settings(definition.cooldown(),
Duration.ofNanos(definition.parameters().get("duration-seconds").movePointRight(9).longValueExact()),
definition.parameters().get("damage-reduction-percent"));
}
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
protections.clear();
}
}
@@ -0,0 +1,33 @@
package games.dmg.fantasystick.ability;
import games.dmg.fantasystick.party.PartyRoster;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Consumer;
import org.bukkit.entity.*;
import org.bukkit.event.*;
import org.bukkit.event.entity.*;
import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();long ownerSequence=owner.sequence;UUID source=owner.execution.input().session().character();
try{nextTick.accept(()->consume(target,before,owned,ownerSequence,source,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,long ownerSequence,UUID source,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,source,aided,BigDecimal.valueOf(consumed).stripTrailingZeros()));}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&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();}
}
@@ -34,6 +34,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
@@ -173,21 +174,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
String text = definition.name() + "\nIcon: " + definition.icon().name()
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
+ "\nCooldown: " + definition.cooldown().toSeconds() + "s" + parameters + "\n\n" + state;
+ "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state;
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
} else if (learned) {
Component action = Component.text("Assign " + definition.name() + "\n\nSlot: ");
Component action = Component.text("Assign ability\n\nSlot:\n");
for (int slot = 0; slot < 7; slot++) {
if (slot > 0) action = action.append(Component.space());
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
}
action = action.append(Component.text("\n\nRefund: "))
.append(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id(), null));
pages.add(action);
}
return java.util.List.copyOf(pages);
}
private static String durationSeconds(java.time.Duration duration) {
return java.math.BigDecimal.valueOf(duration.getSeconds())
.add(java.math.BigDecimal.valueOf(duration.getNano(), 9)).stripTrailingZeros().toPlainString();
}
private static Component abilityButton(String label, net.kyori.adventure.key.Key action, AbilityView view,
String ability, Integer slot) {
String payload = "\"" + view.nonce() + ":" + ability + (slot == null ? "" : ":" + slot) + "\"";
@@ -285,7 +293,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !assign
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player = connection.getPlayer();
@@ -295,10 +304,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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))
|| unlock != (match.group(3) == null)
|| assign != (match.group(3) != null)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
String ability = match.group(2);
var operation = unlock ? abilityEngine.purchase(view.input(), ability, view.revision())
: unlearn ? abilityEngine.unlearn(view.input(), ability, view.revision())
: abilityEngine.assign(view.input(), ability, Integer.parseInt(match.group(3)), view.revision());
operation.whenComplete((mutation, failure) -> {
if (closed || !view.input().stillCurrent()) return;
@@ -307,19 +317,22 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
player.sendMessage("Ability change could not be confirmed; reopen the character after session recovery.");
return;
}
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
player.sendMessage(abilityMessage(unlock, unlearn, ability, mutation.status()));
load(view.input(), 0);
});
}
private static String abilityMessage(boolean unlock, String ability, CharacterStorage.AbilityMutationStatus status) {
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
CharacterStorage.AbilityMutationStatus status) {
return switch (status) {
case APPLIED -> unlock ? "Learned " + ability + "." : "Assigned " + ability + ".";
case APPLIED -> unlock ? "Learned " + ability + "."
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
case UNCHANGED -> ability + " is already assigned there.";
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
case NO_POINTS -> "This character does not have enough unspent points.";
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
case NOT_LEARNED -> "Learn " + ability + " before assigning it.";
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
: "Learn " + ability + " before assigning it.";
case STALE -> "That character page is stale; reopen it before changing abilities.";
case COOLDOWN -> "That ability is still on cooldown.";
case UNAVAILABLE -> "That character or ability is unavailable.";
@@ -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,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();
@@ -93,6 +93,13 @@ public final class CombatExperience implements Listener, AutoCloseable {
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
}
/** Records target-specific, effective support; only a direct contributor can activate it at defeat. */
public void support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void damage(EntityDamageEvent event) {
if (closed || event.getFinalDamage() <= 0) return;
@@ -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);}
}
@@ -326,6 +326,26 @@ public final class CharacterStorage implements AutoCloseable {
});
}
/** 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) {
@@ -421,12 +441,19 @@ public final class CharacterStorage implements AutoCloseable {
/** 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(),
@@ -469,20 +496,37 @@ public final class CharacterStorage implements AutoCloseable {
}
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(), old.restrictedKills(), 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);
});
@@ -0,0 +1,198 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.FrostShard;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Frost Shard settings; publication remains a server-thread caller decision. */
public final class FrostShardSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "frost-shard-base-damage";
public static final String COOLDOWN = "frost-shard-cooldown-seconds";
public static final String RANGE = "frost-shard-range-blocks";
public static final String LIFETIME = "frost-shard-lifetime-seconds";
public static final String SPEED = "frost-shard-projectile-speed-blocks-per-tick";
public static final String AMPLIFIER = "frost-shard-slowness-amplifier";
public static final String SLOWNESS = "frost-shard-slowness-duration-seconds";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE, AMPLIFIER, SLOWNESS);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "5", RANGE, "32",
LIFETIME, "5", SPEED, "1", AMPLIFIER, "0", SLOWNESS, "2");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, FrostShard.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-frost-shard-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public FrostShardSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
FrostShardSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public FrostShard.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Frost Shard configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(AMPLIFIER)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static FrostShard.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(SPEED));
int amplifier = new java.math.BigInteger(values.get(AMPLIFIER)).intValueExact();
var slowness = seconds(values.get(SLOWNESS));
return new FrostShard.Settings(base, cooldown, range, lifetime, speed, amplifier, slowness);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Frost Shard settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,190 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Hex;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Hex settings; publication remains a server-thread caller decision. */
public final class HexSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "hex-cooldown-seconds";
public static final String RANGE = "hex-range-blocks";
public static final String DURATION = "hex-duration-seconds";
public static final String REDUCTION = "hex-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, RANGE, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "10", RANGE, "12", DURATION, "4", REDUCTION, "20");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Hex.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-hex-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public HexSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
HexSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Hex.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Hex configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Hex.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new Hex.Settings(cooldown, range, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Hex settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,191 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Inspire;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Inspire settings; publication remains a server-thread caller decision. */
public final class InspireSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE = "inspire-base-temporary-health";
public static final String COOLDOWN = "inspire-cooldown-seconds";
public static final String RANGE = "inspire-range-blocks";
public static final String DURATION = "inspire-duration-seconds";
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RANGE, DURATION);
private static final Map<String, String> DEFAULTS = Map.of(BASE, "4", COOLDOWN, "15", RANGE, "12", DURATION, "6");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Inspire.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-inspire-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public InspireSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
InspireSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Inspire configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Inspire.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE));
var cooldown = seconds(values.get(COOLDOWN));
var range = finite(values.get(RANGE));
var duration = seconds(values.get(DURATION));
return new Inspire.Settings(base, cooldown, range, duration);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Inspire settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,201 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Longbow;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Longbow settings; publication remains a server-thread caller decision. */
public final class LongbowSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "longbow-base-damage";
public static final String RECOVERY = "longbow-recovery-seconds";
public static final String MINIMUM_DRAW = "longbow-minimum-draw-ticks";
public static final String FULL_DRAW = "longbow-full-draw-ticks";
public static final String RANGE = "longbow-range-blocks";
public static final String LIFETIME = "longbow-lifetime-seconds";
public static final String FULL_SPEED = "longbow-full-projectile-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(BASE_DAMAGE, RECOVERY, MINIMUM_DRAW, FULL_DRAW, RANGE,
LIFETIME, FULL_SPEED);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "5", RECOVERY, "1",
MINIMUM_DRAW, "3", FULL_DRAW, "20", RANGE, "64", LIFETIME, "10", FULL_SPEED, "3");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Longbow.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-longbow-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public LongbowSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
LongbowSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Longbow.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Longbow configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE) || key.equals(MINIMUM_DRAW) || key.equals(FULL_DRAW)) {
return new java.math.BigInteger(raw).toString();
}
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Longbow.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var recovery = seconds(values.get(RECOVERY));
int minimumDraw = new java.math.BigInteger(values.get(MINIMUM_DRAW)).intValueExact();
int fullDraw = new java.math.BigInteger(values.get(FULL_DRAW)).intValueExact();
double range = finite(values.get(RANGE));
var lifetime = seconds(values.get(LIFETIME));
double speed = finite(values.get(FULL_SPEED));
return new Longbow.Settings(base, recovery, minimumDraw, fullDraw, range, lifetime, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Longbow settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,43 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for party capacity checks and book presentation. */
public final class PartySettings implements AutoCloseable {
public static final String KEY="party-max-size";public static final String DEFAULT="6";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,int maximum,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.Quickstep;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Quickstep settings; publication remains a server-thread caller decision. */
public final class QuickstepSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "quickstep-cooldown-seconds";
public static final String DISTANCE = "quickstep-distance-blocks";
public static final String SPEED = "quickstep-speed-blocks-per-tick";
private static final List<String> KEYS = List.of(COOLDOWN, DISTANCE, SPEED);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "5", DISTANCE, "4", SPEED, "1");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, Quickstep.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-quickstep-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public QuickstepSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
QuickstepSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public Quickstep.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Quickstep configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static Quickstep.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
double distance = finite(values.get(DISTANCE));
double speed = finite(values.get(SPEED));
return new Quickstep.Settings(cooldown, distance, speed);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Quickstep settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,167 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Feature-scoped Revive interruption proof; never authority to guess or mint an item. */
public final class RevivalStore implements AutoCloseable {
private static final int MAX_FILE_BYTES = 32_768;
private static final int MAX_FILES = 4096;
private static final Set<String> KEYS_V1 = Set.of("schema-version", "reservation-id", "caster-character-id",
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
private static final Set<String> KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative"))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false);
}
public Reservation {
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
if (createdAt.getNano() != 0 || sourceSlot < 0 || sourceSlot > 40 || sourceSlot > 35 && sourceSlot != 40
|| sourceAmount < 1 || sourceAmount > 99 || destinationSlot < -1 || destinationSlot > 40
|| destinationSlot > 35 && destinationSlot != 40 || destinationSlot == sourceSlot) {
throw new IllegalArgumentException("Invalid revival reservation");
}
}
}
private final Path directory;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-revival-persistence"));
public RevivalStore(Path pluginDataDirectory) {
directory = Objects.requireNonNull(pluginDataDirectory).resolve("revivals").toAbsolutePath();
}
public CompletableFuture<Optional<Reservation>> reserve(Reservation reservation) {
Objects.requireNonNull(reservation);
return submit(() -> {
Path file = file(reservation.character());
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
safeDirectory();
Path temporary = directory.resolve("." + reservation.character() + "." + UUID.randomUUID() + ".tmp");
try {
Files.writeString(temporary, encode(reservation), StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE);
} finally { Files.deleteIfExists(temporary); }
return Optional.of(reservation);
});
}
public CompletableFuture<Optional<Reservation>> find(UUID character) {
Objects.requireNonNull(character);
return submit(() -> load(file(character)));
}
public CompletableFuture<List<Reservation>> target(UUID player) {
Objects.requireNonNull(player);
return submit(() -> {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of();
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
var result = new ArrayList<Reservation>();
try (var files = Files.list(directory)) {
for (var path : files.limit(MAX_FILES + 1).toList()) {
if (result.size() >= MAX_FILES) throw new IOException("Too many revival reservations");
if (!path.getFileName().toString().endsWith(".yml")) continue;
var reservation = load(path).orElseThrow(() -> new IOException("Missing revival reservation"));
if (reservation.targetPlayer().equals(player)) result.add(reservation);
}
}
return List.copyOf(result);
});
}
public CompletableFuture<Boolean> clear(UUID character, UUID reservation) {
Objects.requireNonNull(character); Objects.requireNonNull(reservation);
return submit(() -> {
var found = load(file(character));
if (found.isEmpty() || !found.orElseThrow().id().equals(reservation)) return false;
return Files.deleteIfExists(file(character));
});
}
private void safeDirectory() throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unsafe revival directory");
}
} else Files.createDirectories(directory);
}
private Path file(UUID character) { return directory.resolve(character + ".yml"); }
private Optional<Reservation> load(Path file) throws IOException {
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)
|| Files.size(file) > MAX_FILE_BYTES) throw new IOException("Unsafe revival reservation");
Object decoded;
try {
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys");
try {
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))throw new IllegalArgumentException("schema");
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative"));
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
return Optional.of(value);
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
}
private static String encode(Reservation value) {
var map = new LinkedHashMap<String, Object>();
map.put("schema-version", "2"); map.put("reservation-id", value.id().toString());
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
map.put("administrative", Boolean.toString(value.administrative()));
return new Yaml().dump(map);
}
private static String text(Map<?, ?> map, String key) {
Object value = map.get(key); if (!(value instanceof String text) || text.isBlank()) throw new IllegalArgumentException(key); return text;
}
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
private static boolean bool(Map<?,?> map,String key){String value=text(map,key);if(!value.equals("true")&&!value.equals("false"))throw new IllegalArgumentException(key);return Boolean.parseBoolean(value);}
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
private <T> CompletableFuture<T> submit(Work<T> work) {
try {
return CompletableFuture.supplyAsync(() -> { try { return work.run(); } catch (IOException error) { throw new CompletionException(error); } }, worker);
} catch (RejectedExecutionException rejected) { return CompletableFuture.failedFuture(rejected); }
}
@Override public void close() { worker.shutdown(); }
}
@@ -0,0 +1,152 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for roleplay availability and coordinated exits. */
public final class RoleplaySettings implements AutoCloseable {
public static final String KEY = "roleplay-disabled";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(boolean disabled, long sequence) { }
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-roleplay-configuration"));
private volatile boolean disabled;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public RoleplaySettings(Path directory, boolean initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
RoleplaySettings(Path directory, boolean initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
disabled = initial;
}
public static boolean parse(Object value) {
if (value == null) return false;
if (value instanceof Boolean parsed) return parsed;
throw new IllegalArgumentException(KEY + " must be true or false");
}
public boolean disabled() { return disabled; }
public String value() { return Boolean.toString(disabled); }
public CompletionStage<Update> set(String raw) {
if (!"true".equals(raw) && !"false".equals(raw)) {
throw new IllegalArgumentException(KEY + " must be true or false");
}
boolean accepted = Boolean.parseBoolean(raw);
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(accepted);
return new Update(accepted, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Roleplay configuration save refused or failed; effective setting unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
disabled = update.disabled();
publishedSequence = update.sequence();
return true;
}
private void save(boolean value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20);
options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
bytes = input.readNBytes(MAX_BYTES + 1);
}
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
values.put(key, entry.getValue());
}
}
}
values.put(KEY, value);
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true;
worker.shutdown();
if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,187 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.SecondWind;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable SecondWind settings; publication remains a server-thread caller decision. */
public final class SecondWindSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_HEALING = "second-wind-base-healing";
public static final String COOLDOWN = "second-wind-cooldown-seconds";
private static final List<String> KEYS = List.of(BASE_HEALING, COOLDOWN);
private static final Map<String, String> DEFAULTS = Map.of(BASE_HEALING, "6", COOLDOWN, "15");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, SecondWind.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-second-wind-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SecondWindSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SecondWindSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public SecondWind.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("SecondWind configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_HEALING)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static SecondWind.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_HEALING));
var cooldown = seconds(values.get(COOLDOWN));
return new SecondWind.Settings(base, cooldown);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("SecondWind settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,209 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable paired resource-pack overrides; each publication carries the latest complete saved snapshot. */
public final class SheetResourcePackSettings implements AutoCloseable {
public static final String URL_KEY = "sheet-resource-pack-url";
public static final String SHA1_KEY = "sheet-resource-pack-sha1";
private static final int MAX_BYTES = 1_048_576;
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Defaults(String url, String sha1) {
public Defaults { url = effectiveUrl(url, new DefaultsMarker()); sha1 = normalizeHash(sha1, false); }
}
private static final class DefaultsMarker { }
public record Snapshot(String configuredUrl, String configuredSha1, String url, String sha1) { }
public record Update(Snapshot snapshot, long sequence) { }
private final Path directory;
private final Defaults defaults;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker;
private volatile Snapshot effective;
private String persistedUrl;
private String persistedSha1;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SheetResourcePackSettings(Path directory, Object configuredUrl, Object configuredSha1, Defaults defaults) {
this(directory, raw(configuredUrl, URL_KEY), raw(configuredSha1, SHA1_KEY), defaults,
(from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
SheetResourcePackSettings(Path directory, String configuredUrl, String configuredSha1, Defaults defaults, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.defaults = Objects.requireNonNull(defaults);
this.replacement = Objects.requireNonNull(replacement);
effective = snapshot(configuredUrl, configuredSha1);
worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "fantasy-sheet-resource-pack-configuration"));
persistedUrl = configuredUrl;
persistedSha1 = configuredSha1;
}
public static Defaults packagedDefaults() {
try (var input = SheetResourcePackSettings.class.getResourceAsStream("/sheet-resource-pack.properties")) {
if (input == null) throw new IOException("Missing packaged sheet resource-pack metadata");
var values = new Properties(); values.load(input);
return new Defaults(values.getProperty("url"), values.getProperty("sha1"));
} catch (IOException | IllegalArgumentException failure) {
throw new IllegalStateException("Invalid packaged sheet resource-pack metadata", failure);
}
}
private static String raw(Object value, String key) {
if (value == null) return "";
if (value instanceof String text) return text;
throw new IllegalArgumentException(key + " must be a string");
}
public static String effectiveUrl(String configured, Defaults defaults) {
Objects.requireNonNull(defaults);
return configured == null || configured.isEmpty() ? defaults.url() : validateUrl(configured);
}
private static String effectiveUrl(String value, DefaultsMarker ignored) { return validateUrl(value); }
private static String validateUrl(String value) {
if (value == null || value.length() > 2048 || value.chars().anyMatch(Character::isWhitespace)) {
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL");
}
try {
var uri = URI.create(value);
if (!"https".equals(uri.getScheme()) || uri.getHost() == null || uri.getHost().isBlank()
|| uri.getRawUserInfo() != null || uri.getRawFragment() != null
|| uri.getRawPath() == null || uri.getRawPath().isBlank()) {
throw new IllegalArgumentException(URL_KEY + " must be an absolute HTTPS asset URL without credentials or fragment");
}
return uri.toASCIIString();
} catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL", invalid);
}
}
public static String effectiveSha1(String configured, Defaults defaults) {
Objects.requireNonNull(defaults);
return configured == null || configured.isEmpty() ? defaults.sha1() : normalizeHash(configured, false);
}
private static String normalizeHash(String value, boolean allowEmpty) {
if (allowEmpty && (value == null || value.isEmpty())) return "";
if (value == null || !value.matches("[a-fA-F0-9]{40}")) {
throw new IllegalArgumentException(SHA1_KEY + " must be exactly 40 hexadecimal characters");
}
return value.toLowerCase(java.util.Locale.ROOT);
}
private Snapshot snapshot(String configuredUrl, String configuredSha1) {
String url = configuredUrl == null ? "" : configuredUrl;
String sha1 = configuredSha1 == null ? "" : normalizeHash(configuredSha1, true);
return new Snapshot(url, sha1, effectiveUrl(url, defaults), effectiveSha1(sha1, defaults));
}
public String url() { return effective.url(); }
public String sha1() { return effective.sha1(); }
public String value(String key) {
var state = effective;
return switch (key) {
case URL_KEY -> state.url();
case SHA1_KEY -> state.sha1();
default -> throw new IllegalArgumentException("Unknown sheet resource-pack key");
};
}
public CompletionStage<Update> set(String key, String raw) {
if (!URL_KEY.equals(key) && !SHA1_KEY.equals(key)) throw new IllegalArgumentException("Unknown sheet resource-pack key");
if (raw == null) throw new IllegalArgumentException(key + " requires a value");
if (URL_KEY.equals(key) && !raw.isEmpty()) validateUrl(raw);
if (SHA1_KEY.equals(key)) raw = normalizeHash(raw, true);
final String accepted = raw;
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
save(key, accepted);
if (URL_KEY.equals(key)) persistedUrl = accepted; else persistedSha1 = accepted;
return new Update(snapshot(persistedUrl, persistedSha1), ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(new IOException(
"Sheet resource-pack configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
public synchronized boolean publish(Update update) {
if (update.sequence() <= publishedSequence) return false;
effective = update.snapshot();
publishedSequence = update.sequence();
return true;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> values = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String name)) throw new IOException("Invalid configuration key");
values.put(name, entry.getValue());
}
}
}
values.put(key, value);
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
}
@@ -0,0 +1,192 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.ShieldBash;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Shield Bash settings; publication remains a server-thread caller decision. */
public final class ShieldBashSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String BASE_DAMAGE = "shield-bash-base-damage";
public static final String COOLDOWN = "shield-bash-cooldown-seconds";
public static final String RANGE = "shield-bash-range-blocks";
public static final String HORIZONTAL = "shield-bash-horizontal-knockback";
public static final String VERTICAL = "shield-bash-minimum-vertical-knockback";
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, RANGE, HORIZONTAL, VERTICAL);
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "4", COOLDOWN, "6", RANGE, "3",
HORIZONTAL, "0.8", VERTICAL, "0.35");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, ShieldBash.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-shield-bash-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public ShieldBashSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
ShieldBashSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public ShieldBash.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Shield Bash configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
if (key.equals(BASE_DAMAGE)) return new java.math.BigInteger(raw).toString();
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static ShieldBash.Settings parse(Map<String, String> values) {
try {
var base = new java.math.BigInteger(values.get(BASE_DAMAGE));
var cooldown = seconds(values.get(COOLDOWN));
return new ShieldBash.Settings(base, cooldown, finite(values.get(RANGE)),
finite(values.get(HORIZONTAL)), finite(values.get(VERTICAL)));
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Shield Bash settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,188 @@
package games.dmg.fantasystick.persistence;
import games.dmg.fantasystick.ability.StoneSkin;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Serialized durable Stone Skin settings; publication remains a server-thread caller decision. */
public final class StoneSkinSettings implements AutoCloseable {
private static final int MAX_BYTES = 1_048_576;
public static final String COOLDOWN = "stone-skin-cooldown-seconds";
public static final String DURATION = "stone-skin-duration-seconds";
public static final String REDUCTION = "stone-skin-damage-reduction-percent";
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, REDUCTION);
private static final Map<String, String> DEFAULTS = Map.of(COOLDOWN, "14", DURATION, "4", REDUCTION, "25");
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
public record Update(String key, String value, StoneSkin.Settings settings,
Map<String, String> values, long sequence) {
public Update { values = Map.copyOf(values); }
}
private final Path directory;
private final Replacement replacement;
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-stone-skin-configuration"));
private volatile Map<String, String> effectiveValues;
private Map<String, String> persistedValues;
private long persistedSequence;
private long publishedSequence;
private boolean closed;
private int pending;
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public StoneSkinSettings(Path directory, Map<String, String> initial) {
this(directory, initial, (from, to) -> Files.move(from, to,
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
}
StoneSkinSettings(Path directory, Map<String, String> initial, Replacement replacement) {
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
this.replacement = Objects.requireNonNull(replacement);
var copy = new LinkedHashMap<String, String>();
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
parse(copy);
effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
}
public static List<String> keys() { return KEYS; }
public static String defaultValue(String key) { return DEFAULTS.get(key); }
public String value(String key) { return effectiveValues.get(key); }
public StoneSkin.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
}
public CompletionStage<Update> set(String key, String value) {
Objects.requireNonNull(key); Objects.requireNonNull(value);
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
String accepted = canonical(key, value);
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted);
parse(preview); // Fail malformed/out-of-range command input before queueing accepted persistence.
synchronized (this) {
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
pending++;
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
var next = new LinkedHashMap<>(persistedValues);
next.put(key, accepted);
var parsed = parse(next);
save(key, accepted);
persistedValues = Map.copyOf(next);
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
} catch (IOException | RuntimeException failure) {
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
? failure : new IOException("Stone Skin configuration save refused or failed; effective settings unchanged"));
} finally { finished(); }
}, worker).minimalCompletionStage();
} catch (RejectedExecutionException rejected) {
finished();
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
}
}
private static String canonical(String key, String raw) {
try {
return new java.math.BigDecimal(raw).stripTrailingZeros().toPlainString();
} catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
}
public static StoneSkin.Settings parse(Map<String, String> values) {
try {
var cooldown = seconds(values.get(COOLDOWN));
var duration = seconds(values.get(DURATION));
var reduction = new java.math.BigDecimal(values.get(REDUCTION));
return new StoneSkin.Settings(cooldown, duration, reduction);
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
throw new IllegalArgumentException("Stone Skin settings must use supported numeric values", invalid);
}
}
private static Duration seconds(String value) {
var nanos = new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
return Duration.ofNanos(nanos);
}
private static double finite(String value) {
double result = new java.math.BigDecimal(value).doubleValue();
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
return result;
}
private void save(String key, String value) throws IOException {
Files.createDirectories(directory);
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
var file = directory.resolve("config.yml");
var lockFile = directory.resolve(".config.lock");
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
if (lock == null) throw new IOException("Configuration busy");
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
var yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> document = new LinkedHashMap<>();
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
byte[] bytes;
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
if (loaded != null) {
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
for (var entry : map.entrySet()) {
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
document.put(savedKey, entry.getValue());
}
}
}
document.put(key, value);
byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
}
replacement.move(temporary, file);
} finally { Files.deleteIfExists(temporary); }
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
}
private synchronized void finished() {
pending--;
if (closed && pending == 0) drained.complete(null);
}
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
@Override public synchronized void close() {
closed = true; worker.shutdown(); if (pending == 0) drained.complete(null);
}
}
@@ -0,0 +1,42 @@
package games.dmg.fantasystick.persistence;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/** Durable save-before-publish policy for character-switch delay checks and later exits. */
public final class SwitchDelaySettings implements AutoCloseable {
public static final String KEY="character-swap-cooldown-seconds";public static final String DEFAULT="900";
private static final int MAX_BYTES=1_048_576;
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
public record Update(String value,Duration delay,long sequence){}
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-switch-delay-configuration"));
private volatile Duration effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
public SwitchDelaySettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
SwitchDelaySettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(900);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds",invalid);}}
private static Duration validate(Duration value){if(value.isNegative()||value.getNano()!=0)throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds");try{var deadline=java.time.Instant.now().plus(value);if(deadline.atOffset(java.time.ZoneOffset.UTC).getYear()>9999)throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range");return value;}catch(java.time.DateTimeException|ArithmeticException invalid){throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range",invalid);}}
public Duration delay(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Character switch configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.delay());publishedSequence=update.sequence();}
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
}
@@ -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; }
}
@@ -10,8 +10,10 @@ import java.util.UUID;
/** Server-thread bounded contribution ledger keyed by target and originating character. */
public final class CombatContributions implements AutoCloseable {
private static final java.time.Duration SUPPORT_LIFETIME = java.time.Duration.ofSeconds(10);
private final CombatRewardRules rules;
private final Map<UUID, Map<UUID, Instant>> targets = new HashMap<>();
private final Map<UUID, Map<UUID, Map<UUID, Instant>>> supports = new HashMap<>();
private boolean closed;
public CombatContributions(CombatRewardRules rules) { this.rules = Objects.requireNonNull(rules); }
@@ -23,26 +25,53 @@ public final class CombatContributions implements AutoCloseable {
(previous, next) -> previous.isAfter(next) ? previous : next);
}
public void support(UUID target, UUID aidedCharacter, UUID supportingCharacter, Instant acceptedAt) {
Objects.requireNonNull(target); Objects.requireNonNull(aidedCharacter);
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(acceptedAt);
if (closed) return;
supports.computeIfAbsent(target, ignored -> new HashMap<>())
.computeIfAbsent(aidedCharacter, ignored -> new HashMap<>())
.merge(supportingCharacter, acceptedAt, (previous, next) -> previous.isAfter(next) ? previous : next);
}
public Set<UUID> consume(UUID target, Instant deathAt) {
Objects.requireNonNull(target); Objects.requireNonNull(deathAt);
if (closed) return Set.of();
var contributions = targets.remove(target);
var assistance = supports.remove(target);
if (contributions == null) return Set.of();
var eligible = new LinkedHashSet<UUID>();
contributions.forEach((character, acceptedAt) -> {
if (rules.contributes(acceptedAt, deathAt)) eligible.add(character);
});
for (UUID aided : Set.copyOf(eligible)) {
var supporters = assistance == null ? null : assistance.get(aided);
if (supporters != null) supporters.forEach((supporter, acceptedAt) -> {
if (supports(acceptedAt, deathAt)) eligible.add(supporter);
});
}
return Set.copyOf(eligible);
}
public void invalidate(UUID target) { if (!closed) targets.remove(Objects.requireNonNull(target)); }
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);
}
@Override public void close() { closed = true; targets.clear(); }
private static boolean supports(Instant acceptedAt, Instant defeatAt) {
return !acceptedAt.isAfter(defeatAt) && !defeatAt.isAfter(acceptedAt.plus(SUPPORT_LIFETIME));
}
@Override public void close() { closed = true; targets.clear(); supports.clear(); }
}
@@ -0,0 +1,121 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
public final class AdminCharacterIssuer implements AutoCloseable {
public enum Outcome { DELIVERED, FULL, BUSY, STALE, SAVE_FAILED, RETAINED }
public record Failure(UUID player, UUID character, String operation, Throwable cause) { }
private final CharacterStorage storage;
private final Supplier<CharacterRecord> allocate;
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> failures;
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
this.storage = Objects.requireNonNull(storage);
this.allocate = Objects.requireNonNull(allocate);
this.nextTick = Objects.requireNonNull(nextTick);
this.failures = Objects.requireNonNull(failures);
}
/** Must be entered on the server thread. Completion is delivered on that thread. */
public void issue(Player player, Consumer<Outcome> response) {
Objects.requireNonNull(player); Objects.requireNonNull(response);
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
int slot = player.getInventory().firstEmpty();
if (slot < 0) { response.accept(Outcome.FULL); return; }
var attempt = new Attempt(player, slot, response);
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
try {
attempt.record = allocate.get();
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
} catch (RuntimeException failure) {
pending.remove(attempt.playerId, attempt);
abandon(attempt);
report(attempt, "prepare", failure);
response.accept(Outcome.SAVE_FAILED);
}
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.playerId) != attempt) { abandon(attempt); return; }
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
catch (RuntimeException rejected) {
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
}
}
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
if (failure != null) {
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
}
var occupying = attempt.player.getInventory().getItem(attempt.slot);
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|| occupying != null && !occupying.getType().isAir()) {
abandon(attempt); respond(attempt, Outcome.STALE); return;
}
var claimed = batch.claimForDelivery();
if (claimed.isEmpty() || claimed.orElseThrow().size() != 1
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
}
try {
var sheet = new CharacterSheets().create(attempt.record);
attempt.player.getInventory().setItem(attempt.slot, sheet);
attempt.delivered = true;
} catch (RuntimeException uncertain) {
report(attempt, "delivery", uncertain);
respond(attempt, Outcome.RETAINED);
return;
}
try { attempt.player.updateInventory(); }
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
respond(attempt, Outcome.DELIVERED);
}
private void respond(Attempt attempt, Outcome outcome) {
try { attempt.response.accept(outcome); }
catch (RuntimeException failure) { report(attempt, "response", failure); }
}
private void abandon(Attempt attempt) {
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
attempt.preparation.abandon().thenAccept(cleanup -> {
if (!cleanup.retained().isEmpty()) report(attempt, "cleanup-retained", null);
});
}
private void report(Attempt attempt, String operation, Throwable cause) {
try { failures.accept(new Failure(attempt.playerId, attempt.record == null ? null : attempt.record.id(), operation, cause)); }
catch (RuntimeException ignored) { }
}
@Override public void close() {
closed = true;
for (var attempt : List.copyOf(pending.values())) {
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
}
}
private static final class Attempt {
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
}
}
@@ -0,0 +1,68 @@
package games.dmg.fantasystick.session;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.items.TailBooks;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.RevivalStore;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import net.kyori.adventure.text.Component;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
/** Admin-owned, expiring Tail selection with authoritative consume-once restoration. */
public final class AdminRevival implements Listener, AutoCloseable {
public interface PopupFactory { Inventory create(int size, Component title); }
public interface Scheduler { AutoCloseable later(Duration delay, Runnable action); }
private record Candidate(int slot, ItemStack item, TailBooks.Identity identity) { }
private record Choice(Candidate candidate, CharacterRecord record) { }
private final CharacterStorage characters; private final RevivalStore reservations; private final Clock clock;
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
private final class View implements AutoCloseable {
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);}
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}}
}
public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);}
public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout);}));}
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())continue;var identity=tails.identify(item);if(identity.isPresent())result.add(new Candidate(slot,item.clone(),identity.orElseThrow()));}return List.copyOf(result);}
private boolean eligible(CharacterRecord record,TailBooks.Identity id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());}
private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);}
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout));views.put(inventory,view);admin.openInventory(inventory);}
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);}
@EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&event.getRawSlots().stream().anyMatch(slot->slot<view.inventory.getSize()))event.setCancelled(true);}
@EventHandler(priority=EventPriority.MONITOR) public void close(org.bukkit.event.inventory.InventoryCloseEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();}
}
@@ -21,6 +21,31 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Failure> diagnostics;
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
private games.dmg.fantasystick.ability.AbilityRegistry abilities;
private games.dmg.fantasystick.persistence.LongbowSettings longbowSettings;
private games.dmg.fantasystick.ability.Longbow longbow;
private games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings;
private games.dmg.fantasystick.ability.Quickstep quickstep;
private games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings;
private games.dmg.fantasystick.ability.SecondWind secondWind;
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
private games.dmg.fantasystick.ability.StoneSkin stoneSkin;
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
private games.dmg.fantasystick.ability.ShieldBash shieldBash;
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
private games.dmg.fantasystick.ability.Hex hex;
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
private games.dmg.fantasystick.ability.Inspire inspire;
private AdminCharacterIssuer characterIssuer;
private AdminRevival adminRevival;
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
private games.dmg.fantasystick.persistence.PartySettings partySettings;
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
@@ -38,6 +63,173 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
this.settings = Objects.requireNonNull(settings);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings);
this.frostSettings = Objects.requireNonNull(frostSettings);
this.frostShard = Objects.requireNonNull(frostShard);
this.abilities = Objects.requireNonNull(abilities);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard, abilities);
this.longbowSettings = Objects.requireNonNull(longbowSettings);
this.longbow = Objects.requireNonNull(longbow);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, abilities);
this.quickstepSettings = Objects.requireNonNull(quickstepSettings);
this.quickstep = Objects.requireNonNull(quickstep);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, abilities);
this.secondWindSettings = Objects.requireNonNull(secondWindSettings);
this.secondWind = Objects.requireNonNull(secondWind);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind, abilities);
this.stoneSkinSettings = Objects.requireNonNull(stoneSkinSettings);
this.stoneSkin = Objects.requireNonNull(stoneSkin);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, abilities);
this.shieldBashSettings = Objects.requireNonNull(shieldBashSettings);
this.shieldBash = Objects.requireNonNull(shieldBash);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.persistence.HexSettings hexSettings,
games.dmg.fantasystick.ability.Hex hex,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, abilities);
this.hexSettings = Objects.requireNonNull(hexSettings); this.hex = Objects.requireNonNull(hex);
}
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics,
games.dmg.fantasystick.persistence.AdventureSettings settings,
games.dmg.fantasystick.persistence.FrostShardSettings frostSettings,
games.dmg.fantasystick.ability.FrostShard frostShard,
games.dmg.fantasystick.persistence.LongbowSettings longbowSettings,
games.dmg.fantasystick.ability.Longbow longbow,
games.dmg.fantasystick.persistence.QuickstepSettings quickstepSettings,
games.dmg.fantasystick.ability.Quickstep quickstep,
games.dmg.fantasystick.persistence.SecondWindSettings secondWindSettings,
games.dmg.fantasystick.ability.SecondWind secondWind,
games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings,
games.dmg.fantasystick.ability.StoneSkin stoneSkin,
games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings,
games.dmg.fantasystick.ability.ShieldBash shieldBash,
games.dmg.fantasystick.persistence.HexSettings hexSettings,
games.dmg.fantasystick.ability.Hex hex,
games.dmg.fantasystick.persistence.InspireSettings inspireSettings,
games.dmg.fantasystick.ability.Inspire inspire,
games.dmg.fantasystick.ability.AbilityRegistry abilities) {
this(receipts, sessions, server, nextTick, diagnostics, settings, frostSettings, frostShard,
longbowSettings, longbow, quickstepSettings, quickstep, secondWindSettings, secondWind,
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex, abilities);
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
}
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
}
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
characterIssuer = Objects.requireNonNull(issuer);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
@@ -48,6 +240,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
configuration(sender, args);
return true;
}
if (args.length > 0 && args[0].equals("give")) {
give(sender, args);
return true;
}
if (args.length > 0 && args[0].equals("revive")) {
adminRevive(sender,args);return true;
}
UUID owner;
try {
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
@@ -108,16 +307,267 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
return true;
}
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
private void give(CommandSender sender, String[] args) {
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
}
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
&& player.getName().equalsIgnoreCase(args[1])).toList();
if (matches.size() != 1) {
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
}
var target = matches.getFirst();
characterIssuer.issue(target, outcome -> {
if (closed || !sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(switch (outcome) {
case DELIVERED -> "Issued one new character sheet to " + target.getName() + ".";
case FULL -> "Character issue refused: " + target.getName() + " has no inventory space. No character was created.";
case BUSY -> "Character issue refused: another delivery to " + target.getName() + " is pending.";
case STALE -> "Character issue refused: target disconnected or the reserved inventory slot changed. No sheet was delivered.";
case SAVE_FAILED -> "Character issue failed before delivery; no sheet was delivered. See server diagnostics.";
case RETAINED -> "Character issue has ambiguous retained state; no retry was performed. See server diagnostics.";
});
});
}
private void configuration(CommandSender sender, String[] args) {
if (args.length < 2 || args.length > 3 || !args[1].equals("adventure-mode")
|| args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]");
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
longbowConfiguration(sender, args); return;
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
quickstepConfiguration(sender, args); return;
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
secondWindConfiguration(sender, args); return;
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
stoneSkinConfiguration(sender, args); return;
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
shieldBashConfiguration(sender, args); return;
}
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
hexConfiguration(sender, args); return;
}
if (inspireSettings != null && games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])) {
inspireConfiguration(sender, args); return;
}
if (frostSettings == null || !games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
configurationUsage(sender); return;
}
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
try { operation = frostSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Frost Shard setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
frostShard.configure(saved.settings());
abilities.replace(frostShard.definition());
frostSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
: "Frost Shard configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void longbowConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
try { operation = longbowSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Longbow setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
longbow.configure(saved.settings());
abilities.replace(longbow.definition());
longbowSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
: "Longbow configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void quickstepConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
try { operation = quickstepSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Quickstep setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
quickstepSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
: "Quickstep configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void hexConfiguration(CommandSender sender, String[] args) {
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex setting is invalid; the effective value is unchanged.");return;}
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;
try{nextTick.accept(()->{if(closed)return;if(failure==null){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.publish(saved);}
if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;
sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Hex casts; active effects and deadlines are unchanged."
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
}
private void shieldBashConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
try { operation = shieldBashSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
: "Shield Bash configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
try { operation = stoneSkinSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Stone Skin setting is invalid; the effective value is unchanged."); return; }
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
: "Stone Skin configuration save failed; the effective setting is unchanged.");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void secondWindConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) {
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
try { operation = secondWindSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Second Wind setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
secondWindSettings.publish(saved);
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
: "Second Wind configuration save failed; the effective setting is unchanged.");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void adventureConfiguration(CommandSender sender, String[] args) {
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
}
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
if (args.length == 2) {
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)");
return;
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
}
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-adventure-mode", failure);
@@ -134,14 +584,85 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private static void configurationUsage(CommandSender sender) {
sender.sendMessage("Usage: /fantasystick config <setting> [value]");
}
private void report(UUID owner, String operation, Throwable cause) {
try { diagnostics.accept(new Failure(owner, operation, cause)); }
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
}
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.SecondWindSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.StoneSkinSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.ShieldBashSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.HexSettings.keys());
keys.addAll(games.dmg.fantasystick.persistence.InspireSettings.keys());
return matching(keys, args[1]);
}
if (args.length == 3 && args[0].equals("config")) {
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
String current = frostSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
String current = longbowSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.LongbowSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (quickstepSettings != null && games.dmg.fantasystick.persistence.QuickstepSettings.keys().contains(args[1])) {
String current = quickstepSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.QuickstepSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (secondWindSettings != null && games.dmg.fantasystick.persistence.SecondWindSettings.keys().contains(args[1])) {
String current = secondWindSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.SecondWindSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (stoneSkinSettings != null && games.dmg.fantasystick.persistence.StoneSkinSettings.keys().contains(args[1])) {
String current = stoneSkinSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.StoneSkinSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (shieldBashSettings != null && games.dmg.fantasystick.persistence.ShieldBashSettings.keys().contains(args[1])) {
String current = shieldBashSettings.value(args[1]);
String defaults = games.dmg.fantasystick.persistence.ShieldBashSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if (hexSettings != null && games.dmg.fantasystick.persistence.HexSettings.keys().contains(args[1])) {
String current = hexSettings.value(args[1]); String defaults = games.dmg.fantasystick.persistence.HexSettings.defaultValue(args[1]);
return matching(current.equals(defaults) ? List.of(current) : List.of(current, defaults), args[2]);
}
if(inspireSettings!=null&&games.dmg.fantasystick.persistence.InspireSettings.keys().contains(args[1])){String current=inspireSettings.value(args[1]);String defaults=games.dmg.fantasystick.persistence.InspireSettings.defaultValue(args[1]);return matching(current.equals(defaults)?List.of(current):List.of(current,defaults),args[2]);}
}
return List.of();
}
@Override public void close() { closed = true; }
private static List<String> matching(java.util.Collection<String> values, String prefix) {
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
}
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
}
@@ -343,8 +343,14 @@ 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")
? event.getPlayer().getTargetEntity(64)
: selected.ability().equals("inspire")
? event.getPlayer().isSneaking() ? event.getPlayer() : event.getPlayer().getTargetEntity(64)
: null;
emit(event.getPlayer(), event.getItem(), selected, ControlInput.Gesture.USE, target, 0, 0);
}
else if (selected != null && selected.ability().equals("longbow")) beginDraw(event.getPlayer(), event.getItem(), selected);
}
}
@@ -434,8 +440,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);
}
}
}
@@ -449,7 +458,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());
}
}
@@ -38,7 +38,8 @@ public final class EquipmentSessions implements AutoCloseable {
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;
@@ -91,6 +92,7 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
org.bukkit.Location entryLocation;
@@ -118,7 +120,8 @@ 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);
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
this.nextTick = Objects.requireNonNull(nextTick);
@@ -142,6 +145,19 @@ 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");
@@ -267,6 +283,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;
}
@@ -326,6 +346,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) {
@@ -337,7 +361,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 "
@@ -376,6 +400,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) {
@@ -401,6 +429,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]);
@@ -454,6 +484,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;
@@ -463,6 +494,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);
}
@@ -607,6 +639,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;
@@ -703,19 +736,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);
+71
View File
@@ -1,6 +1,14 @@
# 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
@@ -18,3 +26,66 @@ 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
+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 character sheets, inspect/recover sessions or configure Fantasy Stick.
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
permission: fantasystick.admin
permissions:
fantasystick.admin:
description: Inspect/recover equipment sessions and configure fantasy mode.
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
default: op
@@ -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,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());
}
@@ -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","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", "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);
@@ -73,6 +73,8 @@ class FantasyStickLifecycleNativeTest {
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
"Discovery must not become active before successful recipe registration");
assertNotNull(fixture.command);
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
assertTrue(fixture.tasks.isEmpty());
@@ -138,7 +140,7 @@ class FantasyStickLifecycleNativeTest {
@Test
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer", "RecipeDiscovery")) {
boolean collision = stage.equals("collision");
try (var fixture = new Fixture(data)) {
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
@@ -173,6 +175,10 @@ class FantasyStickLifecycleNativeTest {
try (var fixture = new Fixture(directory)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
((games.dmg.fantasystick.crafting.RecipeDiscovery) Fixture.field(fixture.plugin, "recipeDiscovery"))
.discover(actor.player);
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), actor.knownRecipes);
assertEquals(1, actor.recipeAwards);
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
assertTrue(new CharacterSheets().identify(fixture.recipes.get(CharacterSheets.RECIPE).getResult()).isEmpty());
var cancelled = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
@@ -1102,6 +1108,7 @@ class FantasyStickLifecycleNativeTest {
final List<BukkitTask> timers = new ArrayList<>();
final List<Runnable> pulses = new ArrayList<>();
final List<Runnable> delayed = new ArrayList<>();
final List<Long> delayedTicks = new ArrayList<>();
com.comphenix.protocol.events.PacketListener packetListener;
final List<java.util.concurrent.ExecutorService> workers = new ArrayList<>();
final Thread main = Thread.currentThread();
@@ -1173,7 +1180,7 @@ class FantasyStickLifecycleNativeTest {
case "runTaskLater" -> {
schedulerCalls.incrementAndGet();
assertTrue(((org.bukkit.plugin.Plugin) args[0]).isEnabled());
delayed.add((Runnable) args[1]);
delayed.add((Runnable) args[1]); delayedTicks.add((Long) args[2]);
var task = mock(BukkitTask.class); timers.add(task); yield task;
}
default -> throw new AssertionError("Unexpected scheduler call " + name);
@@ -1187,6 +1194,8 @@ class FantasyStickLifecycleNativeTest {
case "getOnlinePlayers" -> List.copyOf(players.values());
case "getPlayer" -> { assertSame(main, Thread.currentThread()); playerLookups++; yield players.get(args[0]); }
case "getPluginCommand" -> command;
case "createInventory" -> new org.bukkit.craftbukkit.inventory.CraftInventoryCustom(
(org.bukkit.inventory.InventoryHolder) args[0], (Integer) args[1], (net.kyori.adventure.text.Component) args[2]);
case "addRecipe" -> {
order.add("recipe");
if ("recipe".equals(failRegistrationAt)) throw new IllegalStateException("Recipe registration failed");
@@ -1262,7 +1271,7 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.core.marker().endsWith("|PACKED"));
}
private void captureWorkers(JavaPlugin instance) {
for (String name : List.of("characters", "receipts", "adventureSettings")) {
for (String name : List.of("characters", "receipts", "adventureSettings", "revivals")) {
var store = field(instance, name);
if (store != null) {
var worker = (java.util.concurrent.ExecutorService) field(store, "worker");
@@ -1297,8 +1306,13 @@ class FantasyStickLifecycleNativeTest {
final org.bukkit.World world = mock(org.bukkit.World.class);
final List<String> messages = new ArrayList<>();
final List<org.bukkit.inventory.ItemStack> books = new ArrayList<>();
final List<org.bukkit.inventory.Inventory> inventories = new ArrayList<>();
int inventoryCloses;
final List<Integer> particles = new ArrayList<>();
final List<org.bukkit.Sound> sounds = new ArrayList<>();
final List<Object[]> resourcePacks = new ArrayList<>();
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
@@ -1361,8 +1375,19 @@ class FantasyStickLifecycleNativeTest {
case "getScoreboard" -> craftBoard;
case "sendMessage" -> { messages.add((String) args[0]); yield null; }
case "openBook" -> { books.add(((org.bukkit.inventory.ItemStack) args[0]).clone()); yield null; }
case "openInventory" -> { inventories.add((org.bukkit.inventory.Inventory) args[0]); yield core.view; }
case "closeInventory" -> { inventoryCloses++; yield null; }
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
var requested = ((java.util.Collection<?>) args[0]).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
recipeAwards.add(requested); int before = knownRecipes.size(); knownRecipes.addAll(requested);
yield knownRecipes.size() - before;
}
case "undiscoverRecipes" -> throw new AssertionError("Recipe discovery must not be revoked");
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
@@ -101,6 +101,79 @@ class FireballNativeTest {
}
}
@Test void bookUnlearnRefundsOnceClearsOwnedFireballAndRetainsDeadlineThroughReuse() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withInt(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
org.mockito.Mockito.when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var projectile = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var launches = new java.util.concurrent.atomic.AtomicInteger();
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
org.mockito.Mockito.doAnswer(call -> { launches.incrementAndGet(); return projectile; }).when(a.player)
.launchProjectile(org.mockito.ArgumentMatchers.eq(org.bukkit.entity.Snowball.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class));
cast(f, a);
var cast = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = cast.cooldowns().get("fireball"); assertNotNull(deadline); assertEquals(1, launches.get());
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
for (var page : content.pages()) for (String line : page.raw().getString().split("\\n", -1)) {
assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
"Learned ability action exceeds native visible width: " + line);
}
var unlearn = PartyBookNativeTest.button(a, "[Unlearn]");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("fireball"));
assertTrue(refunded.loadout().stream().noneMatch("fireball"::equals));
assertEquals(deadline, refunded.cooldowns().get("fireball"));
org.mockito.Mockito.verify(projectile, org.mockito.Mockito.atLeastOnce()).remove();
assertTrue(new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).isEmpty());
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[+1 WIS]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var spent = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.ONE, spent.stats().get(games.dmg.fantasystick.progression.Stat.WIS));
assertEquals(BigInteger.valueOf(39), spent.unspentPoints());
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[Learn]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, PartyBookNativeTest.button(a, "[3]"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
cast(f, a); assertEquals(1, launches.get());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
var transferredSheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(transferredSheet);
f.players.put(b.core.id, b.player); f.activate(b);
b.core.inventory.getInventory().setSelectedSlot(2);
assertEquals("fireball", new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(b.core.inventory.getItem(2)).orElseThrow());
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(b.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
b.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f);
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")),
"Transfer/reload must retain the original Fireball deadline after refund and relearn");
}
}
@Test void partyAndMixedModeProjectileHitsPreserveProtectionCancellation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "fireball-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
@@ -0,0 +1,537 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostShardNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAndDirectHitDealDamageAndOwnedSlow() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStats(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("FrostShard"), book); assertTrue(book.contains("INT12"), book);
assertTrue(book.contains("WIS10"), book); assertTrue(book.contains("SlownessI"), book);
var learn = abilityButton(a, "[Learn]", "frost_shard");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "frost_shard"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertTrue(assigned.learned().contains("frost_shard"));
assertEquals("frost_shard", assigned.loadout().getFirst());
assertEquals(org.bukkit.Material.PRISMARINE_SHARD, a.core.inventory.getItem(2).getType());
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
doAnswer(call -> {
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var cast = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(cast); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(org.bukkit.event.Event.Result.DENY, cast.useItemInHand());
verify(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
var frost = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShard");
assertEquals(1, ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(frost, "projectiles")).size());
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 10, 65.6, 0));
when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var hit = new org.bukkit.event.entity.ProjectileHitEvent(projectile, target);
f.dispatcher.accept(hit);
assertFalse(hit.isCancelled(), "Eligible hostile Frost Shard hit was unexpectedly cancelled");
assertEquals(1, damages.size()); assertEquals(5.0, damages.getFirst().getDamage());
var effect = org.mockito.ArgumentCaptor.forClass(org.bukkit.potion.PotionEffect.class);
verify(target).addPotionEffect(effect.capture());
assertEquals(org.bukkit.potion.PotionEffectType.SLOWNESS, effect.getValue().getType());
assertEquals(40, effect.getValue().getDuration()); assertEquals(0, effect.getValue().getAmplifier());
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Accepted Frost Shard damage is attributed to the originating character");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(1, damages.size());
verify(projectile, atLeastOnce()).remove();
}
}
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFrostShard(data, a, 12, 10);
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()));
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var first = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var second = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var third = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, first, second, third);
cast(f, a); var partyHit = new org.bukkit.event.entity.ProjectileHitEvent(first, b.player); f.dispatcher.accept(partyHit);
assertTrue(partyHit.isCancelled()); verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
cast(f, a); var ordinaryHit = new org.bukkit.event.entity.ProjectileHitEvent(second, ordinary); f.dispatcher.accept(ordinaryHit);
assertTrue(ordinaryHit.isCancelled()); verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(third, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0));
event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
verify(target, never()).addPotionEffect(any());
}
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var outside = projectile(a, new org.bukkit.Location(a.world, 32.01, 65.6, 0));
var boundary = projectile(a, new org.bukkit.Location(a.world, 32, 65.6, 0));
var expiring = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
var exiting = projectile(a, new org.bukkit.Location(a.world, 1, 65.6, 0));
prepareLaunches(f, a, outside, boundary, expiring, exiting);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
damages.incrementAndGet();
var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
var projectile = source.getDirectEntity();
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(outside, target));
assertEquals(0, damages.get()); verify(outside, atLeastOnce()).remove();
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(boundary, target));
assertEquals(1, damages.get());
cast(f, a); f.delayed.getLast().run(); verify(expiring, atLeastOnce()).remove();
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(expiring, target)); assertEquals(1, damages.get());
cast(f, a); a.core.inventory.setItemInOffHand(null); f.changed(a);
PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
verify(exiting, atLeastOnce()).remove();
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); learnAndAssign(f, a);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, projectile);
cast(f, a);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "frost_shard");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("frost_shard"));
assertTrue(refunded.loadout().stream().noneMatch("frost_shard"::equals));
assertEquals(deadline, refunded.cooldowns().get("frost_shard"));
verify(projectile, atLeastOnce()).remove();
learnAndAssign(f, a); cast(f, a);
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
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);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); cast(f, b);
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
}
}
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var frost = new games.dmg.fantasystick.ability.FrostShard(
games.dmg.fantasystick.ability.FrostShard.Settings.defaults()); registry.register(frost.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(12));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.TEN);
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("frost_shard"),
java.util.List.of("frost_shard", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(),
false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Frost Shard cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("frost_shard"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "frost_shard",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
assertEquals(1, failures.size()); engine.close(); frost.close(); parties.close();
}
@Test void configuredDefinitionAndFormulaPreserveReviewedFiniteUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"frost-shard-base-damage: 9\nfrost-shard-cooldown-seconds: 2.5\n"
+ "frost-shard-range-blocks: 40\nfrost-shard-lifetime-seconds: 7\n"
+ "frost-shard-projectile-speed-blocks-per-tick: 2\n"
+ "frost-shard-slowness-amplifier: 2\nfrost-shard-slowness-duration-seconds: 3\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("frost_shard").orElseThrow();
assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("7").compareTo(definition.parameters().get("lifetime")));
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("projectile-speed")));
assertEquals(0, new java.math.BigDecimal("2").compareTo(definition.parameters().get("slowness-amplifier")));
assertEquals(0, new java.math.BigDecimal("3").compareTo(definition.parameters().get("slowness-duration")));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "")
.contains("Unavailable:requirements"));
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "frost_shard"));
}
assertEquals(BigInteger.valueOf(4), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN));
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.FrostShard.damage(BigInteger.TEN.pow(500)));
}
@Test void invalidPackagedFrostSettingsFailBeforeInstallingGameplay() throws Exception {
for (String configuration : java.util.List.of("frost-shard-base-damage: -1\n",
"frost-shard-cooldown-seconds: 31536001\n", "frost-shard-range-blocks: 0\n",
"frost-shard-lifetime-seconds: 0\n", "frost-shard-projectile-speed-blocks-per-tick: 0\n",
"frost-shard-slowness-amplifier: 256\n", "frost-shard-slowness-duration-seconds: 0\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test void sourceExitRemovesOnlyOwnedSlowAndRestoresThePriorNativeLayer() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
doReturn(eye).when(a.player).getEyeLocation();
var projectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile)); return projectile; })
.when(a.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
var previous = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 1);
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>(previous);
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(call -> current.get());
when(target.addPotionEffect(any())).thenAnswer(call -> { current.set(call.getArgument(0)); return true; });
doAnswer(call -> { current.set(null); return null; }).when(target)
.removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(projectile, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(projectile, target));
assertEquals(0, current.get().getAmplifier());
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
assertTrue(current.get().getDuration() > 0 && current.get().getDuration() <= 200);
}
}
@Test void reenableWaitsForAcceptedFrostConfigurationBeforeInstallingGameplay() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "frostShardSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "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 failure) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
release.countDown(); f.tick();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
} finally { release.countDown(); }
}
}
@Test void failedAdminPersistenceLeavesTheEffectiveDefinitionAndExternalFileUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var outside = data.resolveSibling(data.getFileName() + "-frost-outside");
java.nio.file.Files.writeString(outside, "untouched\n");
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("save failed"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("4"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
}
}
@Test void acknowledgedUpdatesAffectFutureCastsButNotAnActiveProjectileSnapshot() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var oldProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
var newProjectile = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
prepareLaunches(f, a, oldProjectile, newProjectile); cast(f, a);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0)); when(target.addPotionEffect(any())).thenReturn(true);
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
damages.add(call.getArgument(0)); var source = call.getArgument(1, org.bukkit.damage.DamageSource.class);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.getDirectEntity(), target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(oldProjectile, target));
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(newProjectile, target));
assertEquals(java.util.List.of(5.0, 10.0), damages);
}
}
@Test void administratorCanReadUpdateAndCompleteEveryFrostShardSetting() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("frost-shard-base-damage"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-b"}));
assertEquals(java.util.List.of("frost-shard-base-damage", "frost-shard-cooldown-seconds",
"frost-shard-lifetime-seconds", "frost-shard-projectile-speed-blocks-per-tick",
"frost-shard-range-blocks", "frost-shard-slowness-amplifier",
"frost-shard-slowness-duration-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost-shard-"}));
assertEquals(java.util.List.of("4"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "frost-shard-base-damage", ""}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "unknown"}).isEmpty());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "frost"}).isEmpty());
sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=4"));
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage", "9"});
assertTrue(sender.messages.isEmpty(), "Do not publish settings before durable acknowledgment");
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("frost-shard-base-damage=9"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
var saved = (java.util.Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(
java.nio.file.Files.readString(data.resolve("config.yml")));
assertEquals("9", String.valueOf(saved.get("frost-shard-base-damage")));
for (String invalid : java.util.List.of("-1", "1.5", "nope")) {
f.command.execute(sender.port, "fantasystick",
new String[]{"config", "frost-shard-base-damage", invalid});
assertTrue(sender.messages.getLast().contains("invalid"));
}
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), reloaded.find("frost_shard").orElseThrow()
.parameters().get("base-damage"));
sender.messages.clear();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-shard-base-damage"});
assertTrue(sender.messages.getLast().contains("=9"));
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "frost_shard"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void cast(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
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));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void prepareLaunches(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.entity.Snowball... projectiles) {
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0)); doReturn(eye).when(actor.player).getEyeLocation();
var queue = new java.util.ArrayDeque<>(java.util.List.of(projectiles));
doAnswer(call -> {
var projectile = queue.removeFirst(); fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(projectile));
return projectile;
}).when(actor.player).launchProjectile(eq(org.bukkit.entity.Snowball.class), any(org.bukkit.util.Vector.class));
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withFrostShard(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int wisdom) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("frost_shard"), java.util.List.of("frost_shard", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int wisdom) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.INT, BigInteger.valueOf(intelligence));
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(wisdom));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static org.bukkit.entity.Snowball projectile(FantasyStickLifecycleNativeTest.Actor actor,
org.bukkit.Location location) {
var projectile = mock(org.bukkit.entity.Snowball.class);
when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(projectile.getShooter()).thenReturn(actor.player);
when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
when(projectile.getWorld()).thenReturn(location.getWorld());
when(projectile.getPersistentDataContainer()).thenReturn(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
return projectile;
}
}
@@ -0,0 +1,369 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.math.BigInteger;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class GreatSwordNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndFullyChargedEquippedStrikeReplaceVanillaDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a);
assertTrue(book.contains("Great Sword"));
assertTrue(book.contains("STR 12"));
assertTrue(book.contains("1.2"));
var learn = abilityButton(a, "[Learn]", "great_sword");
PartyBookNativeTest.click(f, a, learn); PartyBookNativeTest.click(f, a, learn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "great_sword"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var assigned = storage.find(a.core.record.id()).get().orElseThrow();
assertTrue(assigned.learned().contains("great_sword"));
assertEquals("great_sword", assigned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), assigned.unspentPoints());
assertEquals(org.bukkit.Material.WOODEN_SWORD, a.core.inventory.getItem(2).getType());
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damages = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0));
damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var before = java.time.Instant.now();
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "The bound sword must never continue into vanilla damage");
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(1, damages.size());
assertEquals(8.0, damages.getFirst().getDamage());
assertSame(a.player, damages.getFirst().getDamager());
assertEquals(org.bukkit.damage.DamageType.PLAYER_ATTACK, damages.getFirst().getDamageSource().getDamageType());
var saved = storage.find(a.core.record.id()).get().orElseThrow();
var deadline = saved.cooldowns().get("great_sword");
assertNotNull(deadline);
assertFalse(deadline.isBefore(before.plusMillis(1200)));
assertFalse(deadline.isAfter(java.time.Instant.now().plusMillis(1200)));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience(),
"Accepted direct damage is attributed to the originating character");
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, damages.size(), "The persisted recovery prevents a repeated strike");
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void partialChargeUsesTheApprovedNativeCurveWithoutVanillaContinuation() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
withGreatSword(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
when(a.player.getAttackCooldown()).thenReturn(0.5f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var damages = new java.util.ArrayList<Double>();
doAnswer(call -> {
double amount = call.getArgument(0); damages.add(amount);
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, amount));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(attack.isCancelled());
assertEquals(java.util.List.of(3.2), damages);
}
}
@Test void refusedPlayersAndCancelledNativeDamagePreserveProtectionWithoutFreeVanillaHits() 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);
withGreatSword(data, a, 12);
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()));
when(a.player.getAttackCooldown()).thenReturn(1.0f);
a.core.inventory.getInventory().setSelectedSlot(2);
var partyAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true);
f.dispatcher.accept(partyAttack); PartyBookNativeTest.drain(f);
assertTrue(partyAttack.isCancelled());
verify(b.player, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var ordinary = mock(org.bukkit.entity.Player.class);
when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var ordinaryAttack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, ordinary, true);
f.dispatcher.accept(ordinaryAttack); PartyBookNativeTest.drain(f);
assertTrue(ordinaryAttack.isCancelled());
verify(ordinary, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 4, 64, 0));
var damage = new java.util.concurrent.atomic.AtomicReference<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
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(true); damage.set(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertNotNull(damage.get()); assertTrue(damage.get().isCancelled());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"),
"An acknowledged recovery is not refunded by later native cancellation");
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damage.get().getDamageSource(),
new java.util.ArrayList<>(), 0));
PartyBookNativeTest.drain(f);
assertEquals(BigInteger.ZERO, storage.find(a.core.record.id()).get().orElseThrow().experience());
}
}
@Test void unlearnRelearnAndWholeCharacterTransferRetainTheOriginalRecovery() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "great-sword-recovery-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStrength(data, a, 12);
f.players.put(a.core.id, a.player); f.activate(a);
learnAndAssign(f, a);
when(a.player.getAttackCooldown()).thenReturn(1.0f);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
hits.incrementAndGet();
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(a.player, target,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source,
(Double) call.getArgument(0))); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(1, hits.get());
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "great_sword");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("great_sword"));
assertTrue(refunded.loadout().stream().noneMatch("great_sword"::equals));
assertEquals(deadline, refunded.cooldowns().get("great_sword"));
assertTrue(new games.dmg.fantasystick.items.SessionControls(
(games.dmg.fantasystick.ability.AbilityRegistry) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry"))
.ability(a.core.inventory.getItem(2)).isEmpty());
learnAndAssign(f, a);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, hits.get());
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);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
when(b.player.getAttackCooldown()).thenReturn(1.0f);
b.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f);
assertEquals(1, hits.get(), "Transfer cannot reset the character-bound recovery");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("great_sword"));
}
}
@Test void failedCooldownPersistenceDealsNoDamageOrFreeEffect() {
var storage = mock(games.dmg.fantasystick.persistence.CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var sword = new games.dmg.fantasystick.ability.GreatSword(
games.dmg.fantasystick.ability.GreatSword.Settings.defaults());
registry.register(sword.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12));
var record = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), base.name(), base.revision(), stats,
base.level(), base.experience(), base.unspentPoints(), base.health(), java.util.Set.of("great_sword"),
java.util.List.of("great_sword", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(),
false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Great Sword cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("great_sword"), any()))
.thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "great_sword",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target, 0, 1, () -> true));
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
assertEquals(1, failures.size());
engine.close(); sword.close(); parties.close();
}
@Test void invalidGreatSwordBalanceFailsBeforeInstallingGameplay() throws Exception {
for (String configuration : java.util.List.of("great-sword-base-damage: -1\n",
"great-sword-recovery-seconds: -0.1\n", "great-sword-recovery-seconds: 31536001\n",
"great-sword-recovery-seconds: 0.0000000001\n", "great-sword-base-damage: nope\n")) {
var directory = data.resolve(Integer.toHexString(configuration.hashCode()));
java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), configuration);
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> fixture.plugin.setEnabled(true));
assertTrue(fixture.order.isEmpty()); assertTrue(fixture.recipes.isEmpty());
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
}
}
}
@Test void configuredDefinitionAndChargeCurvePreserveExactUncappedScaling() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"great-sword-base-damage: 9\ngreat-sword-recovery-seconds: 2.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("great_sword").orElseThrow();
assertEquals(java.time.Duration.ofMillis(2500), definition.cooldown());
assertEquals(new java.math.BigDecimal("9"), definition.parameters().get("base-damage"));
assertEquals("Great Sword", definition.name());
assertEquals(org.bukkit.Material.WOODEN_SWORD, definition.icon());
assertEquals(java.util.Map.of(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(12)),
definition.requirements());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, definition.gesture());
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
String text = PartyBookNativeTest.text(unavailable);
assertTrue(text.contains("Great Sword"));
assertTrue(text.replaceAll("\\s+", "").contains("Unavailable:requirements"), text);
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "great_sword"));
}
assertEquals(new java.math.BigDecimal("8"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 1.0f));
assertEquals(new java.math.BigDecimal("3.2"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.5f));
assertEquals(new java.math.BigDecimal("1.6"),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.valueOf(12), 0.0f));
assertEquals(new java.math.BigDecimal(BigInteger.TEN.pow(500).subtract(BigInteger.TEN)
.divide(BigInteger.TWO).add(BigInteger.valueOf(7))),
games.dmg.fantasystick.ability.GreatSword.damage(BigInteger.TEN.pow(500), 1.0f));
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "great_sword"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withGreatSword(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
java.util.Set.of("great_sword"), java.util.List.of("great_sword", "", "", "", "", "", ""),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withStrength(Path data, FantasyStickLifecycleNativeTest.Actor actor, int strength) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, BigInteger.valueOf(strength));
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(), old.health(),
old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,196 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
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 HexNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() 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);
withHex(data, a, false); 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()));
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
doReturn(enemy).when(a.player).getTargetEntity(64);
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new PlayerInteractEvent(a.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
a.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
var protectedHit = new EntityDamageByEntityEvent(enemy, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
f.dispatcher.accept(protectedHit); assertEquals(8, protectedHit.getDamage(), 1.0e-9);
var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);
var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy, allySource, new java.util.ArrayList<>(), 0));
for (int i = 0; i < 4; i++) PartyBookNativeTest.drain(f);
assertEquals(BigInteger.valueOf(5), storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage.find(b.core.record.id()).get().orElseThrow().experience());
}
}
@Test void replacementOldExpiryAndSourceExitPreserveOnlyCurrentOwnedPenalty() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(2,f.delayed.size());f.delayed.getFirst().run();
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
}
}
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);withHex(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);
var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();for(var member:List.of(c,b))assertTrue(parties.accept(member.core.id,parties.invite(a.core.id,party.id(),member.core.id).orElseThrow().id()));
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));
for(var caster:List.of(a,c)){doReturn(new Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(enemy);doReturn(enemy).when(caster.player).getTargetEntity(64);use(f,caster);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));var enemySource=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,10);f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(c.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());
}}
@Test void unusedProtectionCannotManufactureSupportShare() 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);withHex(data,a,true);
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 enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var allySource=DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());
}
}
@Test void failedCooldownPersistenceAppliesNoHex() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
var mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.definition());
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(14));stats.put(Stat.WIS,BigInteger.valueOf(12));
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),java.util.Set.of("hex"),List.of("hex","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);
var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(world,8,64,0));doReturn(true).when(player).hasLineOfSight(enemy);when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("hex"),any())).thenReturn(failed);
var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"hex",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,enemy,0,0,()->true));
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("hex"),any());var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);mitigation.reduce(hit);assertEquals(10,hit.getDamage(),1.0e-9);engine.close();hex.close();mitigation.close();parties.close();
}
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() 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);withHex(data,a,true);
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 enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-9);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex");
a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=abilityButton(a,"[Unlearn]","hex");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("hex"));
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(hit);assertEquals(10,hit.getDamage(),1.0e-9);
learnAndAssign(f,a);use(f,a);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);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(new Location(a.world,0,64,0)).when(b.player).getLocation();doReturn(true).when(b.player).hasLineOfSight(enemy);doReturn(enemy).when(b.player).getTargetEntity(64);use(f,b);assertTrue(b.messages.stream().anyMatch(message->message.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
}}
@Test void configuredDefinitionAdminRoutesRestartAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),"hex-cooldown-seconds: 2\nhex-range-blocks: 16\nhex-duration-seconds: 6\nhex-damage-reduction-percent: 30\n");
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("hex").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2),d.cooldown());assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("6").compareTo(d.parameters().get("duration-seconds")));assertEquals(0,new java.math.BigDecimal("30").compareTo(d.parameters().get("damage-reduction-percent")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}).isEmpty());
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;
assertEquals(List.of("hex-cooldown-seconds","hex-damage-reduction-percent","hex-duration-seconds","hex-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-"}));
assertEquals(List.of("12","16"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","hex-range-blocks",""}));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks"});assertTrue(sender.messages.getLast().contains("=16"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
assertEquals(0,new java.math.BigDecimal("18").compareTo(registry.find("hex").orElseThrow().parameters().get("range")));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,17,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var configured=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);f.dispatcher.accept(configured);assertEquals(7,configured.getDamage(),1.0e-9);
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","hex-range-blocks","18","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(0,new java.math.BigDecimal("18").compareTo(reloaded.find("hex").orElseThrow().parameters().get("range")));
}
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}
}
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);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()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new Location(a.world,8,64,0)).when(target).getLocation();doReturn(true).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("hex"));}}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,abilityButton(a,"[3]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom abilityButton(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=abilityButton(page.raw(),label,ability);if(found!=null)return found;}throw new AssertionError("Missing action");}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,String label,String ability){
if(component.getString().equals(label)&&component.getStyle().getClickEvent() instanceof 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=abilityButton(child,label,ability);if(found!=null)return found;}return null;}
private static void withHex(Path data,FantasyStickLifecycleNativeTest.Actor actor,boolean learned)throws Exception{
java.nio.file.Files.createDirectories(data);var repository=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old=repository.load(actor.core.record.id()).orElseThrow();var stats=new EnumMap<>(old.stats());
stats.put(Stat.CHR,BigInteger.valueOf(14));stats.put(Stat.WIS,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?java.util.Set.of("hex"):old.learned(),
learned?List.of("hex","","","","","",""):old.loadout(),old.cooldowns(),old.restrictedKills(),false,null,null),old.revision());}
}
@@ -0,0 +1,27 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertTrue(book.contains("Cost:1"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()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);withInspire(data,a,true);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()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);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 target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.ZERO,storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.TEN,storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());when(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,a,button(a,"[3]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}
private static ClickEvent.Custom button(net.minecraft.network.chat.Component c,String label,String ability){if(c.getString().equals(label)&&c.getStyle().getClickEvent() instanceof ClickEvent.Custom x&&x.payload().orElse(null) instanceof net.minecraft.nbt.StringTag p&&p.value().contains(":"+ability))return x;for(var k:c.getSiblings()){var x=button(k,label,ability);if(x!=null)return x;}return null;}
private static void withInspire(Path d,FantasyStickLifecycleNativeTest.Actor a,boolean learned)throws Exception{java.nio.file.Files.createDirectories(d);var r=new YamlCharacterRepository(d);var o=r.load(a.core.record.id()).orElseThrow();var s=new EnumMap<>(o.stats());s.put(Stat.CHR,BigInteger.valueOf(12));s.put(Stat.WIS,BigInteger.TEN);r.replace(new CharacterRecord(o.id(),o.name(),o.revision().add(BigInteger.ONE),s,o.level(),o.experience(),BigInteger.valueOf(learned?39:40),o.health(),learned?Set.of("inspire"):o.learned(),learned?List.of("inspire","","","","","",""):o.loadout(),o.cooldowns(),o.restrictedKills(),false,null,null),o.revision());}}
@@ -0,0 +1,479 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.ProjectileHitEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
class LongbowNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionNativeDrawFullShotDamageAttributionAndPersistentRecovery() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 0, 14);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("Longbow")); assertTrue(text.contains("DEX14"));
assertTrue(text.contains("Cost:1"), text); assertTrue(text.contains("Draw"), text);
assertTrue(text.contains("1s"), text);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "longbow"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "longbow"));
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();
assertTrue(learned.learned().contains("longbow")); assertEquals("longbow", learned.loadout().getFirst());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0)); when(target.getWorld()).thenReturn(a.world);
var damages = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var event = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
a.core.inventory.setItem(20, new ItemStack(Material.ARROW, 32));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var velocity = ArgumentCaptor.forClass(Vector.class);
verify(a.player).launchProjectile(eq(Arrow.class), velocity.capture());
assertEquals(3.0, velocity.getValue().length(), 1.0e-9);
verify(arrow).setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
verify(arrow).setDamage(0);
assertEquals(32, a.core.inventory.getItem(20).getAmount(), "Ordinary ammunition must remain untouched");
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
assertEquals(1, damages.size()); assertEquals(7.0, damages.getFirst().getFinalDamage(), 1.0e-9);
f.dispatcher.accept(new EntityDeathEvent(target, damages.getFirst().getDamageSource(), new ArrayList<>(), 0));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
}
}
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 2);
verify(a.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("longbow"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(a.world, 6, 65.6, 0));
var amount = new double[1];
doAnswer(call -> {
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
f.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
source, amount[0])); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, a, 10); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
}
}
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() 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);
withLongbow(data, a, 14); f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var party = PartyLifecycleNativeTest.parties(f).create(a.core.id).orElseThrow();
assertTrue(PartyLifecycleNativeTest.parties(f).accept(b.core.id,
PartyLifecycleNativeTest.parties(f).invite(a.core.id, party.id(), b.core.id).orElseThrow().id()));
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var ordinaryTarget = mock(LivingEntity.class); when(ordinaryTarget.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var nativeDamage = new EntityDamageByEntityEvent(arrow, ordinaryTarget,
EntityDamageEvent.DamageCause.PROJECTILE, 7.0);
f.dispatcher.accept(nativeDamage); assertTrue(nativeDamage.isCancelled(), "Owned arrows cannot add vanilla damage");
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
verify(b.player, never()).damage(anyDouble(), any(DamageSource.class));
var c = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, c, 14);
f.players.put(c.core.id, c.player); f.activate(c); when(c.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow2 = arrow(c, new Location(c.world, 4, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow2)); return arrow2; })
.when(c.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var hostile = mock(LivingEntity.class); when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(hostile.getLocation()).thenReturn(new Location(c.world, 4, 65.6, 0));
doAnswer(call -> {
var source = call.getArgument(1, DamageSource.class);
var event = new EntityDamageByEntityEvent(arrow2, hostile, EntityDamageEvent.DamageCause.PROJECTILE,
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(hostile).damage(anyDouble(), any(DamageSource.class));
drawAndRelease(f, c, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(arrow2, hostile));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
}
}
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
for (int route = 0; route < 3; route++) {
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var location = new Location(a.world, route == 0 ? 64.01 : 2, 65.6, 0);
var arrow = arrow(a, location);
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
if (route == 0) {
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
} else if (route == 1) {
f.delayed.getLast().run();
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
verify(target, never()).damage(anyDouble(), any(DamageSource.class));
} else {
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(
f.plugin, "abilityEngine")).sweep();
}
verify(arrow, atLeastOnce()).remove();
}
}
}
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("longbow");
assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "longbow");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertFalse(refunded.learned().contains("longbow")); assertEquals(deadline, refunded.cooldowns().get("longbow"));
verify(arrow, atLeastOnce()).remove();
learnAndAssign(f, a); drawAndRelease(f, a, 20);
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
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);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); drawAndRelease(f, b, 20);
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var longbow = new games.dmg.fantasystick.ability.Longbow(
games.dmg.fantasystick.ability.Longbow.Settings.defaults()); registry.register(longbow.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("longbow"),
List.of("longbow", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Longbow recovery failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("longbow"), any())).thenReturn(failed);
var failures = new ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "longbow",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.DRAW_RELEASE, null, 20, 0, () -> true));
verify(player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
}
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
f.players.put(a.core.id, a.player); f.activate(a); when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var oldArrow = arrow(a, new Location(a.world, 3, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(oldArrow)); return oldArrow; })
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var oldTarget = targetRecordingDamage(f, a, oldArrow); f.dispatcher.accept(new ProjectileHitEvent(oldArrow, oldTarget.entity()));
assertEquals(7.0, oldTarget.amount()[0], 1.0e-9);
var b = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, b, 14);
f.players.put(b.core.id, b.player); f.activate(b); when(b.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var newArrow = arrow(b, new Location(b.world, 3, 65.6, 0));
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(newArrow)); return newArrow; })
.when(b.player).launchProjectile(eq(Arrow.class), any(Vector.class));
var newTarget = targetRecordingDamage(f, b, newArrow);
drawAndRelease(f, b, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.dispatcher.accept(new ProjectileHitEvent(newArrow, newTarget.entity()));
assertEquals(11.0, newTarget.amount()[0], 1.0e-9);
}
}
@Test void failedAdminWriteKeepsLongbowDefinitionAndExternalFileUnchanged() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var outside = data.resolveSibling(data.getFileName() + "-longbow-outside");
java.nio.file.Files.writeString(outside, "untouched\n");
java.nio.file.Files.createSymbolicLink(data.resolve("config.yml"), outside);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertTrue(sender.messages.getLast().contains("save failed"));
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("5"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
assertEquals("untouched\n", java.nio.file.Files.readString(outside));
java.nio.file.Files.delete(data.resolve("config.yml")); java.nio.file.Files.delete(outside);
}
}
@Test void reenableDrainsAcceptedLongbowConfigurationBeforeGameplayRegistration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var settings = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "longbowSettings");
var worker = (java.util.concurrent.ExecutorService) FantasyStickLifecycleNativeTest.Fixture.field(settings, "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 failure) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"));
release.countDown(); f.tick();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
} finally { release.countDown(); }
}
}
@Test void configuredDefinitionFormulaValidationAndAdminRoutesCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"longbow-base-damage: 8\nlongbow-recovery-seconds: 2\nlongbow-minimum-draw-ticks: 4\n"
+ "longbow-full-draw-ticks: 30\nlongbow-range-blocks: 70\nlongbow-lifetime-seconds: 11\n"
+ "longbow-full-projectile-speed-blocks-per-tick: 4\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("longbow").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(new java.math.BigDecimal("8"), definition.parameters().get("base-damage"));
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("minimum-draw-ticks"));
assertEquals(new java.math.BigDecimal("30"), definition.parameters().get("full-draw-ticks"));
assertEquals(0, new java.math.BigDecimal("70").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("11").compareTo(definition.parameters().get("lifetime")));
assertEquals(0, new java.math.BigDecimal("4").compareTo(definition.parameters().get("full-projectile-speed")));
assertEquals(java.math.BigDecimal.ONE, games.dmg.fantasystick.ability.Longbow.power(30, 30));
assertEquals(0, games.dmg.fantasystick.ability.Longbow.power(15, 30)
.compareTo(new java.math.BigDecimal("0.4166666666666666666666666666666667")));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.valueOf(14)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(5)),
games.dmg.fantasystick.ability.Longbow.fullDamage(BigInteger.TEN.pow(500)));
var unavailable = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(unavailable.core.id, unavailable.player); f.activate(unavailable);
unavailable.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, unavailable);
assertTrue(PartyBookNativeTest.text(unavailable).replaceAll("\\s+", "").contains("Unavailable:requirements"));
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(java.util.List.of("config", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
assertEquals(java.util.List.of("longbow-base-damage"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-b"}));
assertEquals(7, f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow-"}).size());
assertEquals(java.util.List.of("5", "8"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "longbow-base-damage", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage"});
assertTrue(sender.messages.getLast().contains("longbow-base-damage=8"));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "longbow"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied"), sender.messages.toString()); sender.allowed = true;
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").orElseThrow()
.parameters().get("base-damage"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "longbow-base-damage", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("9"), reloaded.find("longbow").orElseThrow()
.parameters().get("base-damage"));
}
for (String invalid : List.of("longbow-base-damage: -1\n", "longbow-recovery-seconds: 0.5\n",
"longbow-minimum-draw-ticks: 0\n", "longbow-full-draw-ticks: 2\n",
"longbow-range-blocks: 0\n", "longbow-lifetime-seconds: 0\n",
"longbow-full-projectile-speed-blocks-per-tick: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
}
}
}
private record DamageTarget(LivingEntity entity, double[] amount) { }
private static DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getLocation()).thenReturn(new Location(actor.world, 3, 65.6, 0));
var amount = new double[1];
doAnswer(call -> {
amount[0] = call.getArgument(0); var source = call.getArgument(1, DamageSource.class);
fixture.dispatcher.accept(new EntityDamageByEntityEvent(arrow, target,
EntityDamageEvent.DamageCause.PROJECTILE, source, amount[0])); return null;
}).when(target).damage(anyDouble(), any(DamageSource.class));
return new DamageTarget(target, amount);
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "longbow"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void drawAndRelease(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor, int ticks) {
actor.core.inventory.getInventory().setSelectedSlot(2);
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
var bow = actor.core.inventory.getItem(2).clone();
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
bow, null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
fixture.dispatcher.accept(new io.papermc.paper.event.player.PlayerStopUsingItemEvent(actor.player, bow, ticks));
}
private static Arrow arrow(FantasyStickLifecycleNativeTest.Actor actor, Location location) {
var arrow = PartyCombatNativeTest.entity(Arrow.class);
when(arrow.getShooter()).thenReturn(actor.player); when(arrow.getLocation()).thenReturn(location);
when(arrow.getWorld()).thenReturn(actor.world); return arrow;
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof 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 = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int intelligence, int dexterity) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.INT, BigInteger.valueOf(intelligence));
stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
old.level(), old.experience(), BigInteger.valueOf(40), old.health(), old.learned(), old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
private static void withLongbow(Path data, FantasyStickLifecycleNativeTest.Actor actor, int dexterity) throws Exception {
withStats(data, actor, 0, dexterity);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE), old.health(),
java.util.Set.of("longbow"), List.of("longbow", "", "", "", "", "", ""), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -424,6 +424,8 @@ class ManualCraftingControllerNativeTest {
org.bukkit.Location eyeLocation;
boolean online = true;
int syncs;
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
int recipeAwards;
Runnable onSync = () -> { };
Actor(int width, int amount) {
@@ -438,6 +440,14 @@ class ManualCraftingControllerNativeTest {
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
case "isOnline" -> online;
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
case "getName" -> "CraftingActor";
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
case "discoverRecipes" -> {
recipeAwards++; int before = knownRecipes.size();
((java.util.Collection<?>) args[0]).stream().map(org.bukkit.NamespacedKey.class::cast)
.forEach(knownRecipes::add);
yield knownRecipes.size() - before;
}
case "equals" -> object == args[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Crafting actor fixture";
@@ -0,0 +1,7 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class PartyCapacityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredPolicyDrivesActualBookInvitationsAcceptanceGrandfatheringAndRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));assertEquals(java.util.List.of("6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-max-size",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"2");var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);PartyBookNativeTest.open(f,leader);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Create party]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));PartyBookNativeTest.open(f,member);PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Invitations]"));PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Accept]"));PartyBookNativeTest.drain(f);var parties=PartyLifecycleNativeTest.parties(f);assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyBookNativeTest.text(member).contains("2/2 members"));PartyBookNativeTest.open(f,leader);assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));save(f,sender,"3");var third=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(third.core.id,third.player);f.activate(third);PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("2/3 members"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));assertEquals(1,parties.invitationsFor(third.core.id).size());save(f,sender,"2");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertTrue(parties.partyOf(third.core.id).isEmpty());assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"3");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyCombatNativeTest.hit(f,leader.player,member.player).isCancelled());PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("3/2 members"));assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=2"));assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).isEmpty());}}
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party,PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -0,0 +1,308 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickstepNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseAppliesAndStopsOwnedHorizontalVelocityAtFourBlocks() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("Quickstep")); assertTrue(text.contains("DEX12"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("4")); assertTrue(text.contains("5s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "quickstep"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(1, 0.25, 0), velocity.get());
verify(a.player, never()).setFallDistance(anyFloat());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
}
}
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
for (int route = 0; route < 4; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Integer.toString(route)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Integer.toString(route)));
withStats(data.resolve(Integer.toString(route)), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0.3, 0.4, 0.5));
doReturn(route != 0).when(a.player).isOnGround();
doReturn(route == 1).when(a.player).isFlying();
doReturn(route == 2).when(a.player).isGliding();
doReturn(route == 3).when(a.player).isInsideVehicle();
use(f, a);
assertEquals(new Vector(0.3, 0.4, 0.5), velocity.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
}
}
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
for (int route = 0; route < 3; route++) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("motion-" + route))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("motion-" + route));
withStats(data.resolve("motion-" + route), a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0, 0.3, 0));
if (route == 0) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == a.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
if (route == 0) {
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
} else if (route == 1) {
velocity.set(new Vector(0, 0.3, 0));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
} else {
velocity.set(new Vector(0, 0.6, 2));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
}
verify(a.player, never()).setFallDistance(anyFloat());
}
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var velocity = prepareMovement(a, new Vector(0, 0.4, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"); assertNotNull(deadline);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "quickstep");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("quickstep"));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals(new Vector(0, 0.4, 0), velocity.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void failedCooldownPersistenceAppliesNoVelocity() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var quickstep = new games.dmg.fantasystick.ability.Quickstep(
games.dmg.fantasystick.ability.Quickstep.Settings.defaults(), ignored -> {});
registry.register(quickstep.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.DEX, BigInteger.valueOf(12));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("quickstep"),
List.of("quickstep", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class);
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
doReturn(true).when(player).isOnGround(); doReturn(false).when(player).isFlying();
doReturn(false).when(player).isGliding(); doReturn(false).when(player).isInsideVehicle();
var eye = new Location(mock(org.bukkit.World.class), 0, 0, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(player).getEyeLocation();
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated Quickstep cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("quickstep"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "quickstep",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
verify(player, never()).setVelocity(any(Vector.class)); assertEquals(1, failures.size());
engine.close(); quickstep.close(); parties.close();
}
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); var oldVelocity = prepareMovement(a, new Vector(0, 0.2, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-speed-blocks-per-tick", "2"});
for (int i = 0; i < 2; i++) {
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
}
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 4, 64, 0)));
assertEquals(new Vector(0, 0.2, 0), oldVelocity.get(), "Active dash retains its four-block snapshot");
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, b, 12, true);
f.players.put(b.core.id, b.player); f.activate(b); var newVelocity = prepareMovement(b, new Vector(0, 0.3, 0));
use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(new Vector(2, 0.3, 0), newVelocity.get(), "Future dash uses acknowledged speed");
}
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEveryQuickstepSetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"quickstep-cooldown-seconds: 2\nquickstep-distance-blocks: 6\nquickstep-speed-blocks-per-tick: 1.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("quickstep").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("quickstep-cooldown-seconds", "quickstep-distance-blocks", "quickstep-speed-blocks-per-tick"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "quickstep-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(registry.find("quickstep").orElseThrow()
.parameters().get("distance")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "quickstep-distance-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick",
new String[]{"config", "quickstep-distance-blocks", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(reloaded.find("quickstep").orElseThrow()
.parameters().get("distance")));
}
for (String invalid : List.of("quickstep-cooldown-seconds: -1\n", "quickstep-distance-blocks: 0\n",
"quickstep-speed-blocks-per-tick: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty()); assertTrue(f.recipes.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "quickstep"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static AtomicReference<Vector> prepareMovement(FantasyStickLifecycleNativeTest.Actor actor, Vector initial) {
var velocity = new AtomicReference<>(initial.clone());
doReturn(true).when(actor.player).isOnGround(); doReturn(false).when(actor.player).isFlying();
doReturn(false).when(actor.player).isGliding(); doReturn(false).when(actor.player).isInsideVehicle();
doAnswer(ignored -> velocity.get().clone()).when(actor.player).getVelocity();
doAnswer(call -> { velocity.set(call.getArgument(0, Vector.class).clone()); return null; }).when(actor.player).setVelocity(any(Vector.class));
var eye = new Location(actor.world, 0, 65.6, 0); eye.setDirection(new Vector(1, 0, 0));
doReturn(eye).when(actor.player).getEyeLocation();
return velocity;
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof 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 = abilityButton(child, label, ability);
if (found != null) return found;
}
return null;
}
private static void withStats(Path data, FantasyStickLifecycleNativeTest.Actor actor,
int dexterity, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(dexterity));
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 ? java.util.Set.of("quickstep") : old.learned(),
learned ? List.of("quickstep", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,100 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.Mockito.doAnswer;
import games.dmg.fantasystick.items.CharacterSheets;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RecipeDiscoveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void packagedRegistrationDiscoversOnlineAndJoinPlayersOnceWithoutMintingAndRetainsKnowledge() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var online = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(online.core.id, online.player);
long records = records(data);
f.plugin.setEnabled(true);
var recipe = (org.bukkit.inventory.ShapelessRecipe) f.recipes.get(CharacterSheets.RECIPE);
assertNotNull(recipe);
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), online.knownRecipes);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(records, records(data));
assertTrue(new CharacterSheets().identify(recipe.getResult()).isEmpty());
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(joining.core.id, joining.player);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined"));
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined again"));
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
assertEquals(records + 1, records(data));
f.plugin.setEnabled(false);
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "disabled"));
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
f.plugin.setEnabled(true);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
}
}
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("player-failure"))) {
var failing = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
var healthy = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
var fail = new AtomicBoolean(true);
doAnswer(call -> {
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
.map(org.bukkit.NamespacedKey.class::cast).toList();
failing.recipeAwards.add(requested); failing.knownRecipes.addAll(requested); return requested.size();
}).when(failing.player).discoverRecipes(anyCollection());
f.players.put(failing.core.id, failing.player); f.players.put(healthy.core.id, healthy.player);
var logs = new java.util.ArrayList<LogRecord>();
var handler = new Handler() {
@Override public void publish(LogRecord record) { logs.add(record); }
@Override public void flush() { }
@Override public void close() { }
};
f.plugin.getLogger().addHandler(handler);
try {
f.plugin.setEnabled(true);
assertTrue(f.recipes.containsKey(CharacterSheets.RECIPE));
assertTrue(failing.knownRecipes.isEmpty());
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), healthy.knownRecipes);
assertTrue(logs.stream().anyMatch(log -> log.getMessage().contains("discover character-sheet recipe")));
f.dispatcher.accept(new PlayerJoinEvent(failing.player, "retry"));
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
} finally { f.plugin.getLogger().removeHandler(handler); }
}
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true));
assertTrue(online.knownRecipes.isEmpty());
assertTrue(online.recipeAwards.isEmpty());
assertTrue(f.recipes.isEmpty());
}
}
private static long records(Path directory) throws Exception {
var characters = directory.resolve("characters");
if (!java.nio.file.Files.isDirectory(characters)) return 0;
try (var files = java.nio.file.Files.list(characters)) {
return files.filter(file -> file.getFileName().toString().endsWith(".yml")).count();
}
}
}
@@ -0,0 +1,427 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigInteger;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ReviveNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void completedProgressionBudgetCanLearnAndAssignReviveWithoutALevelGate() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
try (var storage = new games.dmg.fantasystick.persistence.CharacterStorage(repository, java.time.Clock.systemUTC())) {
var current = storage.awardExperience(actor.core.record.id(), actor.core.record.revision(),
BigInteger.valueOf(2700), games.dmg.fantasystick.progression.ProgressionRules.defaults()).join().record().orElseThrow();
for (var purchase : java.util.List.of(
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.WIS, 30),
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CHR, 24),
java.util.Map.entry(games.dmg.fantasystick.progression.Stat.CON, 12))) {
for (int count = 0; count < purchase.getValue(); count++) {
current = storage.purchaseStat(current.id(), current.revision(), purchase.getKey(), current.health(),
new java.math.BigDecimal("1024")).join().record().orElseThrow();
}
}
assertEquals(28, current.level()); assertEquals(BigInteger.ONE, current.unspentPoints());
}
fixture.plugin.setEnabled(true); fixture.players.put(actor.core.id, actor.player); fixture.activate(actor);
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
String text = PartyBookNativeTest.text(actor).replaceAll("\\s+", "");
assertTrue(text.contains("Revive")); assertTrue(text.contains("WIS30")); assertTrue(text.contains("CHR24"));
assertTrue(text.contains("CON12")); assertTrue(text.contains("Cooldown:86400s"));
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "revive"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var saved = storage.find(actor.core.record.id()).get().orElseThrow();
assertTrue(saved.learned().contains("revive")); assertEquals("revive", saved.loadout().getFirst());
assertEquals(BigInteger.ZERO, saved.unspentPoints());
assertEquals(org.bukkit.Material.ECHO_SHARD, actor.core.inventory.getItem(2).getType());
}
}
@Test void ordinaryTargetSelectsOneTailAndReceivesTheSameLivingSheetOnce() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster);
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID();
var died = java.time.Instant.now().minusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), "Fallen Mira", original.revision(),
original.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", java.time.Instant.now().plusSeconds(30)), original.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead);
var copiedStack = tail.clone(); copiedStack.setAmount(2); target.core.inventory.setItem(10, copiedStack);
caster.core.location.setWorld(caster.world); caster.core.location.setX(0); caster.core.location.setY(64); caster.core.location.setZ(0);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64); target.core.location.setZ(0);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size());
var popup = target.inventories.getFirst();
assertEquals(1, java.util.stream.IntStream.range(0, popup.getSize()).filter(slot -> {
var item = popup.getItem(slot); return item != null && item.getType() == org.bukkit.Material.WRITTEN_BOOK;
}).count());
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
var selection = 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);
fixture.dispatcher.accept(selection); assertTrue(selection.isCancelled(), "Popup copies cannot be exported");
var revivalStore = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
PartyBookNativeTest.drain(fixture);
assertTrue(revivalStore.find(dead.id()).join().isPresent());
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var restored = storage.find(dead.id()).get().orElseThrow();
assertFalse(restored.dead(), "caster=" + caster.messages + ", target=" + target.messages + ", source=" + target.core.inventory.getItem(10)
+ ", reservation=" + revivalStore.find(dead.id()).join());
assertEquals(dead.id(), restored.id()); assertEquals(dead.name(), restored.name());
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()); assertEquals(restored.maximumHealth(), restored.health());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(1)).orElseThrow());
assertEquals(1, target.core.inventory.getItem(10).getAmount());
assertEquals(new games.dmg.fantasystick.items.TailBooks.Identity(dead.id(), dead.deathId()),
new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow());
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).active(target.core.id).isEmpty());
fixture.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));
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(1)).orElseThrow());
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
}
}
@Test void stackedTailWithNoSafeSheetDestinationChangesNothingAndKeepsTheCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Full inventory", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
for (int slot = 0; slot <= 40; slot++) if (slot <= 35 || slot == 40) {
target.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
}
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); tail.setAmount(2); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
org.mockito.Mockito.doReturn(new org.bukkit.Location(caster.world, 0, 65.6, 0)).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var popup = target.inventories.getFirst(); var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
fixture.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(fixture);
assertEquals(tail, target.core.inventory.getItem(10));
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
assertTrue(target.messages.stream().anyMatch(message -> message.contains("empty direct inventory slot")));
assertNotNull(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
}
}
@Test void confirmationRechecksTheExactTwentyFourHourBoundaryBeforeChangingTheTail() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player); fixture.activate(caster);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var recent = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Boundary", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, recent);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); repository.create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var popup = target.inventories.getFirst();
var expired = new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(), dead.name(), dead.revision().add(BigInteger.ONE),
dead.stats(), dead.level(), dead.experience(), dead.unspentPoints(), dead.health(), dead.learned(), dead.loadout(),
dead.cooldowns(), dead.restrictedKills(), true, dead.deathId(),
java.time.Instant.now().minusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.SECONDS));
repository.replace(expired, dead.revision());
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
fixture.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(fixture);
assertEquals(tail, target.core.inventory.getItem(10));
assertTrue(((games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters")).find(dead.id()).get().orElseThrow().dead());
assertTrue(((games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals")).find(dead.id()).join().isEmpty());
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no longer eligible")));
}
}
@Test void eyeRayRequiresAValidTargetAndEmptyResultStillConsumesTheDailyCooldown() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(12); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0); eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
var use = new org.bukkit.event.player.PlayerInteractEvent(caster.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
caster.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
fixture.dispatcher.accept(use);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertFalse(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("revive"));
org.mockito.Mockito.doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(12, 65, 0), target.player))
.when(caster.world).rayTraceEntities(org.mockito.ArgumentMatchers.any(org.bukkit.Location.class),
org.mockito.ArgumentMatchers.any(org.bukkit.util.Vector.class), org.mockito.ArgumentMatchers.eq(12.0),
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(caster.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, caster.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertTrue(storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive")
.isAfter(java.time.Instant.now().plusSeconds(86300)));
assertTrue(caster.messages.stream().anyMatch(message -> message.contains("no direct-inventory Tail")));
assertTrue(target.inventories.isEmpty());
}
}
@Test void unlearningDuringFantasyTargetPopupCancelsSelectionWithoutResettingDailyDeadline() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var caster = new FantasyStickLifecycleNativeTest.Actor(data); withRevive(data, caster);
var target = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(caster.core.id, caster.player); fixture.players.put(target.core.id, target.player);
fixture.activate(caster); fixture.activate(target);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Pending", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(dead);
var tail = new games.dmg.fantasystick.items.TailBooks().create(dead); target.core.inventory.setItem(10, tail);
caster.core.location.setWorld(caster.world); caster.core.location.setY(64);
target.core.location.setWorld(caster.world); target.core.location.setX(3); target.core.location.setY(64);
var eye = new org.bukkit.Location(caster.world, 0, 65.6, 0);
org.mockito.Mockito.doReturn(eye).when(caster.player).getEyeLocation();
org.mockito.Mockito.doReturn(true).when(caster.player).hasLineOfSight(target.player);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size()); var popup = target.inventories.getFirst();
assertTrue(fixture.delayedTicks.contains(6000L), "The popup expires at exactly five real-time minutes");
var view = org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);
org.mockito.Mockito.when(view.getTopInventory()).thenReturn(popup);
org.mockito.Mockito.when(view.getBottomInventory()).thenReturn(target.core.inventory);
org.mockito.Mockito.when(view.getPlayer()).thenReturn(target.player);
caster.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, caster);
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Unlearn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var refunded = storage.find(caster.core.record.id()).get().orElseThrow();
var deadline = refunded.cooldowns().get("revive"); assertNotNull(deadline); assertFalse(refunded.learned().contains("revive"));
assertTrue(java.util.stream.IntStream.range(0, popup.getSize()).allMatch(slot -> popup.getItem(slot) == null));
fixture.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));
assertTrue(storage.find(dead.id()).get().orElseThrow().dead()); assertEquals(tail, target.core.inventory.getItem(10));
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[Learn]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, caster, PartyBookNativeTest.button(caster, "[3]"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
caster.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(caster.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture);
assertEquals(1, target.inventories.size(), "Relearning cannot bypass the original daily deadline");
assertEquals(deadline, storage.find(caster.core.record.id()).get().orElseThrow().cooldowns().get("revive"));
var transferred = caster.core.inventory.getItemInOffHand().clone();
caster.core.inventory.setItemInOffHand(null); fixture.changed(caster);
PartyBookNativeTest.drain(fixture); fixture.awaitCharacterAndReceipt(caster);
var current = storage.find(caster.core.record.id()).get().orElseThrow();
var renamed = storage.renameCharacter(current.id(), "Renamed daily caster").get().orElseThrow();
var nextHolder = new FantasyStickLifecycleNativeTest.Actor(data); nextHolder.core.inventory.setItemInOffHand(transferred);
fixture.players.put(nextHolder.core.id, nextHolder.player); fixture.activate(nextHolder);
nextHolder.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEntityEvent(nextHolder.player, target.player,
org.bukkit.inventory.EquipmentSlot.HAND));
PartyBookNativeTest.drain(fixture);
assertTrue(nextHolder.messages.stream().anyMatch(message -> message.contains("ready in")));
assertEquals("Renamed daily caster", renamed.name()); assertEquals(deadline, renamed.cooldowns().get("revive"));
}
}
@Test void startupReconciliationCompletesOnlyAProvedDeliveredSheetAndClearsAnUnchangedTail() throws Exception {
var target = new FantasyStickLifecycleNativeTest.Actor(data); target.core.inventory.setItemInOffHand(null);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var death = java.util.UUID.randomUUID(); var died = java.time.Instant.now().minusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
var dead = new games.dmg.fantasystick.persistence.CharacterRecord(base.id(), "Interrupted", base.revision(), base.stats(),
base.level(), base.experience(), base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(),
base.cooldowns(), base.restrictedKills(), true, death, died);
repository.create(dead);
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(), death, java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
10, 1, -1);
try (var store = new games.dmg.fantasystick.persistence.RevivalStore(data)) { store.reserve(reservation).join(); }
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.players.put(target.core.id, target.player); fixture.plugin.setEnabled(true);
var store = (games.dmg.fantasystick.persistence.RevivalStore)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revivals");
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
assertFalse(storage.find(dead.id()).get().orElseThrow().dead());
assertTrue(store.find(dead.id()).join().isEmpty());
assertEquals(dead.id(), new games.dmg.fantasystick.items.CharacterSheets()
.identify(target.core.inventory.getItem(10)).orElseThrow());
var secondBase = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var secondDeath = java.util.UUID.randomUUID();
var second = new games.dmg.fantasystick.persistence.CharacterRecord(secondBase.id(), "Not delivered", secondBase.revision(),
secondBase.stats(), secondBase.level(), secondBase.experience(), secondBase.unspentPoints(), java.math.BigDecimal.ZERO,
secondBase.learned(), secondBase.loadout(), secondBase.cooldowns(), secondBase.restrictedKills(), true, secondDeath, died);
repository.create(second); var tail = new games.dmg.fantasystick.items.TailBooks().create(second); target.core.inventory.setItem(12, tail);
var untouched = new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), target.core.id, second.id(), secondDeath,
java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS), 12, 1, -1);
store.reserve(untouched).join();
((games.dmg.fantasystick.ability.Revive) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "revive")).reconcile(target.player);
store.target(target.core.id).join(); PartyBookNativeTest.drain(fixture);
assertTrue(storage.find(second.id()).get().orElseThrow().dead());
assertEquals(tail, target.core.inventory.getItem(12)); assertTrue(store.find(second.id()).join().isEmpty());
}
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(page.raw(), label, ability); if (found != null) return found;
}
throw new AssertionError("Missing " + label + " for " + ability);
}
private static net.minecraft.network.chat.ClickEvent.Custom abilityButton(
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 = abilityButton(child, label, ability); if (found != null) return found;
}
return null;
}
private static void withRevive(Path data, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.WIS, BigInteger.valueOf(30));
stats.put(games.dmg.fantasystick.progression.Stat.CHR, BigInteger.valueOf(24));
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(12));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), java.util.Set.of("revive"),
java.util.List.of("revive", "", "", "", "", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null);
repository.replace(saved, old.revision());
}
@Test void packagedDefinitionMatchesReviewedReviveContract() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
var revive = registry.find("revive").orElseThrow();
assertEquals(org.bukkit.Material.ECHO_SHARD, revive.icon());
assertEquals(BigInteger.ONE, revive.pointCost());
assertEquals(BigInteger.valueOf(30), revive.requirements().get(games.dmg.fantasystick.progression.Stat.WIS));
assertEquals(BigInteger.valueOf(24), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CHR));
assertEquals(BigInteger.valueOf(12), revive.requirements().get(games.dmg.fantasystick.progression.Stat.CON));
assertEquals(Duration.ofHours(24), revive.cooldown());
assertEquals(games.dmg.fantasystick.session.ControlInput.Gesture.USE, revive.gesture());
assertEquals(games.dmg.fantasystick.ability.AbilityDefinition.Activation.SELECTION, revive.activation());
}
}
}
@@ -0,0 +1,11 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class RoleplayAvailabilityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredToggleExitsEveryActiveSessionBlocksEntryAndAllowsRecoveryThenLaterEntry()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("roleplay-disabled"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}));assertEquals(java.util.List.of("false","true"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));for(String bad:java.util.List.of("TRUE","1","yes","")){f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",bad});assertTrue(sender.messages.getLast().contains("true|false"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true",""}).isEmpty());var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var ordinary=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(ordinary.core.id,ordinary.player);var aBefore=a.core.snapshot();var bBefore=b.core.snapshot();var ordinaryBefore=ordinary.core.snapshot();assertDoesNotThrow(()->f.activate(a),"initial A activation");assertDoesNotThrow(()->f.activate(b),"initial B activation");a.core.health=4;save(f,sender,"true");f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertTrue(sessions.active(b.core.id).isEmpty());assertArrayEquals(aBefore,a.core.snapshot());assertArrayEquals(bBefore,b.core.snapshot());assertArrayEquals(ordinaryBefore,ordinary.core.snapshot());assertTrue(a.core.online&&b.core.online);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(0,new java.math.BigDecimal("4").compareTo(storage.find(a.core.record.id()).toCompletableFuture().get().orElseThrow().health()));var c=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(c.core.id,c.player);var cBefore=c.core.snapshot();f.changed(c);PartyBookNativeTest.drain(f);assertTrue(sessions.active(c.core.id).isEmpty());assertArrayEquals(cBefore,c.core.snapshot());assertTrue(c.messages.stream().anyMatch(message->message.toLowerCase().contains("disabled")));
var recovery=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(recovery.core.id,recovery.player);var recoveryBefore=recovery.core.snapshot();var session=java.util.UUID.randomUUID();f.receipts().prepare(recovery.core.id,session,recovery.core.record.id()).toCompletableFuture().get();assertEquals(games.dmg.fantasystick.items.EquipmentStorage.PackResult.PACKED,new games.dmg.fantasystick.items.EquipmentStorage().pack(recovery.core.inventory,recovery.core.id,session));recovery.core.marker(session,"PACKED");f.command.execute(sender.port,"fantasystick",new String[]{"recover",recovery.core.id.toString()});f.tick();assertArrayEquals(recoveryBefore,recovery.core.snapshot());save(f,sender,"false");var policy=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");assertFalse(policy.disabled());f.reenter(c);assertEquals(c.core.record.id(),sessions.active(c.core.id).orElseThrow().character());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));}}
@Test void reenableWaitsForAcceptedRoleplayWriteAndOldLifetimeCannotReplayExit()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var old=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(old,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});f.plugin.setEnabled(false);f.plugin.setEnabled(true);assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));release.countDown();f.tick();assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);assertArrayEquals(before,a.core.snapshot());}}
@Test void startupLoadsDisabledPolicyAndInvalidTypedValuesFailBeforeRegistration()throws Exception{var enabled=data.resolve("enabled");java.nio.file.Files.createDirectories(enabled);java.nio.file.Files.writeString(enabled.resolve("config.yml"),"roleplay-disabled: true\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(enabled)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(enabled);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=true"));}var invalid=data.resolve("invalid");java.nio.file.Files.createDirectories(invalid);java.nio.file.Files.writeString(invalid.resolve("config.yml"),"roleplay-disabled: 'true'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(invalid)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());assertTrue(f.tasks.isEmpty());}}
@Test void failedRestorationRetainsEvidenceWhileDisabled()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);int box=java.util.stream.IntStream.range(9,36).filter(slot->a.core.inventory.getItem(slot)!=null&&a.core.inventory.getItem(slot).getType()==org.bukkit.Material.SHULKER_BOX).findFirst().orElseThrow();a.core.inventory.setItem(box,null);var before=a.core.snapshot();save(f,sender,"true");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var state=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();assertNotNull(state.pending());assertNotNull(state.lastExit());assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());assertTrue(a.core.online);}}
@Test void acknowledgedDisableCancelsPendingEntryAndFailedSaveChangesNothing()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(characters,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));f.changed(a);save(f,sender,"true");release.countDown();characters.find(java.util.UUID.randomUUID()).toCompletableFuture().get();PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());f.plugin.setEnabled(false);}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);f.activate(a);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isPresent());}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -0,0 +1,214 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.bukkit.event.player.PlayerInteractEvent;
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 SecondWindNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "second_wind"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, a.player.getHealth());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(0, BigDecimal.valueOf(11).compareTo(saved.health())); assertEquals(11, a.player.getHealth());
assertNotNull(saved.cooldowns().get("second_wind"));
assertFalse(a.messages.stream().anyMatch(message -> message.contains("support")));
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);
assertEquals(11, a.player.getHealth(), "Persisted healing survives exit and resume");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "second_wind");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints());
assertEquals(saved.cooldowns().get("second_wind"), refunded.cooldowns().get("second_wind"));
learnAndAssign(f, a); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
for (boolean full : new boolean[]{true, false}) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve(Boolean.toString(full)))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve(Boolean.toString(full)));
withSecondWind(data.resolve(Boolean.toString(full)), a, true, BigDecimal.valueOf(full ? 22 : 4));
f.players.put(a.core.id, a.player); f.activate(a);
if (!full) {
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM) {
event.setCancelled(true);
}
}
}
f.dispatcher.register(new Veto(), f.plugin);
}
double before = a.player.getHealth(); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(before, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var saved = storage.find(a.core.record.id()).get().orElseThrow();
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
else assertNotNull(saved.cooldowns().get("second_wind"));
}
}
@Test void failedHealthPersistencePublishesNoNativeHealing() {
var characters = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var player = mock(org.bukkit.entity.Player.class);
var attribute = mock(org.bukkit.attribute.AttributeInstance.class);
when(player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).thenReturn(attribute);
when(attribute.getValue()).thenReturn(22.0); when(player.getHealth()).thenReturn(4.0);
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, BigInteger.valueOf(12));
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new CharacterRecord(base.id(), base.name(), BigInteger.ONE, stats, base.level(), base.experience(),
base.unspentPoints(), BigDecimal.valueOf(4), java.util.Set.of("second_wind"),
List.of("second_wind", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
when(input.player()).thenReturn(player); when(input.session()).thenReturn(active);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var effect = new games.dmg.fantasystick.ability.SecondWind(
games.dmg.fantasystick.ability.SecondWind.Settings.defaults(), characters, sessions, Runnable::run,
ignored -> {}, () -> BigDecimal.valueOf(1024));
when(execution.stillCurrent()).thenReturn(true); when(execution.input()).thenReturn(input);
when(execution.character()).thenReturn(record); when(execution.definition()).thenReturn(effect.definition());
var failure = new java.util.concurrent.CompletableFuture<java.util.Optional<CharacterRecord>>();
when(characters.saveHealth(eq(record.id()), eq(record.revision()), any(), any())).thenReturn(failure);
effect.activate(execution); verify(player, never()).setHealth(anyDouble());
failure.completeExceptionally(new java.io.IOException("simulated health save failure"));
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
}
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("second_wind").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(20), definition.cooldown());
assertEquals(new BigDecimal("8"), definition.parameters().get("base-healing"));
assertEquals(BigInteger.valueOf(7), games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.valueOf(12)));
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("config", "give", "inspect", "recover"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("second-wind-base-healing", "second-wind-cooldown-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second-wind-"}));
assertEquals(List.of("6", "8"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "second-wind-base-healing", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow().parameters().get("base-healing"));
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, true, BigDecimal.valueOf(4));
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(14, a.player.getHealth(), "Future cast uses acknowledged base healing");
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "second-wind-base-healing", "9", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("second_wind").orElseThrow().parameters().get("base-healing"));
}
for (String invalid : List.of("second-wind-base-healing: -1\n", "second-wind-cooldown-seconds: -1\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode()));
java.nio.file.Files.createDirectories(directory); java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true));
assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "second_wind"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player, org.bukkit.event.block.Action.RIGHT_CLICK_AIR,
actor.core.inventory.getItem(2), null, org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " action for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof 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 = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withSecondWind(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned, BigDecimal health) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(12)); stats.put(Stat.WIS, 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), health,
learned ? java.util.Set.of("second_wind") : old.learned(),
learned ? List.of("second_wind", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,11 @@
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.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()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 sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
static Request request(Object[] args){return new Request((UUID)args[0],(String)args[1],(byte[])args[2],(String)args[3],(Boolean)args[4]);}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String key,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config",key,value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("Saved "+key));}
}
@@ -0,0 +1,329 @@
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.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.Location;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
assertTrue(book.contains("Cost:1")); assertTrue(book.contains("3")); assertTrue(book.contains("6s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "shield_bash"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
assertEquals(org.bukkit.Material.SHIELD, a.core.inventory.getItem(2).getType());
a.core.inventory.getInventory().setSelectedSlot(2);
var block = new org.bukkit.event.player.PlayerInteractEvent(a.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
f.dispatcher.accept(block); assertTrue(block.isCancelled(), "Contained shield cannot start ordinary blocking");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertTrue(attack.isCancelled(), "Contained shield cannot continue into vanilla damage/blocking");
assertEquals(1, damages.size()); assertEquals(5, damages.getFirst().getDamage(), 1.0e-9);
assertEquals(new Vector(0.9, 0.35, 0), applied.get());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(target, damages.getFirst().getDamageSource(),
new java.util.ArrayList<>(), 0)); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(BigInteger.TEN, storage.find(a.core.record.id()).get().orElseThrow().experience());
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
for (int route = 0; route < 2; route++) {
var directory = data.resolve("refused-" + route);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, route == 0 ? 4 : 2, 64, 0));
doReturn(route == 0).when(a.player).hasLineOfSight(target);
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
f.dispatcher.accept(attack); assertTrue(attack.isCancelled());
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
}
@Test void partyAndMixedModePlayerTargetsAreRefusedWithoutFreeVanillaHits() 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);
withShieldBash(data,a,true);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()));
a.core.inventory.getInventory().setSelectedSlot(2);
var friendly=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,b.player,true);f.dispatcher.accept(friendly);
assertTrue(friendly.isCancelled());verify(b.player,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var ordinary=mock(org.bukkit.entity.Player.class);when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var mixed=new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,ordinary,true);f.dispatcher.accept(mixed);
assertTrue(mixed.isCancelled());verify(ordinary,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
}
@Test void cancelledDamageAppliesNoKnockbackAndNeverRefundsAcknowledgedCooldown() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); event.setCancelled(true); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(target, never()).setVelocity(any(Vector.class));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() 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); withShieldBash(data, a, true);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a); f.activate(b);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
doReturn(new Location(a.world, 2, 64, 0)).when(b.player).getLocation();
doReturn(true).when(a.player).hasLineOfSight(b.player); doReturn(new Vector(0.1, 0.2, 0)).when(b.player).getVelocity();
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler public void velocity(org.bukkit.event.player.PlayerVelocityEvent event) {
if (event.getPlayer() == b.player) event.setCancelled(true);
}
}
f.dispatcher.register(new Veto(), f.plugin);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, b.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null;
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
}
}
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "shield-bash-cooldown-seconds: 60\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(target);
when(target.getVelocity()).thenReturn(new Vector()); var hits = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> { hits.incrementAndGet(); var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0))); return null; }).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "shield_bash");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("shield_bash"));
learnAndAssign(f, a); a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get());
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b); b.core.inventory.getInventory().setSelectedSlot(2);
doReturn(new Location(a.world, 0, 64, 0)).when(b.player).getLocation(); doReturn(true).when(b.player).hasLineOfSight(target);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(b.player, target, true));
PartyBookNativeTest.drain(f); assertEquals(1, hits.get()); assertEquals(deadline,
storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
}
@Test void exactDamageFormulaSupportsFiniteUncappedStats() {
assertEquals(BigInteger.valueOf(5), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(12)));
assertEquals(BigInteger.valueOf(3), games.dmg.fantasystick.ability.ShieldBash.damage(BigInteger.valueOf(9)));
var huge = BigInteger.TEN.pow(500);
assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
}
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
var registry=new games.dmg.fantasystick.ability.AbilityRegistry();var bash=new games.dmg.fantasystick.ability.ShieldBash(
games.dmg.fantasystick.ability.ShieldBash.Settings.defaults(),ignored->{});registry.register(bash.definition());
var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats=new EnumMap<>(base.stats());stats.put(Stat.STR,BigInteger.valueOf(12));stats.put(Stat.CON,BigInteger.valueOf(12));
var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),
java.util.Set.of("shield_bash"),List.of("shield_bash","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);
var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());
var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());
var world=mock(org.bukkit.World.class);doReturn(new Location(world,0,64,0)).when(player).getLocation();
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(target.getLocation()).thenReturn(new Location(world,2,64,0));
doReturn(true).when(player).hasLineOfSight(target);when(sessions.current(active)).thenReturn(true);
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown failure"));
when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any())).thenReturn(failed);
var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,
(delay,action)->()->{},ignored->{},ignored->{});
engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"shield_bash",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK,
target,0,1,()->true));
verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("shield_bash"),any());
verify(target,never()).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));verify(target,never()).setVelocity(any(Vector.class));
engine.close();bash.close();parties.close();
}
@Test void configuredDefinitionAdminRoutesRestartAndFutureStrikeCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"shield-bash-base-damage: 8\nshield-bash-cooldown-seconds: 0\nshield-bash-range-blocks: 5\n" +
"shield-bash-horizontal-knockback: 1.2\nshield-bash-minimum-vertical-knockback: 0.5\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("shield_bash").orElseThrow(); assertEquals(java.time.Duration.ZERO, definition.cooldown());
assertEquals(0, new java.math.BigDecimal("8").compareTo(definition.parameters().get("base-damage")));
assertEquals(0, new java.math.BigDecimal("5").compareTo(definition.parameters().get("range")));
assertEquals(0, new java.math.BigDecimal("1.2").compareTo(definition.parameters().get("horizontal-knockback")));
assertEquals(0, new java.math.BigDecimal("0.5").compareTo(definition.parameters().get("minimum-vertical-knockback")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed=false;
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash"}).isEmpty());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed=true;
assertEquals(List.of("shield-bash-base-damage","shield-bash-cooldown-seconds","shield-bash-horizontal-knockback",
"shield-bash-minimum-vertical-knockback","shield-bash-range-blocks"),
f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","shield-bash-"}));
assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",
new String[]{"config","shield-bash-base-damage",""}));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage"});
assertTrue(sender.messages.getLast().contains("=8"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9"});
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
assertEquals(0,new java.math.BigDecimal("9").compareTo(registry.find("shield_bash").orElseThrow().parameters().get("base-damage")));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withShieldBash(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
var target=mock(org.bukkit.entity.LivingEntity.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(target.getLocation()).thenReturn(new Location(a.world,4,64,0));
doReturn(true).when(a.player).hasLineOfSight(target);when(target.getVelocity()).thenReturn(new Vector());
var amount=new java.util.concurrent.atomic.AtomicReference<Double>();var movement=new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call->{amount.set(call.getArgument(0));var source=(org.bukkit.damage.DamageSource)call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player,target,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,(Double)call.getArgument(0)));return null;
}).when(target).damage(anyDouble(),any(org.bukkit.damage.DamageSource.class));
doAnswer(call->{movement.set(call.getArgument(0));return null;}).when(target).setVelocity(any(Vector.class));
a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player,target,true));
PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,amount.get(),1.0e-9);assertEquals(new Vector(1.2,0.5,0),movement.get());
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port,"fantasystick",new String[]{"config","shield-bash-base-damage","9","extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
assertEquals(0,new java.math.BigDecimal("9").compareTo(reloaded.find("shield_bash").orElseThrow().parameters().get("base-damage")));
}
for(String invalid:List.of("shield-bash-base-damage: -1\n","shield-bash-cooldown-seconds: -1\n",
"shield-bash-range-blocks: 0\n","shield-bash-horizontal-knockback: 9\n",
"shield-bash-minimum-vertical-knockback: 9\n")){
var directory=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"),invalid);
try(var f=new FantasyStickLifecycleNativeTest.Fixture(directory)){
assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());
}
}
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[Learn]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
PartyBookNativeTest.click(fixture, actor, abilityButton(actor, "[3]", "shield_bash"));
PartyBookNativeTest.drain(fixture); PartyBookNativeTest.drain(fixture);
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability); if (found != null) return found; }
throw new AssertionError("Missing " + label + " for " + ability);
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component, String label, String ability) {
if (component.getString().equals(label) && component.getStyle().getClickEvent() instanceof 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 = abilityButton(child, label, ability); if (found != null) return found; }
return null;
}
private static void withShieldBash(Path data, FantasyStickLifecycleNativeTest.Actor actor, boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.STR, BigInteger.valueOf(12)); stats.put(Stat.CON, 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 ? java.util.Set.of("shield_bash") : old.learned(),
learned ? List.of("shield_bash", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,294 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.List;
import net.minecraft.network.chat.ClickEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
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 StoneSkinNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
assertTrue(text.contains("Cost:1")); assertTrue(text.contains("25%")); assertTrue(text.contains("4s"));
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(BigInteger.valueOf(39), storage.find(a.core.record.id()).get().orElseThrow().unspentPoints());
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var attacker = mock(org.bukkit.entity.Zombie.class);
var hit = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
assertEquals(80L, f.delayedTicks.getLast());
f.delayed.getLast().run();
var expired = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(expired); assertEquals(8, expired.getDamage(), 1.0e-9);
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
var unlearn = abilityButton(a, "[Unlearn]", "stone_skin");
PartyBookNativeTest.click(f, a, unlearn); PartyBookNativeTest.click(f, a, unlearn);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var refunded = storage.find(a.core.record.id()).get().orElseThrow();
assertEquals(BigInteger.valueOf(40), refunded.unspentPoints()); assertEquals(deadline, refunded.cooldowns().get("stone_skin"));
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
PartyBookNativeTest.click(f, a, abilityButton(a, "[3]", "stone_skin"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
}
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var fall = new EntityDamageEvent(a.player, EntityDamageEvent.DamageCause.FALL,
mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
var arrow = mock(org.bukkit.entity.Arrow.class);
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
}
f.dispatcher.register(new Veto(), f.plugin);
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
}
}
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(2, f.delayed.size());
f.delayed.getFirst().run();
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Old expiry cannot remove replacement protection");
f.delayed.getLast().run();
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
var deadline = storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9, "Exit clears owned reduction");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var b = new FantasyStickLifecycleNativeTest.Actor(data); b.core.inventory.setItemInOffHand(sheet);
f.players.put(b.core.id, b.player); f.activate(b);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
}
}
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 0\nstone-skin-duration-seconds: 4\nstone-skin-damage-reduction-percent: 25\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a); use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var sender = new EquipmentAdminCommandNativeTest.Sender();
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "6"});
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
for (int i = 0; i < 2; i++) { var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run(); }
assertEquals(6, hit(f, a, 8).getDamage(), 1.0e-9, "Active cast retains captured 25 percent");
assertEquals(80L, f.delayedTicks.getFirst());
var b = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, b, true);
f.players.put(b.core.id, b.player); f.activate(b); use(f, b); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertEquals(4, hit(f, b, 8).getDamage(), 1.0e-9); assertEquals(120L, f.delayedTicks.getLast());
}
}
@Test void acceptedDamageReportsExactPreventionWhileCancelledDamageReportsNothing() {
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation);
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
var player = mock(org.bukkit.entity.Player.class); var id = java.util.UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id); when(input.player()).thenReturn(player);
when(execution.input()).thenReturn(input); when(execution.definition()).thenReturn(stone.definition());
when(execution.stillCurrent()).thenReturn(true);
stone.activate(execution);
var weaker = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(weaker.stillCurrent()).thenReturn(true);
var unrelated = mitigation.grant(weaker, player, new java.math.BigDecimal("20"));
var accepted = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(accepted); mitigation.accepted(accepted);
verify(execution).report(games.dmg.fantasystick.ability.AbilityReport.Type.PREVENTION,
player, new java.math.BigDecimal("2.0"));
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(cancelled); cancelled.setCancelled(true); mitigation.accepted(cancelled);
verify(execution, times(1)).report(any(), any(), any());
verify(weaker, never()).report(any(), any(), any());
try { unrelated.close(); } catch (Exception failure) { fail(failure); }
stone.close(); mitigation.close();
}
@Test void failedCooldownPersistenceCreatesNoReduction() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var registry = new games.dmg.fantasystick.ability.AbilityRegistry();
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var stone = new games.dmg.fantasystick.ability.StoneSkin(
games.dmg.fantasystick.ability.StoneSkin.Settings.defaults(), mitigation); registry.register(stone.definition());
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new EnumMap<>(base.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
var record = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), base.health(), java.util.Set.of("stone_skin"),
List.of("stone_skin", "", "", "", "", "", ""), base.cooldowns(), base.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(active.player());
when(sessions.current(active)).thenReturn(true); when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();
failed.completeExceptionally(new java.io.IOException("simulated cooldown save failure"));
when(storage.commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any())).thenReturn(failed);
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "stone_skin",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
verify(storage).commitAbilityCooldown(eq(record.id()), eq(record.revision()), eq("stone_skin"), any());
engine.close(); stone.close(); mitigation.close(); parties.close();
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
var definition = registry.find("stone_skin").orElseThrow();
assertEquals(java.time.Duration.ofSeconds(2), definition.cooldown());
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("duration-seconds")));
assertEquals(0, new java.math.BigDecimal("40").compareTo(definition.parameters().get("damage-reduction-percent")));
var sender = new EquipmentAdminCommandNativeTest.Sender(); sender.allowed = false;
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin"}).isEmpty());
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
assertTrue(sender.messages.getLast().contains("Permission denied")); sender.allowed = true;
assertEquals(List.of("stone-skin-cooldown-seconds", "stone-skin-damage-reduction-percent", "stone-skin-duration-seconds"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "stone-skin-"}));
assertEquals(List.of("4", "6"), f.command.tabComplete(sender.port, "fantasystick",
new String[]{"config", "stone-skin-duration-seconds", ""}));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds"});
assertTrue(sender.messages.getLast().contains("=6"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
assertEquals(0, new java.math.BigDecimal("7").compareTo(
registry.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
f.command.execute(sender.port, "fantasystick", new String[]{"config", "stone-skin-duration-seconds", "7", "extra"});
assertTrue(sender.messages.getLast().contains("Usage"));
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
var reloaded = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("7").compareTo(
reloaded.find("stone_skin").orElseThrow().parameters().get("duration-seconds")));
}
for (String invalid : List.of("stone-skin-cooldown-seconds: -1\n",
"stone-skin-duration-seconds: 0\n", "stone-skin-damage-reduction-percent: 101\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
assertThrows(IllegalArgumentException.class, () -> f.plugin.setEnabled(true)); assertTrue(f.order.isEmpty());
}
}
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor target, double amount) {
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), amount);
fixture.dispatcher.accept(event); return event;
}
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) {
actor.core.inventory.getInventory().setSelectedSlot(2);
fixture.dispatcher.accept(new PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, actor.core.inventory.getItem(2), null,
org.bukkit.block.BlockFace.SELF, EquipmentSlot.HAND));
}
private static ClickEvent.Custom abilityButton(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 = abilityButton(page.raw(), label, ability);
if (found != null) return found;
}
fail("Missing " + label + " for " + ability); return null;
}
private static ClickEvent.Custom abilityButton(net.minecraft.network.chat.Component component,
String label, String ability) {
if (component.getString().equals(label)
&& component.getStyle().getClickEvent() instanceof 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 = abilityButton(child, label, ability); if (found != null) return found;
}
return null;
}
private static void withStoneSkin(Path data, FantasyStickLifecycleNativeTest.Actor actor,
boolean learned) throws Exception {
java.nio.file.Files.createDirectories(data);
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.CON, BigInteger.valueOf(14));
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 ? java.util.Set.of("stone_skin") : old.learned(),
learned ? List.of("stone_skin", "", "", "", "", "", "") : old.loadout(),
old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
}
}
@@ -0,0 +1,9 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import java.time.Duration;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SwitchDelayAdminNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredCommandDurablyChangesActualExitAndEntryPolicyWithoutRewritingDeadlines()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("character-swap-cooldown-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-s"}));assertEquals(java.util.List.of("900"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;for(String bad:java.util.List.of("-1","1.5","bad","9223372036854775807")){f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",bad});assertTrue(sender.messages.getLast().contains("invalid"),bad+" -> "+sender.messages);}f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"37");
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");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);sessions.exit(a.player);sessions.exit(b.player);f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var aExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();var bExit=f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(aExit.exitedAt(),aExit.deadline()));assertEquals(Duration.ofSeconds(37),Duration.between(bExit.exitedAt(),bExit.deadline()));var otherA=new FantasyStickLifecycleNativeTest.Actor(data);var otherB=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(otherA.core.inventory.getItemInOffHand().clone());b.core.inventory.setItemInOffHand(otherB.core.inventory.getItemInOffHand().clone());save(f,sender,"0");f.activate(a);assertEquals(otherA.core.record.id(),sessions.active(a.core.id).orElseThrow().character());assertEquals(aExit,f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit());save(f,sender,"900");var policy=(games.dmg.fantasystick.persistence.SwitchDelaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchDelaySettings");assertEquals(Duration.ofSeconds(900),policy.delay());assertFalse(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().canEnter(otherB.core.record.id(),policy.delay(),java.time.Instant.now()));assertTrue(sessions.active(b.core.id).isEmpty(),"B remained active after exit");f.changed(b);f.receipts().find(b.core.id).toCompletableFuture().get();PartyBookNativeTest.drain(f);assertTrue(sessions.active(b.core.id).isEmpty(),b.messages::toString);assertEquals(bExit,f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit());sessions.exit(a.player);f.awaitCharacterAndReceipt(a);var newExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(900),Duration.between(newExit.exitedAt(),newExit.deadline()));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
@Test void exitRetainsPolicyCapturedWhenItsNativeTransitionStarts()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();save(f,sender,"37");var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(characters,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException interrupted){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(a.player);save(f,sender,"0");release.countDown();f.awaitCharacterAndReceipt(a);var exit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(exit.exitedAt(),exit.deadline()));}}
@Test void failedRegisteredWriteLeavesEffectivePolicyAndUnrelatedStateUntouched()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(data.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","0"});var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",value});assertEquals(before,sender.messages.size());var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("="+value));}
}
@@ -0,0 +1,21 @@
{
"model": {
"type": "minecraft:select",
"property": "minecraft:display_context",
"cases": [
{
"when": [
"firstperson_lefthand",
"firstperson_righthand"
],
"model": {
"type": "minecraft:empty"
}
}
],
"fallback": {
"type": "minecraft:model",
"model": "minecraft:item/paper"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"pack": {
"description": "Fantasy Stick ${version} sheet presentation",
"min_format": [88, 0],
"max_format": [88, 0]
}
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Path;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
@@ -33,6 +34,57 @@ class DistributionTest {
assertEquals(1, metadata.getPermissions().size());
assertEquals("fantasystick.admin", metadata.getPermissions().getFirst().getName());
assertEquals(org.bukkit.permissions.PermissionDefault.OP, metadata.getPermissions().getFirst().getDefault());
var configuration = jar.getJarEntry("config.yml");
assertNotNull(configuration, "Ship reviewed session defaults for administrators");
String defaults;
try (var input = jar.getInputStream(configuration)) {
defaults = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
assertTrue(defaults.contains("character-swap-cooldown-seconds: 900"),
"The delivered configuration must expose the reviewed 15-minute default");
assertTrue(defaults.contains("adventure-mode: true"));
assertTrue(defaults.contains("great-sword-base-damage: 7"));
assertTrue(defaults.contains("great-sword-recovery-seconds: 1.2"));
for (String setting : java.util.List.of("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")) {
assertTrue(defaults.contains(setting), "Missing packaged Frost Shard default: " + setting);
}
for (String setting : java.util.List.of("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")) {
assertTrue(defaults.contains(setting), "Missing packaged Longbow default: " + setting);
}
for (String setting : java.util.List.of("quickstep-cooldown-seconds: 5",
"quickstep-distance-blocks: 4", "quickstep-speed-blocks-per-tick: 1")) {
assertTrue(defaults.contains(setting), "Missing packaged Quickstep default: " + setting);
}
for (String setting : java.util.List.of("second-wind-base-healing: 6",
"second-wind-cooldown-seconds: 15")) {
assertTrue(defaults.contains(setting), "Missing packaged Second Wind default: " + setting);
}
for (String setting : java.util.List.of("stone-skin-cooldown-seconds: 14",
"stone-skin-duration-seconds: 4", "stone-skin-damage-reduction-percent: 25")) {
assertTrue(defaults.contains(setting), "Missing packaged Stone Skin default: " + setting);
}
for (String setting : java.util.List.of("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")) {
assertTrue(defaults.contains(setting), "Missing packaged Shield Bash default: " + setting);
}
for (String setting : java.util.List.of("hex-cooldown-seconds: 10", "hex-range-blocks: 12",
"hex-duration-seconds: 4", "hex-damage-reduction-percent: 20")) {
assertTrue(defaults.contains(setting), "Missing packaged Hex default: " + setting);
}
for (String setting : java.util.List.of("inspire-base-temporary-health: 4",
"inspire-cooldown-seconds: 15", "inspire-range-blocks: 12", "inspire-duration-seconds: 6")) {
assertTrue(defaults.contains(setting), "Missing packaged Inspire default: " + setting);
}
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
assertNotNull(entrypoint);
@@ -46,4 +98,38 @@ class DistributionTest {
"Server API and test dependencies must not ship in the plugin");
}
}
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
Path pack = Path.of(System.getProperty("resource.pack.zip"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-fantasy-stick-" + version + "-resource-pack.zip", pack.getFileName().toString());
byte[] bytes = java.nio.file.Files.readAllBytes(pack);
String sha1 = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-1").digest(bytes));
try (var zip = new ZipFile(pack.toFile())) {
var manifest = zip.getEntry("pack.mcmeta"); assertNotNull(manifest);
String manifestJson; try (var input = zip.getInputStream(manifest)) { manifestJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); }
assertTrue(manifestJson.contains("\"min_format\": [88, 0]"));
assertTrue(manifestJson.contains("\"max_format\": [88, 0]"));
assertTrue(manifestJson.contains(version));
var model = zip.getEntry("assets/fantasystick/items/character_sheet.json");
assertNotNull(model);
String json;
try (var input = zip.getInputStream(model)) { json = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); }
assertTrue(json.contains("minecraft:select"));
assertTrue(json.contains("minecraft:display_context"));
assertTrue(json.contains("firstperson_lefthand"));
assertTrue(json.contains("firstperson_righthand"));
assertTrue(json.contains("minecraft:empty"));
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
}
try (var jar = new JarFile(artifact.toFile())) {
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
var values = new java.util.Properties();
try (var input = jar.getInputStream(metadata)) { values.load(input); }
assertEquals(sha1, values.getProperty("sha1"));
assertEquals("https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v" + version
+ "/purpur-fantasy-stick-" + version + "-resource-pack.zip", values.getProperty("url"));
}
}
}
@@ -0,0 +1,103 @@
package games.dmg.fantasystick.crafting;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
class RecipeDiscoveryTest {
@Test void onlineJoinRepeatedFailureAndRetryAreIsolatedAndIdempotent() {
var warnings = new ArrayList<LogRecord>();
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { warnings.add(record); }
@Override public void flush() { }
@Override public void close() { }
});
var firstKnown = new LinkedHashSet<NamespacedKey>(); var firstAwards = new ArrayList<List<NamespacedKey>>();
var secondKnown = new LinkedHashSet<NamespacedKey>(); var secondAwards = new ArrayList<List<NamespacedKey>>();
var fail = new AtomicBoolean(true);
var first = player("First", firstKnown, firstAwards, fail);
var second = player("Second", secondKnown, secondAwards, new AtomicBoolean(false));
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
discovery.discoverAll(List.of(first, second));
discovery.onJoin(new PlayerJoinEvent(first, "joined"));
discovery.onJoin(new PlayerJoinEvent(first, "joined again"));
discovery.discover(second);
assertEquals(Set.of(CharacterSheets.RECIPE), firstKnown);
assertEquals(Set.of(CharacterSheets.RECIPE), secondKnown);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), firstAwards);
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), secondAwards);
assertEquals(1, warnings.size());
assertTrue(warnings.getFirst().getMessage().contains("First"));
}
@Test void missingNativePostconditionIsLoggedAndRetried() {
var warnings = new ArrayList<LogRecord>();
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
logger.addHandler(new Handler() {
@Override public void publish(LogRecord record) { warnings.add(record); }
@Override public void flush() { }
@Override public void close() { }
});
var known = new LinkedHashSet<NamespacedKey>(); var awards = new ArrayList<List<NamespacedKey>>();
var omit = new AtomicBoolean(true);
var player = proxy("Ada", known, awards, omit);
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
discovery.discover(player);
discovery.discover(player);
assertEquals(Set.of(CharacterSheets.RECIPE), known);
assertEquals(List.of(List.of(CharacterSheets.RECIPE), List.of(CharacterSheets.RECIPE)), awards);
assertEquals(1, warnings.size());
}
private static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
AtomicBoolean fail) {
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
if (method.getName().equals("getName")) return name;
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
if (method.getName().equals("discoverRecipes")) {
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
awards.add(requested); known.addAll(requested); return requested.size();
}
if (method.getName().equals("toString")) return name;
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
if (method.getName().equals("equals")) return proxy == args[0];
throw new UnsupportedOperationException(method.getName());
});
}
private static Player proxy(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
AtomicBoolean omitFirst) {
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
if (method.getName().equals("getName")) return name;
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
if (method.getName().equals("discoverRecipes")) {
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
awards.add(requested); if (!omitFirst.getAndSet(false)) known.addAll(requested); return requested.size();
}
if (method.getName().equals("toString")) return name;
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
if (method.getName().equals("equals")) return proxy == args[0];
throw new UnsupportedOperationException(method.getName());
});
}
}
@@ -108,6 +108,28 @@ class PartyRosterTest {
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
}
@Test void liveCapacityGrandfathersExistingMembersAndRechecksPendingInvitations() {
var limit = new java.util.concurrent.atomic.AtomicInteger(3);
try (var dynamic = new PartyRoster(clock, online::contains, limit::get)) {
UUID leader = player(), first = player(), second = player(), pending = player(), later = player();
var party = dynamic.create(leader).orElseThrow();
assertTrue(dynamic.accept(first, dynamic.invite(leader, party.id(), first).orElseThrow().id()));
var pendingOffer = dynamic.invite(leader, party.id(), pending).orElseThrow();
assertTrue(dynamic.accept(second, dynamic.invite(leader, party.id(), second).orElseThrow().id()));
limit.set(2);
assertEquals(3, dynamic.partyOf(leader).orElseThrow().members().size());
assertFalse(dynamic.accept(pending, pendingOffer.id()));
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty());
assertTrue(dynamic.leave(first, party.id()));
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty(), "At the limit cannot grow");
assertTrue(dynamic.leave(second, party.id()));
assertTrue(dynamic.accept(later, dynamic.invite(leader, party.id(), later).orElseThrow().id()));
limit.set(4);
assertEquals(4, dynamic.maximumSize());
assertTrue(dynamic.invite(leader, party.id(), pending).isPresent());
}
}
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
UUID leader = player(), member = player(), stranger = player();
var party = parties.create(leader).orElseThrow();
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class AdminReviveSettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedUpdates()throws Exception{assertEquals(Duration.ofSeconds(60),AdminReviveSettings.parse(null));for(Object bad:new Object[]{0,-1,"1.5","no",31536001L})assertThrows(IllegalArgumentException.class,()->AdminReviveSettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nadmin-revive-selection-timeout-seconds: 60\n");try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){var update=settings.set("90").toCompletableFuture().get();assertEquals("60",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("90",settings.value());assertEquals(Duration.ofSeconds(90),settings.timeout());settings.publish(update);assertEquals("90",settings.value());}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("90").toCompletableFuture().get());assertEquals("60",settings.value());}}
}
@@ -169,6 +169,32 @@ class CharacterStorageTest {
}
}
@Test
void abilityUnlearnRefundsExactlyOnePointClearsEverySlotAndPreservesCooldown() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var deadline = java.time.Instant.parse("2026-09-21T12:00:00Z");
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "fireball", "", "", "", ""), java.util.Map.of("fireball", deadline),
base.restrictedKills(), false, null, null);
repository.create(learned);
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refunded = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refunded.status());
var saved = refunded.record().orElseThrow();
assertEquals(BigInteger.valueOf(40), saved.unspentPoints());
assertFalse(saved.learned().contains("fireball"));
assertTrue(saved.loadout().stream().noneMatch("fireball"::equals));
assertEquals(deadline, saved.cooldowns().get("fireball"));
assertEquals(saved, repository.load(saved.id()).orElseThrow());
assertEquals(CharacterStorage.AbilityMutationStatus.NOT_LEARNED,
storage.unlearnAbility(saved.id(), saved.revision(), "fireball").join().status());
assertEquals(saved, repository.load(saved.id()).orElseThrow());
}
}
@Test
void abilityUnlockRefusesMissingRequirementsAndPointsWithoutChangingSavedState() throws Exception {
var repository = new YamlCharacterRepository(data);
@@ -227,6 +253,32 @@ class CharacterStorageTest {
}
}
@Test void failedAbilityRefundLeavesLearnedStateAndPointsUnchangedForOneRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var learned = new CharacterRecord(base.id(), base.name(), base.revision(), base.stats(), base.level(),
base.experience(), BigInteger.valueOf(39), base.health(), java.util.Set.of("fireball"),
java.util.List.of("fireball", "", "", "", "", "", ""),
java.util.Map.of("fireball", java.time.Instant.parse("2026-09-22T00:00:00Z")),
base.restrictedKills(), false, null, null);
repository.create(learned);
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("simulated refund failure"); });
try (var storage = new CharacterStorage(failing, Clock.systemUTC())) {
assertThrows(CompletionException.class,
() -> storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join());
}
assertEquals(learned, repository.load(learned.id()).orElseThrow());
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
var refund = storage.unlearnAbility(learned.id(), learned.revision(), "fireball").join();
assertEquals(CharacterStorage.AbilityMutationStatus.APPLIED, refund.status());
assertEquals(BigInteger.valueOf(40), refund.record().orElseThrow().unspentPoints());
assertEquals(CharacterStorage.StatPurchaseStatus.STALE,
storage.purchaseStat(learned.id(), learned.revision(), games.dmg.fantasystick.progression.Stat.STR,
learned.health(), new java.math.BigDecimal("1024")).join().status());
}
}
@Test void failedAbilityUnlockSaveLeavesPointsAndLearnedStateUnchangedForRetry() throws Exception {
var repository = new YamlCharacterRepository(data);
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
@@ -246,6 +298,24 @@ class CharacterStorageTest {
}
}
@Test void nativeReviveBoundsFullHealthWithoutCappingTransferableCon() throws Exception {
var base = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var stats = new java.util.EnumMap<>(base.stats());
stats.put(games.dmg.fantasystick.progression.Stat.CON, BigInteger.valueOf(5000));
var death = UUID.randomUUID(); var died = java.time.Instant.parse("2026-09-20T08:00:00Z");
var dead = new CharacterRecord(base.id(), base.name(), base.revision(), stats, base.level(), base.experience(),
base.unspentPoints(), java.math.BigDecimal.ZERO, base.learned(), base.loadout(), base.cooldowns(),
base.restrictedKills(), true, death, died);
var repository = new YamlCharacterRepository(data); repository.create(dead);
try (var storage = new CharacterStorage(repository,
Clock.fixed(java.time.Instant.parse("2026-09-20T09:00:00Z"), ZoneOffset.UTC))) {
var restored = storage.restoreDeathRecord(dead.id(), death, new java.math.BigDecimal("1024")).join().orElseThrow();
assertEquals(new java.math.BigDecimal("1024"), restored.health());
assertEquals(BigInteger.valueOf(5000), restored.stats().get(games.dmg.fantasystick.progression.Stat.CON));
}
}
@Test
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
@@ -0,0 +1,100 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FrostShardSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new FrostShardSettings(data, defaults())) {
var damage = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture();
var range = settings.set(FrostShardSettings.RANGE, "40.5").toCompletableFuture();
var savedDamage = damage.get(5, TimeUnit.SECONDS);
var savedRange = range.get(5, TimeUnit.SECONDS);
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
assertEquals(new java.math.BigInteger("4"), settings.settings().baseDamage(),
"Durability acknowledgment alone must not publish runtime settings");
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
assertEquals(40.5, settings.settings().range());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
assertEquals("retained", saved.get("unrelated"));
assertEquals("9", String.valueOf(saved.get(FrostShardSettings.BASE_DAMAGE)));
assertEquals("40.5", String.valueOf(saved.get(FrostShardSettings.RANGE)));
}
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
try (var settings = new FrostShardSettings(data, defaults())) {
for (var entry : Map.of(
FrostShardSettings.BASE_DAMAGE, "-1",
FrostShardSettings.COOLDOWN, "31536001",
FrostShardSettings.RANGE, "0",
FrostShardSettings.LIFETIME, "0",
FrostShardSettings.SPEED, "Infinity",
FrostShardSettings.AMPLIFIER, "256",
FrostShardSettings.SLOWNESS, "0").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.BASE_DAMAGE, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(FrostShardSettings.COOLDOWN, "0.0000000001"));
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "frost-shard-base-damage: 4\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new FrostShardSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
assertTrue(Files.readString(data.resolve("config.yml")).contains("4"));
var retry = settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertEquals("9", retry.value());
assertEquals("4", settings.value(FrostShardSettings.BASE_DAMAGE));
settings.publish(retry);
assertEquals("9", settings.value(FrostShardSettings.BASE_DAMAGE));
}
}
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside");
Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new FrostShardSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
}
var settings = new FrostShardSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(FrostShardSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : FrostShardSettings.keys()) result.put(key, FrostShardSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;import java.util.*;import java.util.concurrent.TimeUnit;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class HexSettingsTest{@TempDir Path data;
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new HexSettings(data,defaults())){
var r=s.set(HexSettings.RANGE,"16").toCompletableFuture();var p=s.set(HexSettings.REDUCTION,"30").toCompletableFuture();var rr=r.get(5,TimeUnit.SECONDS);var pp=p.get(5,TimeUnit.SECONDS);
assertEquals(12,s.settings().range(),0);s.publish(pp);s.publish(rr);assertEquals(16,s.settings().range(),0);assertEquals(0,new java.math.BigDecimal("30").compareTo(s.settings().reductionPercent()));
var y=(Map<?,?>)new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));assertEquals("retained",y.get("unrelated"));assertEquals("16",String.valueOf(y.get(HexSettings.RANGE)));}}
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new HexSettings(data,defaults())){for(var e:Map.of(HexSettings.COOLDOWN,"-1",HexSettings.RANGE,"0",HexSettings.DURATION,"0",HexSettings.REDUCTION,"101").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(HexSettings.COOLDOWN,"0.0000000001"));}
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new HexSettings(data,defaults(),(a,b)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(a,b,StandardCopyOption.REPLACE_EXISTING);})){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(HexSettings.RANGE));}}
@Test void unsafeAndClosedStoresFailSafely()throws Exception{if(!System.getProperty("os.name","").toLowerCase().contains("win")){var out=data.resolveSibling(data.getFileName()+"-outside");Files.writeString(out,"safe\n");Files.createSymbolicLink(data.resolve("config.yml"),out);try(var s=new HexSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
var s=new HexSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(HexSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));s.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);}
private static Map<String,String>defaults(){var m=new LinkedHashMap<String,String>();for(String k:HexSettings.keys())m.put(k,HexSettings.defaultValue(k));return m;}}
@@ -0,0 +1,13 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;import java.util.*;import java.util.concurrent.TimeUnit;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class InspireSettingsTest{@TempDir Path data;
@Test void serializesPreservesAndPublishesAcknowledgedSnapshots()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: retained\n");try(var s=new InspireSettings(data,defaults())){
var r=s.set(InspireSettings.RANGE,"16").toCompletableFuture();var p=s.set(InspireSettings.BASE,"8").toCompletableFuture();var rr=r.get(5,TimeUnit.SECONDS);var pp=p.get(5,TimeUnit.SECONDS);
assertEquals(12,s.settings().range(),0);s.publish(pp);s.publish(rr);assertEquals(16,s.settings().range(),0);assertEquals(new java.math.BigInteger("8"),s.settings().baseTemporaryHealth());
var y=(Map<?,?>)new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));assertEquals("retained",y.get("unrelated"));assertEquals("16",String.valueOf(y.get(InspireSettings.RANGE)));}}
@Test void invalidAndFailedWritesRetainState()throws Exception{try(var s=new InspireSettings(data,defaults())){for(var e:Map.of(InspireSettings.COOLDOWN,"-1",InspireSettings.RANGE,"0",InspireSettings.DURATION,"0",InspireSettings.BASE,"-1").entrySet())assertThrows(IllegalArgumentException.class,()->s.set(e.getKey(),e.getValue()));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.COOLDOWN,"0.0000000001"));assertThrows(IllegalArgumentException.class,()->s.set(InspireSettings.BASE,"1.5"));}
var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var s=new InspireSettings(data,defaults(),(a,b)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(a,b,StandardCopyOption.REPLACE_EXISTING);})){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("12",s.value(InspireSettings.RANGE));}}
@Test void unsafeAndClosedStoresFailSafely()throws Exception{if(!System.getProperty("os.name","").toLowerCase().contains("win")){var out=data.resolveSibling(data.getFileName()+"-outside");Files.writeString(out,"safe\n");Files.createSymbolicLink(data.resolve("config.yml"),out);try(var s=new InspireSettings(data,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));assertEquals("safe\n",Files.readString(out));}}
var s=new InspireSettings(data.resolve("closed"),defaults());s.close();assertThrows(java.util.concurrent.ExecutionException.class,()->s.set(InspireSettings.RANGE,"16").toCompletableFuture().get(5,TimeUnit.SECONDS));s.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);}
private static Map<String,String>defaults(){var m=new LinkedHashMap<String,String>();for(String k:InspireSettings.keys())m.put(k,InspireSettings.defaultValue(k));return m;}}
@@ -0,0 +1,101 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class LongbowSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentAcceptedKeysWithoutLosingUnrelatedConfiguration() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new LongbowSettings(data, defaults())) {
var damage = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture();
var range = settings.set(LongbowSettings.RANGE, "40.5").toCompletableFuture();
var savedDamage = damage.get(5, TimeUnit.SECONDS);
var savedRange = range.get(5, TimeUnit.SECONDS);
assertEquals("9", savedDamage.value()); assertEquals("40.5", savedRange.value());
assertEquals(new java.math.BigInteger("5"), settings.settings().baseDamage(),
"Durability acknowledgment alone must not publish runtime settings");
settings.publish(savedRange); settings.publish(savedDamage); // stale callback cannot roll back the newer snapshot.
assertEquals(new java.math.BigInteger("9"), settings.settings().baseDamage());
assertEquals(40.5, settings.settings().range());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode"));
assertEquals("retained", saved.get("unrelated"));
assertEquals("9", String.valueOf(saved.get(LongbowSettings.BASE_DAMAGE)));
assertEquals("40.5", String.valueOf(saved.get(LongbowSettings.RANGE)));
}
}
@Test void rejectsMalformedAndOutOfRangeValuesBeforeQueueingOrChangingEffectiveState() {
try (var settings = new LongbowSettings(data, defaults())) {
for (var entry : Map.of(
LongbowSettings.BASE_DAMAGE, "-1",
LongbowSettings.RECOVERY, "0.999",
LongbowSettings.MINIMUM_DRAW, "0",
LongbowSettings.FULL_DRAW, "2",
LongbowSettings.RANGE, "0",
LongbowSettings.LIFETIME, "0",
LongbowSettings.FULL_SPEED, "Infinity").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.BASE_DAMAGE, "1" + "0".repeat(309)));
assertThrows(IllegalArgumentException.class, () -> settings.set(LongbowSettings.RECOVERY, "1.0000000001"));
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedAtomicReplacementRetainsEffectiveAndFileStateAndAllowsOneExplicitRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "longbow-base-damage: 5\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new LongbowSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated replacement failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
assertTrue(Files.readString(data.resolve("config.yml")).contains("5"));
var retry = settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture()
.get(5, TimeUnit.SECONDS);
assertEquals("9", retry.value());
assertEquals("5", settings.value(LongbowSettings.BASE_DAMAGE));
settings.publish(retry);
assertEquals("9", settings.value(LongbowSettings.BASE_DAMAGE));
}
}
@Test void rejectsUnsafeConfigurationAndLockFilesAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside");
Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new LongbowSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
Files.delete(data.resolve("config.yml")); Files.deleteIfExists(external);
}
var settings = new LongbowSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(LongbowSettings.BASE_DAMAGE, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : LongbowSettings.keys()) result.put(key, LongbowSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,6 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class PartySettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndRejectsStalePublication()throws Exception{assertEquals(6,PartySettings.parse(null));assertEquals(1,PartySettings.parse(1));assertEquals(Integer.MAX_VALUE,PartySettings.parse(Integer.MAX_VALUE));for(Object bad:new Object[]{0,-1,"1",1.5,"2147483648"})assertThrows(IllegalArgumentException.class,()->PartySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nparty-max-size: 6\n");try(var settings=new PartySettings(data,6)){var one=settings.set("2").toCompletableFuture().get();var two=settings.set("3").toCompletableFuture().get();assertEquals(6,settings.maximum());settings.publish(two);settings.publish(one);assertEquals(3,settings.maximum());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new PartySettings(data,6)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("2").toCompletableFuture().get());assertEquals(6,settings.maximum());}}
}
@@ -0,0 +1,82 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class QuickstepSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesOnlyAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "adventure-mode: true\nunrelated: retained\n");
try (var settings = new QuickstepSettings(data, defaults())) {
var distance = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture();
var speed = settings.set(QuickstepSettings.SPEED, "1.5").toCompletableFuture();
var savedDistance = distance.get(5, TimeUnit.SECONDS); var savedSpeed = speed.get(5, TimeUnit.SECONDS);
assertEquals(4, settings.settings().distance(), 0);
settings.publish(savedSpeed); settings.publish(savedDistance);
assertEquals(6, settings.settings().distance(), 0); assertEquals(1.5, settings.settings().speedPerTick(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals(Boolean.TRUE, saved.get("adventure-mode")); assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(QuickstepSettings.DISTANCE)));
assertEquals("1.5", String.valueOf(saved.get(QuickstepSettings.SPEED)));
}
}
@Test void rejectsMalformedOrOutOfRangeValuesBeforeQueueing() {
try (var settings = new QuickstepSettings(data, defaults())) {
for (var entry : Map.of(QuickstepSettings.COOLDOWN, "-1", QuickstepSettings.DISTANCE, "0",
QuickstepSettings.SPEED, "Infinity").entrySet()) {
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
}
assertThrows(IllegalArgumentException.class, () -> settings.set(QuickstepSettings.COOLDOWN, "0.0000000001"));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
assertFalse(Files.exists(data.resolve("config.yml")));
}
}
@Test void failedReplacementAndUnsafeFilesRetainEffectiveStateAndAllowRetry() throws Exception {
Files.writeString(data.resolve("config.yml"), "quickstep-distance-blocks: 4\n");
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new QuickstepSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated failure");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(QuickstepSettings.DISTANCE));
var retry = settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS);
assertEquals("4", settings.value(QuickstepSettings.DISTANCE)); settings.publish(retry);
assertEquals("6", settings.value(QuickstepSettings.DISTANCE));
}
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.delete(data.resolve("config.yml")); Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new QuickstepSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "7").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
}
@Test void closeRejectsNewWritesAndCompletesDrain() throws Exception {
var settings = new QuickstepSettings(data, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(QuickstepSettings.DISTANCE, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : QuickstepSettings.keys()) result.put(key, QuickstepSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,58 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class RevivalStoreTest {
@TempDir Path data;
@Test void oneReservationRoundTripsAcrossReopenAndClearsOnlyWithItsToken() throws Exception {
var value = reservation();
try (var store = new RevivalStore(data)) {
assertEquals(value, store.reserve(value).join().orElseThrow());
assertTrue(store.reserve(new RevivalStore.Reservation(UUID.randomUUID(), value.casterCharacter(),
value.targetPlayer(), value.character(), value.death(), value.createdAt(), value.sourceSlot(),
value.sourceAmount(), value.destinationSlot())).join().isEmpty());
assertEquals(value, store.find(value.character()).join().orElseThrow());
assertEquals(java.util.List.of(value), store.target(value.targetPlayer()).join());
assertFalse(store.clear(value.character(), UUID.randomUUID()).join());
}
try (var reopened = new RevivalStore(data)) {
assertEquals(value, reopened.find(value.character()).join().orElseThrow());
assertTrue(reopened.clear(value.character(), value.id()).join());
assertTrue(reopened.find(value.character()).join().isEmpty());
}
}
@Test void malformedOversizedAndSymlinkedReservationStateIsRetainedAndRejected() throws Exception {
var value = reservation();
try (var store = new RevivalStore(data)) { store.reserve(value).join(); }
Path file = data.resolve("revivals").resolve(value.character() + ".yml");
String valid = Files.readString(file);
Files.writeString(file, valid + "unknown: value\n");
try (var store = new RevivalStore(data)) {
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
}
assertTrue(Files.readString(file).endsWith("unknown: value\n"));
Files.delete(file);
Path outside = data.resolve("outside.yml"); Files.writeString(outside, valid);
try {
Files.createSymbolicLink(file, outside);
try (var store = new RevivalStore(data)) {
assertThrows(CompletionException.class, () -> store.find(value.character()).join());
}
} catch (UnsupportedOperationException ignored) { }
}
private static RevivalStore.Reservation reservation() {
return new RevivalStore.Reservation(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(),
UUID.randomUUID(), Instant.parse("2026-09-20T10:00:00Z"), 10, 2, 12);
}
}
@@ -0,0 +1,6 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class RoleplaySettingsTest{@TempDir Path data;
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedSequence()throws Exception{assertFalse(RoleplaySettings.parse(null));assertFalse(RoleplaySettings.parse(false));assertTrue(RoleplaySettings.parse(true));for(Object bad:new Object[]{"true","false",1,"yes"})assertThrows(IllegalArgumentException.class,()->RoleplaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nroleplay-disabled: false\n");try(var settings=new RoleplaySettings(data,false)){var first=settings.set("true").toCompletableFuture().get();var second=settings.set("false").toCompletableFuture().get();assertFalse(settings.disabled());settings.publish(second);settings.publish(first);assertFalse(settings.disabled());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new RoleplaySettings(data,false)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("true").toCompletableFuture().get());assertFalse(settings.disabled());}}
}
@@ -0,0 +1,70 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SecondWindSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentWritesPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new SecondWindSettings(data, defaults())) {
var healing = settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture();
var cooldown = settings.set(SecondWindSettings.COOLDOWN, "20").toCompletableFuture();
var h = healing.get(5, TimeUnit.SECONDS); var c = cooldown.get(5, TimeUnit.SECONDS);
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING)); settings.publish(c); settings.publish(h);
assertEquals("9", settings.value(SecondWindSettings.BASE_HEALING));
assertEquals(java.time.Duration.ofSeconds(20), settings.settings().cooldown());
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("9", String.valueOf(saved.get(SecondWindSettings.BASE_HEALING)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.BASE_HEALING, "1.5"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "-1"));
assertThrows(IllegalArgumentException.class, () -> settings.set(SecondWindSettings.COOLDOWN, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new SecondWindSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("6", settings.value(SecondWindSettings.BASE_HEALING));
}
}
@Test void rejectsUnsafeFileAndPostCloseWrites() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new SecondWindSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var other = data.resolve("closed"); var settings = new SecondWindSettings(other, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(SecondWindSettings.BASE_HEALING, "9").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String, String> defaults() {
var result = new LinkedHashMap<String, String>();
for (String key : SecondWindSettings.keys()) result.put(key, SecondWindSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SheetResourcePackSettingsTest{@TempDir Path data;static final SheetResourcePackSettings.Defaults DEFAULTS=new SheetResourcePackSettings.Defaults("https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v1.2.3/purpur-fantasy-stick-1.2.3-resource-pack.zip","0123456789abcdef0123456789abcdef01234567");
@Test void validatesDefaultsSecureOverridesAndHash(){assertEquals(DEFAULTS.url(),SheetResourcePackSettings.effectiveUrl("",DEFAULTS));assertEquals(DEFAULTS.sha1(),SheetResourcePackSettings.effectiveSha1("",DEFAULTS));assertEquals("https://packs.example.test/sheet.zip",SheetResourcePackSettings.effectiveUrl("https://packs.example.test/sheet.zip",DEFAULTS));assertEquals("abcdefabcdefabcdefabcdefabcdefabcdefabcd",SheetResourcePackSettings.effectiveSha1("ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD",DEFAULTS));for(String bad:java.util.List.of("http://packs.example.test/x.zip","relative.zip","https://user@packs.example.test/x.zip","https://packs.example.test/x.zip#fragment","https://packs.example.test/a b"))assertThrows(IllegalArgumentException.class,()->SheetResourcePackSettings.effectiveUrl(bad,DEFAULTS));for(String bad:java.util.List.of("x","0".repeat(39),"g".repeat(40)))assertThrows(IllegalArgumentException.class,()->SheetResourcePackSettings.effectiveSha1(bad,DEFAULTS));}
@Test void serializesConcurrentUpdatesPreservesUnrelatedAndPublishesNewestSnapshot()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\nsheet-resource-pack-url: ''\nsheet-resource-pack-sha1: ''\n");try(var settings=new SheetResourcePackSettings(data,"","",DEFAULTS)){var url=settings.set(SheetResourcePackSettings.URL_KEY,"https://packs.example.test/sheet.zip").toCompletableFuture();var hash=settings.set(SheetResourcePackSettings.SHA1_KEY,"a".repeat(40)).toCompletableFuture();var first=url.get();var second=hash.get();settings.publish(second);settings.publish(first);assertEquals("https://packs.example.test/sheet.zip",settings.url());assertEquals("a".repeat(40),settings.sha1());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
@Test void failedWriteRetainsEffectivePair()throws Exception{try(var settings=new SheetResourcePackSettings(data,"","",DEFAULTS)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set(SheetResourcePackSettings.URL_KEY,"https://packs.example.test/x.zip").toCompletableFuture().get());assertEquals(DEFAULTS.url(),settings.url());assertEquals(DEFAULTS.sha1(),settings.sha1());}}
}
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ShieldBashSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new ShieldBashSettings(data, defaults())) {
var range = settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture();
var horizontal = settings.set(ShieldBashSettings.HORIZONTAL, "1.2").toCompletableFuture();
var r = range.get(5, TimeUnit.SECONDS); var h = horizontal.get(5, TimeUnit.SECONDS);
assertEquals(3, settings.settings().range(), 0); settings.publish(h); settings.publish(r);
assertEquals(5, settings.settings().range(), 0); assertEquals(1.2, settings.settings().horizontalKnockback(), 0);
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated")); assertEquals("5", String.valueOf(saved.get(ShieldBashSettings.RANGE)));
assertEquals("1.2", String.valueOf(saved.get(ShieldBashSettings.HORIZONTAL)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new ShieldBashSettings(data, defaults())) {
for (var entry : Map.of(ShieldBashSettings.BASE_DAMAGE, "-1", ShieldBashSettings.COOLDOWN, "-1",
ShieldBashSettings.RANGE, "0", ShieldBashSettings.HORIZONTAL, "9",
ShieldBashSettings.VERTICAL, "Infinity").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.COOLDOWN, "0.0000000001"));
assertThrows(IllegalArgumentException.class, () -> settings.set(ShieldBashSettings.BASE_DAMAGE, "1.5"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new ShieldBashSettings(data, defaults(), (from,to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated"); Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(ShieldBashSettings.RANGE, "5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("3", settings.value(ShieldBashSettings.RANGE));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name","").toLowerCase().contains("win")) {
var external=data.resolveSibling(data.getFileName()+"-outside"); Files.writeString(external,"safe\n");
Files.createSymbolicLink(data.resolve("config.yml"),external);
try(var settings=new ShieldBashSettings(data,defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
assertEquals("safe\n",Files.readString(external));
}
}
var settings=new ShieldBashSettings(data.resolve("closed"),defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class,()->settings.set(ShieldBashSettings.RANGE,"5").toCompletableFuture().get(5,TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5,TimeUnit.SECONDS);
}
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(String key:ShieldBashSettings.keys())result.put(key,ShieldBashSettings.defaultValue(key));return result;}
}
@@ -0,0 +1,69 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StoneSkinSettingsTest {
@TempDir Path data;
@Test void serializesConcurrentKeysPreservesUnrelatedAndPublishesAcknowledgedSnapshots() throws Exception {
Files.writeString(data.resolve("config.yml"), "unrelated: retained\n");
try (var settings = new StoneSkinSettings(data, defaults())) {
var duration = settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture();
var reduction = settings.set(StoneSkinSettings.REDUCTION, "40").toCompletableFuture();
var d = duration.get(5, TimeUnit.SECONDS); var r = reduction.get(5, TimeUnit.SECONDS);
assertEquals(java.time.Duration.ofSeconds(4), settings.settings().duration());
settings.publish(r); settings.publish(d);
assertEquals(java.time.Duration.ofSeconds(6), settings.settings().duration());
assertEquals(0, new java.math.BigDecimal("40").compareTo(settings.settings().reductionPercent()));
var saved = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
assertEquals("retained", saved.get("unrelated"));
assertEquals("6", String.valueOf(saved.get(StoneSkinSettings.DURATION)));
assertEquals("40", String.valueOf(saved.get(StoneSkinSettings.REDUCTION)));
}
}
@Test void rejectsInvalidValuesAndFailedReplacementRetainsState() throws Exception {
try (var settings = new StoneSkinSettings(data, defaults())) {
for (var entry : Map.of(StoneSkinSettings.COOLDOWN, "-1", StoneSkinSettings.DURATION, "0",
StoneSkinSettings.REDUCTION, "101").entrySet())
assertThrows(IllegalArgumentException.class, () -> settings.set(entry.getKey(), entry.getValue()));
assertThrows(IllegalArgumentException.class, () -> settings.set(StoneSkinSettings.DURATION, "0.0000000001"));
}
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
try (var settings = new StoneSkinSettings(data, defaults(), (from, to) -> {
if (fail.getAndSet(false)) throw new java.io.IOException("simulated");
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
})) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("4", settings.value(StoneSkinSettings.DURATION));
}
}
@Test void unsafeFileAndClosedStoreFailSafely() throws Exception {
if (!System.getProperty("os.name", "").toLowerCase().contains("win")) {
var external = data.resolveSibling(data.getFileName() + "-outside"); Files.writeString(external, "safe\n");
Files.createSymbolicLink(data.resolve("config.yml"), external);
try (var settings = new StoneSkinSettings(data, defaults())) {
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
assertEquals("safe\n", Files.readString(external));
}
}
var directory = data.resolve("closed"); var settings = new StoneSkinSettings(directory, defaults()); settings.close();
assertThrows(java.util.concurrent.ExecutionException.class, () ->
settings.set(StoneSkinSettings.DURATION, "6").toCompletableFuture().get(5, TimeUnit.SECONDS));
settings.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static Map<String,String> defaults() {
var result = new LinkedHashMap<String,String>();
for (String key : StoneSkinSettings.keys()) result.put(key, StoneSkinSettings.defaultValue(key));
return result;
}
}
@@ -0,0 +1,7 @@
package games.dmg.fantasystick.persistence;
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
class SwitchDelaySettingsTest{@TempDir Path data;
@Test void validatesZeroPersistsPreservesUnrelatedAndPublishesAfterAcknowledgment()throws Exception{assertEquals(Duration.ofSeconds(900),SwitchDelaySettings.parse(null));assertEquals(Duration.ZERO,SwitchDelaySettings.parse("0"));for(Object bad:new Object[]{-1,"1.5","bad","9223372036854775807","253402300800"})assertThrows(IllegalArgumentException.class,()->SwitchDelaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\ncharacter-swap-cooldown-seconds: 900\n");try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var update=settings.set("37").toCompletableFuture().get();assertEquals("900",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("37",settings.value());assertEquals(Duration.ofSeconds(37),settings.delay());}}
@Test void concurrentWritesSerializeAndStalePublicationCannotRollBackNewerPolicy()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var one=settings.set("1").toCompletableFuture();var two=settings.set("2").toCompletableFuture();var first=one.get();var second=two.get();settings.publish(second);settings.publish(first);assertEquals("2",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("character-swap-cooldown-seconds: '2'"));}}
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("0").toCompletableFuture().get());assertEquals("900",settings.value());}}
}
@@ -19,6 +19,23 @@ class CombatContributionsTest {
assertEquals(Set.of(), ledger.consume(target, start.plusSeconds(301)), "One target is awarded once");
}
@Test void targetSpecificSupportRequiresDirectAllyAndTenSecondWindowWithoutRecursion() {
var now = Instant.parse("2026-09-19T20:00:00Z");
var target = UUID.randomUUID(); var otherTarget = UUID.randomUUID();
var ally = UUID.randomUUID(); var supporter = UUID.randomUUID(); var supportOnly = UUID.randomUUID();
var ledger = new CombatContributions(CombatRewardRules.defaults());
ledger.accept(target, ally, now);
ledger.support(target, ally, supporter, now);
ledger.support(target, supporter, supportOnly, now);
ledger.support(otherTarget, ally, supportOnly, now);
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)));
ledger.accept(target, ally, now); ledger.accept(target, supporter, now);
ledger.support(target, ally, supporter, now);
assertEquals(Set.of(ally, supporter), ledger.consume(target, now.plusSeconds(10)), "Direct and support roles deduplicate");
ledger.accept(target, ally, now); ledger.support(target, ally, supporter, now);
assertEquals(Set.of(ally), ledger.consume(target, now.plusSeconds(10).plusNanos(1)));
}
@Test void invalidationAndCloseBoundMemoryAndRefuseLaterDamage() {
var ledger = new CombatContributions(CombatRewardRules.defaults());
var target = UUID.randomUUID(); var character = UUID.randomUUID();