Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f2f2481b | ||
|
|
6ec97569d3 | ||
|
|
fd5961ccd8 | ||
|
|
c2d84458c7 | ||
|
|
f2a6043d08 | ||
|
|
793245e890 | ||
|
|
6092567778 | ||
|
|
ff55fbace7 | ||
|
|
a637e4090c | ||
|
|
0f3365c6ee | ||
|
|
af354a3097 | ||
|
|
e111d3919e | ||
|
|
35418dbef5 | ||
|
|
3a2c4b8451 | ||
|
|
60a29221f7 | ||
|
|
23179c62ae | ||
|
|
f0dc304f60 | ||
|
|
3444c786c3 | ||
|
|
3ba3ef4a0b | ||
|
|
bc1a0b1f29 | ||
|
|
fa219d9313 | ||
|
|
d98a2a80a3 | ||
|
|
7a85eea769 | ||
|
|
6d0d695052 | ||
|
|
710a29a43c | ||
|
|
d3f58f4223 | ||
|
|
87024bfd24 | ||
|
|
d8bbe78a08 | ||
|
|
37be6b13fa | ||
|
|
1dcc7cfae8 | ||
|
|
e16570cb35 | ||
|
|
be8870502f | ||
|
|
8b5461753c | ||
|
|
91412fa1ae | ||
|
|
61bc67da3b | ||
|
|
1cc1012ecd | ||
|
|
396baf473f | ||
|
|
1468db0533 | ||
|
|
f9ab5b6e46 | ||
|
|
0042f539a5 | ||
|
|
9d6b945f82 | ||
|
|
5c5f774dea | ||
|
|
0536d4c717 | ||
|
|
079555900c | ||
|
|
1f3a1fe396 | ||
|
|
139cc4519b | ||
|
|
184d5520b2 | ||
|
|
16087bdfec | ||
|
|
bc8fe44033 | ||
|
|
4517aa3a64 | ||
|
|
89a57167cc | ||
|
|
9f5fede5e5 | ||
|
|
18b174609d | ||
|
|
dfa02bc335 | ||
|
|
4c2340d02b | ||
|
|
d890fb8915 | ||
|
|
683c4ed312 | ||
|
|
c80c088945 | ||
|
|
cc6c1a7f44 | ||
|
|
4abaeecf63 | ||
|
|
5a22dc343a | ||
|
|
db1c7b5906 | ||
|
|
9ab5bffd2c | ||
|
|
a58c192358 | ||
|
|
0fcb1e7e9c | ||
|
|
5d574dc6e9 | ||
|
|
a71262f115 |
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -136,6 +136,7 @@ Optional `character-swap-cooldown-seconds` defaults to `900`; zero bypasses the
|
||||
|
||||
Administration (permission `fantasystick.admin`, default operators):
|
||||
|
||||
- `/fantasystick character view <online-player>` — opens the exact online account's current active character as a complete read-only virtual book for an in-game administrator.
|
||||
- `/fantasystick inspect <player-uuid>` — asynchronous receipt/native-session inspection.
|
||||
- `/fantasystick recover <player-uuid>` — validated online equipment/game-mode restoration only; no forced overwrite, progression grant or ambiguous replay.
|
||||
- `/fantasystick config adventure-mode [true|false]` — show or persist the policy for new sessions; no change to active sessions.
|
||||
@@ -166,7 +167,7 @@ A same-process retry can acknowledge gear already verified restored for that exa
|
||||
|
||||
## Native fantasy health, exit and defeat
|
||||
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and shows public blue particles. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fantasyparticles players on`. The account-owned aura preference defaults to off; `/fantasyparticles players` reports it and `/fantasyparticles players off` hides only this recurring aura. Defeat, progression and ability particles remain unchanged. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
|
||||
|
||||
`NativeMaximumHealth` owns only the effective-value cache/dirty callback of that player's existing native maximum-health instance. Base values, modifiers and native potion expiry remain owned by their original sources; nothing is copied back from an old modifier snapshot. The native player health methods and client attribute snapshot are tested. This adapter uses **pinned Purpur 2618 internals**, including private fields; a detached startup probe exercises installation, recalculation and removal before gameplay is registered. Runtime upgrades require renewed native compatibility verification, not a guessed fallback or global attribute-limit changes. Matching native health scaling displays the real fantasy maximum, not a separate virtual health pool; a later unrelated display-scale change is preserved on exit.
|
||||
|
||||
|
||||
+39
-5
@@ -1,5 +1,6 @@
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import org.gradle.api.tasks.bundling.Zip
|
||||
|
||||
plugins {
|
||||
java
|
||||
@@ -7,6 +8,7 @@ plugins {
|
||||
|
||||
group = "games.dmg"
|
||||
version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get()
|
||||
val pluginVersion = version.toString()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
@@ -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)
|
||||
|
||||
@@ -9,8 +9,9 @@ try {
|
||||
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|
||||
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input");
|
||||
const jar = await readFile(`build/libs/purpur-fantasy-stick-${version}.jar`);
|
||||
const resourcePack = await readFile(`build/distributions/purpur-fantasy-stick-${version}-resource-pack.zip`);
|
||||
await publishRelease({
|
||||
credential, version, commit, jar,
|
||||
credential, version, commit, jar, resourcePack,
|
||||
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
|
||||
}, fetch);
|
||||
console.log(`Published purpur-fantasy-stick v${version}.`);
|
||||
|
||||
@@ -8,12 +8,15 @@ const input: ReleaseInput = {
|
||||
commit: "a".repeat(40),
|
||||
notes: "Verified changes",
|
||||
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
|
||||
resourcePack: new Uint8Array([0x50, 0x4b, 0x03, 0x05]),
|
||||
};
|
||||
|
||||
function success(init: RequestInit): Response {
|
||||
function success(init: RequestInit, url = ""): Response {
|
||||
const pack = url.includes("resource-pack.zip");
|
||||
return Response.json({
|
||||
id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3",
|
||||
name: "purpur-fantasy-stick-1.2.3.jar", size: input.jar.length,
|
||||
name: pack ? "purpur-fantasy-stick-1.2.3-resource-pack.zip" : "purpur-fantasy-stick-1.2.3.jar",
|
||||
size: pack ? input.resourcePack.length : input.jar.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +24,7 @@ test("creates a draft on the fixed repository and exact source commit, without f
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const id = await publishRelease(input, async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return success(init);
|
||||
return success(init, url);
|
||||
});
|
||||
assert.ok(calls.length >= 1);
|
||||
const first = calls[0]!;
|
||||
@@ -40,17 +43,21 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
await publishRelease(input, async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return success(init);
|
||||
return success(init, url);
|
||||
});
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.equal(calls[1]!.url,
|
||||
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3.jar");
|
||||
assert.equal(calls[1]!.init.method, "POST");
|
||||
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
|
||||
assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream");
|
||||
assert.equal(calls[2]!.init.method, "PATCH");
|
||||
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
|
||||
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
|
||||
assert.equal(calls[2]!.url,
|
||||
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3-resource-pack.zip");
|
||||
assert.equal(calls[2]!.init.method, "POST");
|
||||
assert.deepEqual(new Uint8Array(await new Response(calls[2]!.init.body).arrayBuffer()), input.resourcePack);
|
||||
assert.equal(calls[3]!.init.method, "PATCH");
|
||||
assert.equal(calls[3]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
|
||||
assert.deepEqual(JSON.parse(String(calls[3]!.init.body)), { draft: false });
|
||||
for (const call of calls) {
|
||||
assert.equal(call.init.redirect, "error");
|
||||
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
|
||||
@@ -58,14 +65,14 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
|
||||
}
|
||||
});
|
||||
|
||||
for (const failureStep of [1, 2, 3]) {
|
||||
for (const failureStep of [1, 2, 3, 4]) {
|
||||
for (const status of [302, 401, 409, 500]) {
|
||||
test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(publishRelease(input, async (_url, init) => {
|
||||
calls++;
|
||||
if (calls === failureStep) return new Response(input.credential, { status });
|
||||
return success(init);
|
||||
return success(init, _url);
|
||||
}), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error.message.includes(String(status)));
|
||||
@@ -85,7 +92,7 @@ test("sanitizes transport errors and does not follow a redirect with the credent
|
||||
|
||||
for (const invalid of [
|
||||
{ credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" },
|
||||
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() },
|
||||
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() }, { resourcePack: new Uint8Array() },
|
||||
]) {
|
||||
test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => {
|
||||
let calls = 0;
|
||||
@@ -112,16 +119,18 @@ for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringi
|
||||
for (const [failureStep, body] of [
|
||||
[2, {}], [2, { name: "other.jar", size: 4 }],
|
||||
[2, { name: "purpur-fantasy-stick-1.2.3.jar", size: 0 }],
|
||||
[3, { id: 42, draft: true, tag_name: "v1.2.3" }],
|
||||
[3, { id: 77, draft: false, tag_name: "v1.2.3" }],
|
||||
[3, { id: 42, draft: false, tag_name: "v8.8.8" }],
|
||||
[3, {}], [3, { name: "other.zip", size: 4 }],
|
||||
[3, { name: "purpur-fantasy-stick-1.2.3-resource-pack.zip", size: 0 }],
|
||||
[4, { id: 42, draft: true, tag_name: "v1.2.3" }],
|
||||
[4, { id: 77, draft: false, tag_name: "v1.2.3" }],
|
||||
[4, { id: 42, draft: false, tag_name: "v8.8.8" }],
|
||||
] as const) {
|
||||
test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(publishRelease(input, async (_url, init) => {
|
||||
calls++;
|
||||
return calls === failureStep ? Response.json(body) : success(init);
|
||||
}), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." });
|
||||
return calls === failureStep ? Response.json(body) : success(init, _url);
|
||||
}), { message: failureStep === 2 || failureStep === 3 ? "Invalid release asset response." : "Invalid published release response." });
|
||||
assert.equal(calls, failureStep);
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface ReleaseInput {
|
||||
commit: string;
|
||||
notes: string;
|
||||
jar: Uint8Array;
|
||||
resourcePack: Uint8Array;
|
||||
}
|
||||
|
||||
export type Request = (url: string, init: RequestInit) => Promise<Response>;
|
||||
@@ -13,7 +14,7 @@ const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/r
|
||||
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
|
||||
if (!/^[\x21-\x7e]+$/.test(input.credential)
|
||||
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|
||||
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) {
|
||||
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0 || input.resourcePack.length === 0) {
|
||||
throw new Error("Invalid release input.");
|
||||
}
|
||||
const send: Request = async (url, init) => {
|
||||
@@ -49,6 +50,16 @@ export async function publishRelease(input: ReleaseInput, request: Request): Pro
|
||||
if (asset.name !== `purpur-fantasy-stick-${input.version}.jar` || asset.size !== input.jar.length) {
|
||||
throw new Error("Invalid release asset response.");
|
||||
}
|
||||
const packName = `purpur-fantasy-stick-${input.version}-resource-pack.zip`;
|
||||
const packResponse = await send(`${releases}/${release.id}/assets?name=${packName}`, {
|
||||
method: "POST", redirect: "error",
|
||||
headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" },
|
||||
body: new Uint8Array(input.resourcePack).buffer,
|
||||
});
|
||||
const pack = await readObject(packResponse, "Invalid release asset response.");
|
||||
if (pack.name !== packName || pack.size !== input.resourcePack.length) {
|
||||
throw new Error("Invalid release asset response.");
|
||||
}
|
||||
const publishedResponse = await send(`${releases}/${release.id}`, {
|
||||
method: "PATCH", redirect: "error",
|
||||
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
|
||||
|
||||
@@ -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,15 +29,34 @@ 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;
|
||||
private EquipmentSessionEvents sessionEvents;
|
||||
private games.dmg.fantasystick.book.CharacterBookController books;
|
||||
private games.dmg.fantasystick.party.PartyRoster parties;
|
||||
private games.dmg.fantasystick.party.CharacterPartyDirectory characterParties;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarDirectory partyRegistrars;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarController partyRegistrarController;
|
||||
private games.dmg.fantasystick.training.TrainingDummyDirectory trainingDummies;
|
||||
private games.dmg.fantasystick.training.TrainingDummyController trainingDummyController;
|
||||
private games.dmg.fantasystick.party.PartyManagement partyManagement;
|
||||
private games.dmg.fantasystick.party.PartyRegistrarMenus partyRegistrarMenus;
|
||||
private games.dmg.fantasystick.party.PartyEvents partyEvents;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerDirectory armorers;
|
||||
private games.dmg.fantasystick.shop.FantasyArmorerController armorerController;
|
||||
private games.dmg.fantasystick.shop.ArmorMarket armorMarket;
|
||||
private games.dmg.fantasystick.shop.ArmorShop armorShop;
|
||||
private games.dmg.fantasystick.shop.ArmorShopMenus armorShopMenus;
|
||||
private org.bukkit.scheduler.BukkitTask armorMarketPulse;
|
||||
private org.bukkit.scheduler.BukkitTask armorMenuPulse;
|
||||
private games.dmg.fantasystick.party.PartyCombat partyCombat;
|
||||
private EquipmentAdminCommand admin;
|
||||
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
|
||||
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
|
||||
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
|
||||
private games.dmg.fantasystick.presentation.CombatFeedback combatFeedback;
|
||||
private volatile Object lifetime;
|
||||
private CharacterPresentation presentation;
|
||||
private ProtocolPresentation protocolPresentation;
|
||||
@@ -49,6 +70,7 @@ 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.persistence.FireballSettings fireballSettings;
|
||||
private games.dmg.fantasystick.ability.FrostShard frostShard;
|
||||
private games.dmg.fantasystick.persistence.FrostShardSettings frostShardSettings;
|
||||
private games.dmg.fantasystick.ability.Longbow longbow;
|
||||
@@ -65,6 +87,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.TemporaryHealth temporaryHealth;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.MendingLight mendingLight;
|
||||
private games.dmg.fantasystick.persistence.MendingLightSettings mendingLightSettings;
|
||||
private games.dmg.fantasystick.ability.Purify purify;
|
||||
private games.dmg.fantasystick.persistence.PurifySettings purifySettings;
|
||||
private games.dmg.fantasystick.ability.GuardianBond guardianBond;
|
||||
private games.dmg.fantasystick.persistence.GuardianBondSettings guardianBondSettings;
|
||||
private games.dmg.fantasystick.ability.OutgoingDamageBonus outgoingDamageBonus;
|
||||
private games.dmg.fantasystick.ability.RallyingCry rallyingCry;
|
||||
private games.dmg.fantasystick.persistence.RallyingCrySettings rallyingCrySettings;
|
||||
private games.dmg.fantasystick.ability.FrostNova frostNova;
|
||||
private games.dmg.fantasystick.persistence.FrostNovaSettings frostNovaSettings;
|
||||
private games.dmg.fantasystick.ability.ChainLightning chainLightning;
|
||||
private games.dmg.fantasystick.persistence.ChainLightningSettings chainLightningSettings;
|
||||
private games.dmg.fantasystick.ability.Sanctuary sanctuary;
|
||||
private games.dmg.fantasystick.persistence.SanctuarySettings sanctuarySettings;
|
||||
private games.dmg.fantasystick.ability.WarBanner warBanner;
|
||||
private games.dmg.fantasystick.persistence.WarBannerSettings warBannerSettings;
|
||||
private games.dmg.fantasystick.ability.Meteor meteor;
|
||||
private games.dmg.fantasystick.persistence.MeteorSettings meteorSettings;
|
||||
private games.dmg.fantasystick.persistence.HexSettings hexSettings;
|
||||
private games.dmg.fantasystick.persistence.ShieldBashSettings shieldBashSettings;
|
||||
private games.dmg.fantasystick.persistence.StoneSkinSettings stoneSkinSettings;
|
||||
@@ -75,7 +116,33 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.PartyEconomySettings partyEconomySettings;
|
||||
private games.dmg.fantasystick.persistence.GearSettings gearSettings;
|
||||
private games.dmg.fantasystick.persistence.CoinSettings coinSettings;
|
||||
private games.dmg.fantasystick.persistence.HardMobXpSettings hardMobXpSettings;
|
||||
private games.dmg.fantasystick.persistence.AdventureQuestSettings adventureQuestSettings;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestDirectory adventureQuests;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestController adventureQuestController;
|
||||
private games.dmg.fantasystick.adventure.AdventureQuestCommands adventureQuestCommands;
|
||||
private games.dmg.fantasystick.dungeon.DungeonDirectory dungeons;
|
||||
private games.dmg.fantasystick.dungeon.DungeonController dungeonController;
|
||||
private games.dmg.fantasystick.dungeon.DungeonCommands dungeonCommands;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
|
||||
private games.dmg.fantasystick.session.OutOfCombatRecovery outOfCombatRecovery;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
|
||||
private games.dmg.fantasystick.persistence.QuickerRegenerationIISettings quickerRegenerationIISettings;
|
||||
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
|
||||
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
|
||||
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
|
||||
private games.dmg.fantasystick.passive.SpeedI speedI;
|
||||
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -102,9 +169,45 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 partyEconomyConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PartyEconomySettings.keys()) partyEconomyConfiguration.put(key, getConfig().getString(key,games.dmg.fantasystick.persistence.PartyEconomySettings.defaultValue(key)));
|
||||
var gearConfiguration=new java.util.LinkedHashMap<String,String>();for(String key:games.dmg.fantasystick.persistence.GearSettings.keys())gearConfiguration.put(key,getConfig().getString(key,games.dmg.fantasystick.persistence.GearSettings.defaultValue(key)));
|
||||
var coinConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.CoinSettings.keys()) {
|
||||
coinConfiguration.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.CoinSettings.defaultValue(key)));
|
||||
}
|
||||
var adventureQuestConfiguration = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.AdventureQuestSettings.keys()) {
|
||||
adventureQuestConfiguration.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.AdventureQuestSettings.parse(adventureQuestConfiguration);
|
||||
var recoveryConfiguration = outOfCombatRecoveryConfiguration();
|
||||
var recoveryInitial = games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(recoveryConfiguration);
|
||||
var quickerRegenerationInitial = games.dmg.fantasystick.persistence.QuickerRegenerationSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationSettings.DEFAULT));
|
||||
var quickerRegenerationIIInitial = games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.KEY,
|
||||
games.dmg.fantasystick.persistence.QuickerRegenerationIISettings.DEFAULT));
|
||||
var healthStealInitial = games.dmg.fantasystick.persistence.HealthStealSettings.parse(
|
||||
getConfig().getString(games.dmg.fantasystick.persistence.HealthStealSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HealthStealSettings.DEFAULT));
|
||||
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 hardMobXpConfiguration = getConfig().getString(
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.KEY,
|
||||
games.dmg.fantasystick.persistence.HardMobXpSettings.DEFAULT);
|
||||
var hardMobXp = games.dmg.fantasystick.persistence.HardMobXpSettings.parse(hardMobXpConfiguration);
|
||||
var rewardRules = combatRewardRules(hardMobXp);
|
||||
var fireballConfiguration = fireballConfiguration();
|
||||
var fireballInitial = games.dmg.fantasystick.persistence.FireballSettings.parse(fireballConfiguration);
|
||||
var greatSwordSettings = greatSwordSettings();
|
||||
var frostShardConfiguration = frostShardConfiguration();
|
||||
var frostShardInitial = games.dmg.fantasystick.persistence.FrostShardSettings.parse(frostShardConfiguration);
|
||||
@@ -122,16 +225,58 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var hexInitial = games.dmg.fantasystick.persistence.HexSettings.parse(hexConfiguration);
|
||||
var inspireConfiguration = inspireConfiguration();
|
||||
var inspireInitial = games.dmg.fantasystick.persistence.InspireSettings.parse(inspireConfiguration);
|
||||
var mendingLightConfiguration = mendingLightConfiguration();
|
||||
var mendingLightInitial = games.dmg.fantasystick.persistence.MendingLightSettings.parse(mendingLightConfiguration);
|
||||
var purifyConfiguration = purifyConfiguration();
|
||||
var purifyInitial = games.dmg.fantasystick.persistence.PurifySettings.parse(purifyConfiguration);
|
||||
var guardianBondConfiguration = guardianBondConfiguration();
|
||||
var guardianBondInitial = games.dmg.fantasystick.persistence.GuardianBondSettings.parse(guardianBondConfiguration);
|
||||
var rallyingCryConfiguration = rallyingCryConfiguration();
|
||||
var rallyingCryInitial = games.dmg.fantasystick.persistence.RallyingCrySettings.parse(rallyingCryConfiguration);
|
||||
var frostNovaConfiguration = frostNovaConfiguration();
|
||||
var frostNovaInitial = games.dmg.fantasystick.persistence.FrostNovaSettings.parse(frostNovaConfiguration);
|
||||
var chainLightningConfiguration = chainLightningConfiguration();
|
||||
var chainLightningInitial = games.dmg.fantasystick.persistence.ChainLightningSettings.parse(chainLightningConfiguration);
|
||||
var sanctuaryConfiguration = sanctuaryConfiguration();
|
||||
var sanctuaryInitial = games.dmg.fantasystick.persistence.SanctuarySettings.parse(sanctuaryConfiguration);
|
||||
var warBannerConfiguration = warBannerConfiguration();
|
||||
var warBannerInitial = games.dmg.fantasystick.persistence.WarBannerSettings.parse(warBannerConfiguration);
|
||||
var meteorConfiguration = meteorConfiguration();
|
||||
var meteorInitial = games.dmg.fantasystick.persistence.MeteorSettings.parse(meteorConfiguration);
|
||||
var adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
partyEconomySettings = new games.dmg.fantasystick.persistence.PartyEconomySettings(
|
||||
getDataFolder().toPath(), partyEconomyConfiguration);
|
||||
gearSettings=new games.dmg.fantasystick.persistence.GearSettings(getDataFolder().toPath(),gearConfiguration);
|
||||
coinSettings = new games.dmg.fantasystick.persistence.CoinSettings(getDataFolder().toPath(), coinConfiguration);
|
||||
hardMobXpSettings = new games.dmg.fantasystick.persistence.HardMobXpSettings(
|
||||
getDataFolder().toPath(), hardMobXp.toString());
|
||||
adventureQuestSettings = new games.dmg.fantasystick.persistence.AdventureQuestSettings(
|
||||
getDataFolder().toPath(), adventureQuestConfiguration);
|
||||
var factory = new CharacterFactory(coinSettings::rules);
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC(),
|
||||
coinSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
var armorRandom=java.util.random.RandomGenerator.getDefault();
|
||||
armorMarket=new games.dmg.fantasystick.shop.ArmorMarket(
|
||||
new games.dmg.fantasystick.persistence.YamlArmorCatalogRepository(getDataFolder().toPath()),
|
||||
new games.dmg.fantasystick.persistence.ArmorPurchaseOperationStore(getDataFolder().toPath()),
|
||||
characters,gearSettings::rules,Clock.systemUTC(),
|
||||
(minimum,maximum)->(int)armorRandom.nextLong(minimum,(long)maximum+1));
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
task -> getServer().getScheduler().runTask(this, task),
|
||||
player -> new PlayerSheetDrops(player, event -> getServer().getPluginManager().callEvent(event)),
|
||||
problem -> getLogger().log(Level.WARNING, "Character craft " + problem.operation() + " for player "
|
||||
+ problem.player() + "; character records " + problem.characters(), problem.cause()));
|
||||
java.util.function.Consumer<Runnable> nextTick = task -> getServer().getScheduler().runTask(this, task);
|
||||
try {
|
||||
feedbackPreferences = new games.dmg.fantasystick.persistence.PlayerFeedbackPreferences(getDataFolder().toPath());
|
||||
} catch (java.io.IOException invalidPreferences) {
|
||||
throw new java.io.UncheckedIOException("Player feedback preferences could not be loaded", invalidPreferences);
|
||||
}
|
||||
feedbackCommand = new games.dmg.fantasystick.session.FantasyFeedbackCommand(feedbackPreferences, nextTick,
|
||||
failure -> getLogger().log(Level.WARNING, "Player feedback preference failed", failure));
|
||||
particlesCommand = new games.dmg.fantasystick.session.FantasyParticlesCommand(feedbackPreferences, nextTick,
|
||||
failure -> getLogger().log(Level.WARNING, "Player particle preference failed", failure));
|
||||
java.util.function.Consumer<Throwable> presentationFailures = problem ->
|
||||
getLogger().log(Level.WARNING, "Character presentation failed", problem);
|
||||
// This supplier belongs to one enable, never the mutable plugin field after re-enable.
|
||||
@@ -162,21 +307,64 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
try {
|
||||
characterParties = new games.dmg.fantasystick.party.CharacterPartyDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRepository(getDataFolder().toPath()), partySettings::maximum);
|
||||
partyRegistrars = new games.dmg.fantasystick.party.PartyRegistrarDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository(getDataFolder().toPath()));
|
||||
trainingDummies = new games.dmg.fantasystick.training.TrainingDummyDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlTrainingDummyRepository(getDataFolder().toPath()));
|
||||
adventureQuests = new games.dmg.fantasystick.adventure.AdventureQuestDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlAdventureQuestRepository(getDataFolder().toPath()),
|
||||
Clock.systemUTC(), adventureQuestSettings::rules, java.util.random.RandomGenerator.getDefault());
|
||||
dungeons = new games.dmg.fantasystick.dungeon.DungeonDirectory(
|
||||
new games.dmg.fantasystick.persistence.YamlDungeonRepository(getDataFolder().toPath()),
|
||||
() -> adventureQuestSettings.rules().dungeonRoomLinkDistance());
|
||||
armorers=new games.dmg.fantasystick.shop.FantasyArmorerDirectory(new games.dmg.fantasystick.persistence.YamlFantasyArmorerRepository(getDataFolder().toPath()));
|
||||
} catch (java.io.IOException invalidManagedState) {
|
||||
throw new java.io.UncheckedIOException("Persistent party, shop, training or adventure state could not be loaded", invalidManagedState);
|
||||
}
|
||||
characterParties.suspend();
|
||||
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
|
||||
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
sessions.attachSwitchDelay(switchDelaySettings::delay);
|
||||
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
combatFeedback = new games.dmg.fantasystick.presentation.CombatFeedback(feedbackPreferences, sessions);
|
||||
trainingDummyController = new games.dmg.fantasystick.training.TrainingDummyController(this, trainingDummies);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(characterParties, sessions, partySettings::maximum);
|
||||
characterParties.attachEnded(former -> nextTick.accept(() -> {
|
||||
String message = "Party ended: " + former.displayName();
|
||||
for (java.util.UUID member : former.members()) {
|
||||
var activeMember = sessions.activeCharacter(member).filter(sessions::current).orElse(null);
|
||||
org.bukkit.entity.Player online = activeMember == null ? null : getServer().getPlayer(activeMember.player());
|
||||
if (online != null && online.isOnline()) online.showTitle(net.kyori.adventure.title.Title.title(
|
||||
net.kyori.adventure.text.Component.text(message), net.kyori.adventure.text.Component.empty()));
|
||||
}
|
||||
}));
|
||||
sessions.attachPartyDefeat(characterParties::defeat);
|
||||
var reconcilingParties = characterParties;
|
||||
var partyCharacters = reconcilingParties.all().stream().flatMap(party -> java.util.stream.Stream.concat(
|
||||
party.members().stream(), party.invitations().stream().map(games.dmg.fantasystick.party.CharacterParty.Invitation::character))).distinct().toList();
|
||||
java.util.concurrent.CompletableFuture.allOf(partyCharacters.stream().map(character -> characters.find(character)
|
||||
.thenCompose(record -> record.isEmpty() || record.orElseThrow().dead()
|
||||
? reconcilingParties.defeat(character) : java.util.concurrent.CompletableFuture.completedFuture(
|
||||
games.dmg.fantasystick.party.CharacterPartyDirectory.Defeat.UNCHANGED))).toArray(java.util.concurrent.CompletableFuture[]::new))
|
||||
.whenComplete((ignored, failure) -> { if (failure == null) reconcilingParties.resume();
|
||||
else getLogger().log(Level.SEVERE, "Dead-character party reconciliation failed; party authority remains suspended", failure); });
|
||||
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),
|
||||
sessions, parties, coinSettings::rules, java.util.random.RandomGenerator.getDefault(),
|
||||
Clock.systemUTC(), nextTick, id -> getServer().getPlayer(id),
|
||||
problem -> getLogger().log(Level.WARNING, "Combat reward failed for character " + problem.character(), problem.cause()));
|
||||
sessions.attachPvpDefeat(combatExperience::consumePvpDefeat);
|
||||
abilityRegistry = new games.dmg.fantasystick.ability.AbilityRegistry();
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballSettings);
|
||||
fireballSettings = new games.dmg.fantasystick.persistence.FireballSettings(
|
||||
getDataFolder().toPath(), fireballConfiguration);
|
||||
fireball = new games.dmg.fantasystick.ability.Fireball(fireballInitial);
|
||||
frostShardSettings = new games.dmg.fantasystick.persistence.FrostShardSettings(
|
||||
getDataFolder().toPath(), frostShardConfiguration);
|
||||
frostShard = new games.dmg.fantasystick.ability.FrostShard(frostShardInitial);
|
||||
@@ -208,6 +396,42 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
inspireSettings = new games.dmg.fantasystick.persistence.InspireSettings(getDataFolder().toPath(), inspireConfiguration);
|
||||
inspire = new games.dmg.fantasystick.ability.Inspire(inspireInitial, temporaryHealth);
|
||||
mendingLightSettings = new games.dmg.fantasystick.persistence.MendingLightSettings(
|
||||
getDataFolder().toPath(), mendingLightConfiguration);
|
||||
mendingLight = new games.dmg.fantasystick.ability.MendingLight(mendingLightInitial, characters, sessions, parties,
|
||||
nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
purifySettings = new games.dmg.fantasystick.persistence.PurifySettings(getDataFolder().toPath(), purifyConfiguration);
|
||||
purify = new games.dmg.fantasystick.ability.Purify(purifyInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
guardianBondSettings = new games.dmg.fantasystick.persistence.GuardianBondSettings(
|
||||
getDataFolder().toPath(), guardianBondConfiguration);
|
||||
guardianBond = new games.dmg.fantasystick.ability.GuardianBond(guardianBondInitial, sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
outgoingDamageBonus = new games.dmg.fantasystick.ability.OutgoingDamageBonus(sessions, parties,
|
||||
support -> combatExperience.support(support.target(), support.supportingCharacter(), support.aided(), support.amount()));
|
||||
rallyingCrySettings = new games.dmg.fantasystick.persistence.RallyingCrySettings(
|
||||
getDataFolder().toPath(), rallyingCryConfiguration);
|
||||
rallyingCry = new games.dmg.fantasystick.ability.RallyingCry(
|
||||
rallyingCryInitial, sessions, parties, outgoingDamageBonus);
|
||||
frostNovaSettings = new games.dmg.fantasystick.persistence.FrostNovaSettings(
|
||||
getDataFolder().toPath(), frostNovaConfiguration);
|
||||
frostNova = new games.dmg.fantasystick.ability.FrostNova(frostNovaInitial, sessions, parties);
|
||||
chainLightningSettings = new games.dmg.fantasystick.persistence.ChainLightningSettings(
|
||||
getDataFolder().toPath(), chainLightningConfiguration);
|
||||
chainLightning = new games.dmg.fantasystick.ability.ChainLightning(chainLightningInitial, sessions, parties);
|
||||
sanctuarySettings = new games.dmg.fantasystick.persistence.SanctuarySettings(
|
||||
getDataFolder().toPath(), sanctuaryConfiguration);
|
||||
sanctuary = new games.dmg.fantasystick.ability.Sanctuary(sanctuaryInitial, characters, sessions, parties,
|
||||
combatMitigation, nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
support -> combatExperience.support(support.supportingCharacter(), support.aided(), support.amount()));
|
||||
warBannerSettings = new games.dmg.fantasystick.persistence.WarBannerSettings(
|
||||
getDataFolder().toPath(), warBannerConfiguration);
|
||||
warBanner = new games.dmg.fantasystick.ability.WarBanner(warBannerInitial, sessions, parties, outgoingDamageBonus);
|
||||
meteorSettings = new games.dmg.fantasystick.persistence.MeteorSettings(getDataFolder().toPath(), meteorConfiguration);
|
||||
meteor = new games.dmg.fantasystick.ability.Meteor(meteorInitial, sessions, parties);
|
||||
greatSword = new games.dmg.fantasystick.ability.GreatSword(greatSwordSettings);
|
||||
revivals = new games.dmg.fantasystick.persistence.RevivalStore(getDataFolder().toPath());
|
||||
revive = new games.dmg.fantasystick.ability.Revive(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
@@ -222,12 +446,61 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityRegistry.register(shieldBash.definition());
|
||||
abilityRegistry.register(hex.definition());
|
||||
abilityRegistry.register(inspire.definition());
|
||||
abilityRegistry.register(mendingLight.definition());
|
||||
abilityRegistry.register(purify.definition());
|
||||
abilityRegistry.register(guardianBond.definition());
|
||||
abilityRegistry.register(rallyingCry.definition());
|
||||
abilityRegistry.register(frostNova.definition());
|
||||
abilityRegistry.register(chainLightning.definition());
|
||||
abilityRegistry.register(sanctuary.definition());
|
||||
abilityRegistry.register(warBanner.definition());
|
||||
abilityRegistry.register(meteor.definition());
|
||||
abilityRegistry.register(greatSword.definition());
|
||||
abilityRegistry.register(revive.definition());
|
||||
sessions.attachAbilityRegistry(abilityRegistry);
|
||||
var gearItems=new games.dmg.fantasystick.items.GearItems(this);sessions.attachGear(gearItems);
|
||||
vitals = new games.dmg.fantasystick.session.SessionVitals();
|
||||
sessions.attachVitals(vitals);
|
||||
nutritionPolicy = new games.dmg.fantasystick.session.NutritionPolicy(sessions);
|
||||
outOfCombatRecoverySettings = new games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings(
|
||||
getDataFolder().toPath(), recoveryConfiguration);
|
||||
outOfCombatRecovery = new games.dmg.fantasystick.session.OutOfCombatRecovery(recoveryInitial, characters, sessions,
|
||||
Clock.systemUTC(), nextTick, event -> getServer().getPluginManager().callEvent(event),
|
||||
games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Out-of-combat recovery failed for character " + problem.character(), problem.cause()), combatFeedback);
|
||||
quickerRegenerationSettings = new games.dmg.fantasystick.persistence.QuickerRegenerationSettings(
|
||||
getDataFolder().toPath(), quickerRegenerationInitial.bonus().toString());
|
||||
quickerRegeneration = new games.dmg.fantasystick.passive.QuickerRegenerationI(quickerRegenerationInitial);
|
||||
quickerRegenerationIISettings = new games.dmg.fantasystick.persistence.QuickerRegenerationIISettings(
|
||||
getDataFolder().toPath(), quickerRegenerationIIInitial.bonus().toString());
|
||||
quickerRegenerationII = new games.dmg.fantasystick.passive.QuickerRegenerationII(quickerRegenerationIIInitial);
|
||||
healthStealSettings = new games.dmg.fantasystick.persistence.HealthStealSettings(
|
||||
getDataFolder().toPath(), healthStealInitial.healing().toString());
|
||||
healthSteal = new games.dmg.fantasystick.passive.HealthSteal(healthStealInitial, characters, sessions, nextTick,
|
||||
event -> getServer().getPluginManager().callEvent(event), games.dmg.fantasystick.session.NativeHealthLimit::read,
|
||||
problem -> getLogger().log(Level.WARNING, "Health Steal failed for character " + problem.character(), problem.cause()),
|
||||
combatFeedback);
|
||||
speedI = new games.dmg.fantasystick.passive.SpeedI(sessions);
|
||||
lowLightVision = new games.dmg.fantasystick.passive.LowLightVision(sessions);
|
||||
passiveRegistry = new games.dmg.fantasystick.passive.PassiveRegistry();
|
||||
passiveRegistry.register(quickerRegeneration.definition());
|
||||
passiveRegistry.register(quickerRegenerationII.definition());
|
||||
passiveRegistry.register(healthSteal.definition());
|
||||
passiveRegistry.register(speedI.definition());
|
||||
passiveRegistry.register(lowLightVision.definition());
|
||||
passiveRegistry.register(new games.dmg.fantasystick.passive.StickKnockback().definition());
|
||||
passiveEngine = new games.dmg.fantasystick.passive.PassiveEngine(passiveRegistry, characters, sessions, nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Passive change failed for character " + problem.character()
|
||||
+ "; passive " + problem.passive(), problem.cause()));
|
||||
outOfCombatRecovery.attachBonusHealing(record -> {
|
||||
if (!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)) {
|
||||
return java.math.BigInteger.ZERO;
|
||||
}
|
||||
var bonus = quickerRegeneration.bonus();
|
||||
return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
|
||||
? bonus.add(quickerRegenerationII.bonus()) : bonus;
|
||||
});
|
||||
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);
|
||||
@@ -238,7 +511,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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 -> { },
|
||||
Clock.systemUTC(), nextTick, abilityScheduler, report -> {var current=dungeonController;if(current!=null)current.accept(report);}, combatFeedback, trainingDummyController,
|
||||
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
|
||||
+ "; ability " + problem.ability(), problem.cause()));
|
||||
abilityEngine.install(this);
|
||||
@@ -248,8 +521,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
String name = localPresentation.get().name(id);
|
||||
var player = getServer().getPlayer(id);
|
||||
return net.kyori.adventure.text.Component.text(name != null ? name : player == null ? id.toString() : player.getName());
|
||||
}, abilityRegistry, abilityEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(sessions);
|
||||
}, abilityRegistry, abilityEngine, passiveRegistry, passiveEngine);
|
||||
var localBasicAttack = new games.dmg.fantasystick.combat.BasicAttack(
|
||||
sessions, Clock.systemUTC(), combatFeedback, trainingDummyController);
|
||||
basicAttack = localBasicAttack;
|
||||
sessionEvents = new EquipmentSessionEvents(sessions, nextTick, diagnostics, input -> {
|
||||
books.accept(input);
|
||||
@@ -257,6 +531,42 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.accept(input);
|
||||
});
|
||||
sessionEvents.attachAbilityRegistry(abilityRegistry);
|
||||
sessionEvents.attachGear(gearItems);
|
||||
partyManagement = new games.dmg.fantasystick.party.PartyManagement(characterParties, partyRegistrars,
|
||||
characters, sessions, partyEconomySettings,
|
||||
new games.dmg.fantasystick.persistence.PartyOperationStore(getDataFolder().toPath()), nextTick,
|
||||
failure -> getLogger().log(Level.WARNING, "Party Registrar mutation failed", failure));
|
||||
partyRegistrarMenus = new games.dmg.fantasystick.party.PartyRegistrarMenus(this, partyManagement);
|
||||
partyRegistrarController = new games.dmg.fantasystick.party.PartyRegistrarController(this, partyRegistrars,
|
||||
partyRegistrarMenus::open);
|
||||
armorShop=new games.dmg.fantasystick.shop.ArmorShop(armorers,sessions,armorMarket,nextTick,
|
||||
failure->getLogger().log(Level.WARNING,"Fantasy Armorer purchase failed",failure));
|
||||
armorShopMenus=new games.dmg.fantasystick.shop.ArmorShopMenus(this,armorShop);
|
||||
var localArmorMenus=armorShopMenus;
|
||||
armorMarket.attachListener(catalog->{if(lifetime!=generation)return;try{nextTick.accept(()->{
|
||||
if(lifetime==generation)localArmorMenus.refreshOpen();});}
|
||||
catch(RuntimeException failure){if(lifetime==generation)getLogger().log(Level.WARNING,"Fantasy Armorer menu refresh failed",failure);}});
|
||||
armorerController=new games.dmg.fantasystick.shop.FantasyArmorerController(this,armorers,armorShopMenus::open);
|
||||
adventureQuestController = new games.dmg.fantasystick.adventure.AdventureQuestController(this,
|
||||
adventureQuests, adventureQuestSettings, sessions, parties, characters, rules, nextTick,
|
||||
Clock.systemUTC(), java.util.random.RandomGenerator.getDefault());
|
||||
adventureQuestCommands = new games.dmg.fantasystick.adventure.AdventureQuestCommands(
|
||||
this, adventureQuests, adventureQuestController, nextTick);
|
||||
var bossRecoverySettings=outOfCombatRecoverySettings;var bossQuickerRegeneration=quickerRegeneration;var bossQuickerRegenerationII=quickerRegenerationII;var bossHealthStealSettings=healthStealSettings;
|
||||
dungeonController = new games.dmg.fantasystick.dungeon.DungeonController(this,dungeons,adventureQuests,sessions,parties,characters,rules,nextTick,java.util.random.RandomGenerator.getDefault(),abilityRegistry,passiveRegistry,bossRecoverySettings::settings,record->{if(!record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID))return java.math.BigInteger.ZERO;var bonus=bossQuickerRegeneration.bonus();return record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)?bonus.add(bossQuickerRegenerationII.bonus()):bonus;},()->bossHealthStealSettings.settings().healing());
|
||||
var custodyDirectory=dungeons;
|
||||
sessions.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characters.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
characterParties.attachUnavailableCharacter(character->custodyDirectory.custody(character).isPresent());
|
||||
dungeonCommands = new games.dmg.fantasystick.dungeon.DungeonCommands(this,dungeons,dungeonController,adventureQuests,nextTick,characters::find,character->sessions.activeCharacter(character).isPresent(),character->characterParties.partyOf(character).isPresent());
|
||||
adventureQuests.registerDungeonRewards(site->dungeons.find(site.id()).map(value->new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(value.maximumXp(),value.maximumCoins())).orElse(new games.dmg.fantasystick.adventure.AdventureQuestDirectory.Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO)));
|
||||
adventureQuests.registerDungeonSites(()->dungeons.snapshot().dungeons().stream().filter(value->dungeons.eligibleForFutureOffer(value.id())).map(value->new games.dmg.fantasystick.adventure.AdventureQuestState.Site(value.id(),games.dmg.fantasystick.adventure.AdventureQuestState.SiteType.DUNGEON,value.name(),value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())).toList());
|
||||
adventureQuests.registerDungeonAvailability(site->{var definition=dungeons.find(site.id());if(definition.isEmpty()||!definition.orElseThrow().enabled())return false;var catalog=adventureQuests.snapshot().catalog();return catalog==null||dungeons.runView(site.id(),catalog.revision()).map(run->!run.defeated()).orElse(true);});
|
||||
adventureQuestController.registerResetParticipant(dungeonController);
|
||||
adventureQuestController.registerDungeonRoomPresence((dungeon,location)->location.getWorld()!=null&&dungeons.find(dungeon).filter(value->value.world().equals(location.getWorld().getUID())).isPresent()&&dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ()).isPresent());
|
||||
combatExperience.attachExcluded(entity->adventureQuestController.managed(entity)||dungeonController.managed(entity));
|
||||
sessionEvents.attachManagedInteractionTarget(entity -> partyRegistrarController.tagged(entity)
|
||||
|| armorerController.tagged(entity) || adventureQuestController.giver(entity));
|
||||
admin = new EquipmentAdminCommand(receipts, sessions, getServer(), nextTick,
|
||||
problem -> getLogger().log(Level.WARNING, "Equipment admin " + problem.operation()
|
||||
+ " for player " + problem.player(), problem.cause()), adventureSettings,
|
||||
@@ -264,16 +574,55 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachFireball(fireballSettings, fireball);
|
||||
admin.attachMendingLight(mendingLightSettings, mendingLight);
|
||||
admin.attachPurify(purifySettings, purify);
|
||||
admin.attachGuardianBond(guardianBondSettings, guardianBond);
|
||||
admin.attachRallyingCry(rallyingCrySettings, rallyingCry);
|
||||
admin.attachFrostNova(frostNovaSettings, frostNova);
|
||||
admin.attachChainLightning(chainLightningSettings, chainLightning);
|
||||
admin.attachSanctuary(sanctuarySettings, sanctuary);
|
||||
admin.attachWarBanner(warBannerSettings, warBanner);
|
||||
admin.attachMeteor(meteorSettings, meteor);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachExperienceGrant(new games.dmg.fantasystick.session.AdminExperienceGrant(
|
||||
characters, rules, sessions, nextTick, getServer()::getPlayer,
|
||||
problem -> getLogger().log(Level.WARNING,
|
||||
"Admin experience grant for character " + problem.character(), problem.cause())));
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachPartyEconomySettings(partyEconomySettings);
|
||||
admin.attachPartyRegistrars(partyRegistrars, partyRegistrarController);
|
||||
admin.attachTrainingDummies(trainingDummies, trainingDummyController);
|
||||
admin.attachGearSettings(gearSettings);
|
||||
admin.attachArmorers(armorers,armorerController);
|
||||
admin.attachArmorMarket(armorMarket);
|
||||
admin.attachCoinSettings(coinSettings);
|
||||
admin.attachHardMobXpSettings(hardMobXpSettings, combatExperience);
|
||||
admin.attachAdventureQuests(adventureQuestCommands, adventureQuestSettings);
|
||||
admin.attachDungeons(dungeonCommands);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachOutOfCombatRecovery(outOfCombatRecoverySettings, outOfCombatRecovery);
|
||||
admin.attachQuickerRegeneration(quickerRegenerationSettings, quickerRegeneration, passiveRegistry);
|
||||
admin.attachQuickerRegenerationII(quickerRegenerationIISettings, quickerRegenerationII, passiveRegistry);
|
||||
admin.attachHealthSteal(healthStealSettings, healthSteal, passiveRegistry);
|
||||
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())));
|
||||
admin.attachCharacterBooks(books);
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
var playerFeedbackCommand = java.util.Objects.requireNonNull(getCommand("fantasyfeedback"),
|
||||
"Missing packaged fantasyfeedback command");
|
||||
playerFeedbackCommand.setExecutor(feedbackCommand);
|
||||
playerFeedbackCommand.setTabCompleter(feedbackCommand);
|
||||
var playerParticlesCommand = java.util.Objects.requireNonNull(getCommand("fantasyparticles"),
|
||||
"Missing packaged fantasyparticles command");
|
||||
playerParticlesCommand.setExecutor(particlesCommand);
|
||||
playerParticlesCommand.setTabCompleter(particlesCommand);
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getPluginManager().registerEvents(anvilNames, this);
|
||||
@@ -281,30 +630,66 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(chatNames, this);
|
||||
getServer().getPluginManager().registerEvents(partyEvents, this);
|
||||
getServer().getPluginManager().registerEvents(partyCombat, this);
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarController, this);
|
||||
getServer().getPluginManager().registerEvents(trainingDummyController, this);
|
||||
getServer().getPluginManager().registerEvents(partyRegistrarMenus, this);
|
||||
getServer().getPluginManager().registerEvents(armorerController,this);
|
||||
getServer().getPluginManager().registerEvents(armorShopMenus,this);
|
||||
getServer().getPluginManager().registerEvents(adventureQuestController,this);
|
||||
getServer().getPluginManager().registerEvents(dungeonController,this);
|
||||
partyRegistrarController.install();
|
||||
trainingDummyController.install();
|
||||
armorerController.install();
|
||||
dungeonController.install();
|
||||
adventureQuestController.install();
|
||||
var localArmorMarket=armorMarket;
|
||||
armorMarketPulse=getServer().getScheduler().runTaskTimer(this,()->{if(lifetime!=generation)return;
|
||||
localArmorMarket.current().whenComplete((catalog,failure)->{
|
||||
if(failure!=null&&lifetime==generation)getLogger().log(Level.WARNING,"Scheduled Fantasy Armorer restock check failed",failure);});},20L,20L);
|
||||
armorMenuPulse=getServer().getScheduler().runTaskTimer(this,localArmorMenus::refreshOpen,1200L,1200L);
|
||||
partyCombat.install(this);
|
||||
getServer().getPluginManager().registerEvents(healthSteal, this);
|
||||
getServer().getPluginManager().registerEvents(combatExperience, this);
|
||||
combatExperience.install(this);
|
||||
getServer().getPluginManager().registerEvents(fireball, this);
|
||||
getServer().getPluginManager().registerEvents(frostShard, this);
|
||||
getServer().getPluginManager().registerEvents(frostNova, this);
|
||||
getServer().getPluginManager().registerEvents(chainLightning, this);
|
||||
getServer().getPluginManager().registerEvents(meteor, this);
|
||||
getServer().getPluginManager().registerEvents(longbow, this);
|
||||
getServer().getPluginManager().registerEvents(quickstep, this);
|
||||
getServer().getPluginManager().registerEvents(combatMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(guardianBond, this);
|
||||
guardianBond.install(this);
|
||||
getServer().getPluginManager().registerEvents(shieldBash, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageMitigation, this);
|
||||
getServer().getPluginManager().registerEvents(outgoingDamageBonus, this);
|
||||
rallyingCry.install(this);
|
||||
warBanner.install(this);
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(adminRevival, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(outOfCombatRecovery, this);
|
||||
outOfCombatRecovery.install(this);
|
||||
speedI.install(this);
|
||||
lowLightVision.install(this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); revive.reconcile(player); }
|
||||
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); }
|
||||
@@ -319,14 +704,34 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingProtocol = protocolPresentation; protocolPresentation = null;
|
||||
var closingChat = chatNames; chatNames = null;
|
||||
var closingAdmin = admin; admin = null;
|
||||
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
|
||||
var closingParticlesCommand = particlesCommand; particlesCommand = null;
|
||||
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
var closingPartyCombat = partyCombat; partyCombat = null;
|
||||
var closingParties = parties; parties = null;
|
||||
var closingCharacterParties = characterParties; characterParties = null;
|
||||
var closingPartyRegistrars = partyRegistrars; partyRegistrars = null;
|
||||
var closingPartyRegistrarController = partyRegistrarController; partyRegistrarController = null;
|
||||
var closingTrainingDummies = trainingDummies; trainingDummies = null;
|
||||
var closingTrainingDummyController = trainingDummyController; trainingDummyController = null;
|
||||
var closingAdventureQuests = adventureQuests; adventureQuests = null;
|
||||
var closingAdventureQuestController = adventureQuestController; adventureQuestController = null;
|
||||
adventureQuestCommands = null;
|
||||
var closingDungeons=dungeons;dungeons=null;var closingDungeonController=dungeonController;dungeonController=null;dungeonCommands=null;
|
||||
var closingPartyRegistrarMenus = partyRegistrarMenus; partyRegistrarMenus = null;
|
||||
var closingPartyManagement = partyManagement; partyManagement = null;
|
||||
var closingArmorers=armorers;armorers=null;var closingArmorerController=armorerController;armorerController=null;
|
||||
var closingArmorShopMenus=armorShopMenus;armorShopMenus=null;armorShop=null;
|
||||
var closingArmorMarket=armorMarket;armorMarket=null;var closingArmorMarketPulse=armorMarketPulse;armorMarketPulse=null;
|
||||
var closingArmorMenuPulse=armorMenuPulse;armorMenuPulse=null;
|
||||
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;
|
||||
@@ -338,6 +743,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingCombatExperience = combatExperience; combatExperience = null;
|
||||
var closingAbilityEngine = abilityEngine; abilityEngine = null;
|
||||
var closingFireball = fireball; fireball = null;
|
||||
var closingFireballSettings = fireballSettings; fireballSettings = null;
|
||||
var closingFrostShard = frostShard; frostShard = null;
|
||||
var closingFrostSettings = frostShardSettings; frostShardSettings = null;
|
||||
var closingLongbow = longbow; longbow = null;
|
||||
@@ -350,10 +756,37 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHex = hex; hex = null;
|
||||
var closingInspire = inspire; inspire = null;
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
mendingLight = null;
|
||||
var closingMendingLightSettings = mendingLightSettings; mendingLightSettings = null;
|
||||
purify = null;
|
||||
var closingPurifySettings = purifySettings; purifySettings = null;
|
||||
var closingGuardianBond = guardianBond; guardianBond = null;
|
||||
var closingGuardianBondSettings = guardianBondSettings; guardianBondSettings = null;
|
||||
var closingRallyingCry = rallyingCry; rallyingCry = null;
|
||||
var closingRallyingCrySettings = rallyingCrySettings; rallyingCrySettings = null;
|
||||
var closingFrostNova = frostNova; frostNova = null;
|
||||
var closingFrostNovaSettings = frostNovaSettings; frostNovaSettings = null;
|
||||
var closingChainLightning = chainLightning; chainLightning = null;
|
||||
var closingChainLightningSettings = chainLightningSettings; chainLightningSettings = null;
|
||||
var closingSanctuary = sanctuary; sanctuary = null;
|
||||
var closingSanctuarySettings = sanctuarySettings; sanctuarySettings = null;
|
||||
var closingWarBanner = warBanner; warBanner = null;
|
||||
var closingWarBannerSettings = warBannerSettings; warBannerSettings = null;
|
||||
var closingMeteor = meteor; meteor = null;
|
||||
var closingMeteorSettings = meteorSettings; meteorSettings = null;
|
||||
var closingOutgoingDamageBonus = outgoingDamageBonus; outgoingDamageBonus = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingPartyEconomySettings = partyEconomySettings; partyEconomySettings = null;
|
||||
var closingGearSettings=gearSettings;gearSettings=null;
|
||||
var closingCoinSettings = coinSettings; coinSettings = null;
|
||||
var closingHardMobXpSettings = hardMobXpSettings; hardMobXpSettings = null;
|
||||
var closingAdventureQuestSettings = adventureQuestSettings; adventureQuestSettings = 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;
|
||||
@@ -366,11 +799,25 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingRevivals = revivals; revivals = null;
|
||||
abilityRegistry = null;
|
||||
var closingNutrition = nutritionPolicy; nutritionPolicy = null;
|
||||
var closingOutOfCombatRecovery = outOfCombatRecovery; outOfCombatRecovery = null;
|
||||
var closingOutOfCombatRecoverySettings = outOfCombatRecoverySettings; outOfCombatRecoverySettings = null;
|
||||
var closingQuickerRegenerationSettings = quickerRegenerationSettings; quickerRegenerationSettings = null;
|
||||
var closingQuickerRegenerationIISettings = quickerRegenerationIISettings; quickerRegenerationIISettings = null;
|
||||
var closingHealthStealSettings = healthStealSettings; healthStealSettings = null;
|
||||
var closingHealthSteal = healthSteal; healthSteal = null;
|
||||
var closingSpeedI = speedI; speedI = null;
|
||||
var closingLowLightVision = lowLightVision; lowLightVision = null;
|
||||
var closingPassiveEngine = passiveEngine; passiveEngine = null;
|
||||
quickerRegeneration = null; quickerRegenerationII = null; passiveRegistry = 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 (closingFeedbackCommand != null) cleanup("feedback command", closingFeedbackCommand::close);
|
||||
if (closingParticlesCommand != null) cleanup("particles command", closingParticlesCommand::close);
|
||||
if (closingFeedbackPreferences != null) cleanup("feedback preferences", closingFeedbackPreferences::close);
|
||||
if (closingSettings != null) cleanup("configuration", closingSettings::close);
|
||||
if (closingFireballSettings != null) cleanup("Fireball configuration", closingFireballSettings::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);
|
||||
@@ -379,12 +826,70 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingShieldBashSettings != null) cleanup("Shield Bash configuration", closingShieldBashSettings::close);
|
||||
if (closingHexSettings != null) cleanup("Hex configuration", closingHexSettings::close);
|
||||
if (closingInspireSettings != null) cleanup("Inspire configuration", closingInspireSettings::close);
|
||||
if (closingMendingLightSettings != null) cleanup("Mending Light configuration", closingMendingLightSettings::close);
|
||||
if (closingPurifySettings != null) cleanup("Purify configuration", closingPurifySettings::close);
|
||||
if (closingGuardianBond != null) cleanup("Guardian Bond", closingGuardianBond::close);
|
||||
if (closingGuardianBondSettings != null) cleanup("Guardian Bond configuration", closingGuardianBondSettings::close);
|
||||
if (closingRallyingCry != null) cleanup("Rallying Cry", closingRallyingCry::close);
|
||||
if (closingRallyingCrySettings != null) cleanup("Rallying Cry configuration", closingRallyingCrySettings::close);
|
||||
if (closingFrostNova != null) cleanup("Frost Nova", closingFrostNova::close);
|
||||
if (closingFrostNovaSettings != null) cleanup("Frost Nova configuration", closingFrostNovaSettings::close);
|
||||
if (closingChainLightning != null) cleanup("Chain Lightning", closingChainLightning::close);
|
||||
if (closingChainLightningSettings != null) cleanup("Chain Lightning configuration", closingChainLightningSettings::close);
|
||||
if (closingSanctuary != null) cleanup("Sanctuary", closingSanctuary::close);
|
||||
if (closingSanctuarySettings != null) cleanup("Sanctuary configuration", closingSanctuarySettings::close);
|
||||
if (closingWarBanner != null) cleanup("War Banner", closingWarBanner::close);
|
||||
if (closingWarBannerSettings != null) cleanup("War Banner configuration", closingWarBannerSettings::close);
|
||||
if (closingMeteor != null) cleanup("Meteor", closingMeteor::close);
|
||||
if (closingMeteorSettings != null) cleanup("Meteor configuration", closingMeteorSettings::close);
|
||||
if (closingOutgoingDamageBonus != null) cleanup("outgoing damage bonuses", closingOutgoingDamageBonus::close);
|
||||
if (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingPartyEconomySettings != null) cleanup("party economy configuration", closingPartyEconomySettings::close);
|
||||
if(closingGearSettings!=null)cleanup("gear configuration",closingGearSettings::close);
|
||||
if (closingCoinSettings != null) cleanup("coin configuration", closingCoinSettings::close);
|
||||
if (closingHardMobXpSettings != null) cleanup("hard-mob XP configuration", closingHardMobXpSettings::close);
|
||||
if (closingAdventureQuestSettings != null) cleanup("adventure quest configuration", closingAdventureQuestSettings::close);
|
||||
if (closingAdventureQuests != null) cleanup("adventure quest persistence", closingAdventureQuests::close);
|
||||
if (closingAdventureQuestController != null) cleanup("adventure quests", closingAdventureQuestController::close);
|
||||
if (closingDungeonController != null) cleanup("dungeons", closingDungeonController::close);
|
||||
if (closingDungeons != null) cleanup("dungeon persistence", closingDungeons::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
if (closingOutOfCombatRecoverySettings != null) cleanup("out-of-combat recovery configuration", closingOutOfCombatRecoverySettings::close);
|
||||
if (closingQuickerRegenerationSettings != null) cleanup("Quicker Regeneration I configuration", closingQuickerRegenerationSettings::close);
|
||||
if (closingQuickerRegenerationIISettings != null) cleanup("Quicker Regeneration II configuration", closingQuickerRegenerationIISettings::close);
|
||||
if (closingHealthStealSettings != null) cleanup("Health Steal configuration", closingHealthStealSettings::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingFeedbackPreferences == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFeedbackPreferences.drained().toCompletableFuture(),
|
||||
closingCharacterParties == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingCharacterParties.drained().toCompletableFuture(),
|
||||
closingPartyRegistrars == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyRegistrars.drained().toCompletableFuture(),
|
||||
closingTrainingDummies == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingTrainingDummies.drained().toCompletableFuture(),
|
||||
closingPartyManagement == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyManagement.drained().toCompletableFuture(),
|
||||
closingArmorers==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorers.drained().toCompletableFuture(),
|
||||
closingArmorMarket==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingArmorMarket.drained().toCompletableFuture(),
|
||||
closingGearSettings==null?java.util.concurrent.CompletableFuture.completedFuture(null):closingGearSettings.drained().toCompletableFuture(),
|
||||
closingPartyEconomySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartyEconomySettings.drained().toCompletableFuture(),
|
||||
closingHardMobXpSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHardMobXpSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuestSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuestSettings.drained().toCompletableFuture(),
|
||||
closingAdventureQuests == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdventureQuests.drained().toCompletableFuture(),
|
||||
closingDungeons == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingDungeons.drained().toCompletableFuture(),
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
closingFireballSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFireballSettings.drained().toCompletableFuture(),
|
||||
closingFrostSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostSettings.drained().toCompletableFuture(),
|
||||
closingLongbowSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
@@ -401,12 +906,42 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
: closingHexSettings.drained().toCompletableFuture(),
|
||||
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingMendingLightSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMendingLightSettings.drained().toCompletableFuture(),
|
||||
closingPurifySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPurifySettings.drained().toCompletableFuture(),
|
||||
closingGuardianBondSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingGuardianBondSettings.drained().toCompletableFuture(),
|
||||
closingRallyingCrySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRallyingCrySettings.drained().toCompletableFuture(),
|
||||
closingFrostNovaSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingFrostNovaSettings.drained().toCompletableFuture(),
|
||||
closingChainLightningSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingChainLightningSettings.drained().toCompletableFuture(),
|
||||
closingSanctuarySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSanctuarySettings.drained().toCompletableFuture(),
|
||||
closingWarBannerSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingWarBannerSettings.drained().toCompletableFuture(),
|
||||
closingMeteorSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingMeteorSettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained().toCompletableFuture(),
|
||||
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRoleplaySettings.drained().toCompletableFuture());
|
||||
: 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(),
|
||||
closingOutOfCombatRecoverySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingOutOfCombatRecoverySettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationSettings.drained().toCompletableFuture(),
|
||||
closingQuickerRegenerationIISettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingQuickerRegenerationIISettings.drained().toCompletableFuture(),
|
||||
closingHealthStealSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHealthStealSettings.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);
|
||||
@@ -426,8 +961,26 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingRevive != null) cleanup("Revive", closingRevive::close);
|
||||
if (closingAbilityEngine != null) cleanup("ability engine", closingAbilityEngine::close);
|
||||
if (closingNutrition != null) cleanup("nutrition policy", closingNutrition::close);
|
||||
if (closingOutOfCombatRecovery != null) cleanup("out-of-combat recovery", closingOutOfCombatRecovery::close);
|
||||
if (closingPassiveEngine != null) cleanup("passive engine", closingPassiveEngine::close);
|
||||
if (closingHealthSteal != null) cleanup("Health Steal", closingHealthSteal::close);
|
||||
if (closingSpeedI != null) cleanup("Speed I", closingSpeedI::close);
|
||||
if (closingLowLightVision != null) cleanup("Low-Light Vision", closingLowLightVision::close);
|
||||
if (closingVisuals != null) cleanup("session particles", closingVisuals::close);
|
||||
if (closingParties != null) cleanup("parties", closingParties::close);
|
||||
if (closingCharacterParties != null) cleanup("character parties", closingCharacterParties::close);
|
||||
if (closingPartyRegistrarMenus != null) cleanup("party registrar menus", closingPartyRegistrarMenus::close);
|
||||
if (closingPartyManagement != null) cleanup("party management", closingPartyManagement::close);
|
||||
if (closingPartyRegistrarController != null) cleanup("party registrar entities", closingPartyRegistrarController::close);
|
||||
if (closingPartyRegistrars != null) cleanup("party registrars", closingPartyRegistrars::close);
|
||||
if (closingTrainingDummyController != null) cleanup("training dummy entities", closingTrainingDummyController::close);
|
||||
if (closingTrainingDummies != null) cleanup("training dummies", closingTrainingDummies::close);
|
||||
if(closingArmorMarketPulse!=null)cleanup("armor market schedule",closingArmorMarketPulse::cancel);
|
||||
if(closingArmorMenuPulse!=null)cleanup("armor menu schedule",closingArmorMenuPulse::cancel);
|
||||
if(closingArmorShopMenus!=null)cleanup("armor shop menus",closingArmorShopMenus::close);
|
||||
if(closingArmorerController!=null)cleanup("armorer entities",closingArmorerController::close);
|
||||
if(closingArmorMarket!=null)cleanup("armor market",closingArmorMarket::close);
|
||||
if(closingArmorers!=null)cleanup("armorers",closingArmorers::close);
|
||||
if (closingPartyCombat != null) cleanup("party combat", closingPartyCombat::close);
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
@@ -437,7 +990,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingChat);
|
||||
unregister(closingPartyEvents);
|
||||
unregister(closingPartyCombat);
|
||||
unregister(closingPartyRegistrarController);
|
||||
unregister(closingTrainingDummyController);
|
||||
unregister(closingPartyRegistrarMenus);
|
||||
unregister(closingArmorerController);unregister(closingArmorShopMenus);
|
||||
unregister(closingAdventureQuestController);
|
||||
unregister(closingDungeonController);
|
||||
unregister(closingCombatExperience);
|
||||
unregister(closingHealthSteal);
|
||||
unregister(closingFireball);
|
||||
unregister(closingFrostShard);
|
||||
unregister(closingLongbow);
|
||||
@@ -451,11 +1011,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingOutOfCombatRecovery);
|
||||
unregister(closingBooks);
|
||||
unregister(closingEvents);
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
unregister(closingAnvil);
|
||||
unregister(closingRecipeDiscovery);
|
||||
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
@@ -493,30 +1055,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules() {
|
||||
private games.dmg.fantasystick.progression.CombatRewardRules combatRewardRules(java.math.BigInteger hard) {
|
||||
try {
|
||||
var passive = new java.math.BigInteger(getConfig().getString("passive-xp", "1"));
|
||||
var ordinary = new java.math.BigInteger(getConfig().getString("ordinary-xp", "10"));
|
||||
var boss = new java.math.BigInteger(getConfig().getString("boss-xp", "100"));
|
||||
var spacing = new java.math.BigDecimal(getConfig().getString("restricted-kill-spacing-blocks", "3"));
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, boss, spacing);
|
||||
return new games.dmg.fantasystick.progression.CombatRewardRules(passive, ordinary, hard, boss, spacing);
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Combat reward settings must be decimal numbers", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.Fireball.Settings fireballSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("fireball-base-damage", "6"));
|
||||
var cooldown = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-cooldown-seconds", "4")).longValueExact());
|
||||
double range = new java.math.BigDecimal(getConfig().getString("fireball-range-blocks", "32")).doubleValue();
|
||||
var lifetime = java.time.Duration.ofSeconds(new java.math.BigInteger(
|
||||
getConfig().getString("fireball-lifetime-seconds", "5")).longValueExact());
|
||||
return new games.dmg.fantasystick.ability.Fireball.Settings(base, cooldown, range, lifetime);
|
||||
} catch (ArithmeticException | NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Fireball settings must use supported nonnegative numeric values", invalid);
|
||||
private java.util.Map<String, String> fireballConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FireballSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FireballSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FireballSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> outOfCombatRecoveryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostShardConfiguration() {
|
||||
@@ -597,10 +1165,99 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> mendingLightConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.MendingLightSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.MendingLightSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> purifyConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.PurifySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key, games.dmg.fantasystick.persistence.PurifySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.PurifySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> guardianBondConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.GuardianBondSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.GuardianBondSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> rallyingCryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.RallyingCrySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.RallyingCrySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> frostNovaConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.FrostNovaSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.FrostNovaSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> chainLightningConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.ChainLightningSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.ChainLightningSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> sanctuaryConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.SanctuarySettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.SanctuarySettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> warBannerConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.WarBannerSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.WarBannerSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.WarBannerSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private java.util.Map<String, String> meteorConfiguration() {
|
||||
var values = new java.util.LinkedHashMap<String, String>();
|
||||
for (String key : games.dmg.fantasystick.persistence.MeteorSettings.keys()) {
|
||||
values.put(key, getConfig().getString(key,
|
||||
games.dmg.fantasystick.persistence.MeteorSettings.defaultValue(key)));
|
||||
}
|
||||
games.dmg.fantasystick.persistence.MeteorSettings.parse(values);
|
||||
return java.util.Map.copyOf(values);
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.ability.GreatSword.Settings greatSwordSettings() {
|
||||
try {
|
||||
var base = new java.math.BigInteger(getConfig().getString("great-sword-base-damage", "7"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "1.2"));
|
||||
var seconds = new java.math.BigDecimal(getConfig().getString("great-sword-recovery-seconds", "0.75"));
|
||||
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) {
|
||||
|
||||
@@ -13,9 +13,10 @@ import org.bukkit.Material;
|
||||
|
||||
/** Immutable internal ability metadata and focused effect boundary; not an external add-on API. */
|
||||
public record AbilityDefinition(String id, String name, Material icon, List<String> lore, String instructions,
|
||||
Map<Stat, BigInteger> requirements, BigInteger pointCost, Activation activation,
|
||||
Map<Stat, BigInteger> requirements, BigInteger pointCost, Category category, Activation activation,
|
||||
ControlInput.Gesture gesture, Duration cooldown, Map<String, BigDecimal> parameters,
|
||||
TargetPolicy targetPolicy, AbilityEffect effect) {
|
||||
public enum Category { ATK, BUF, HEAL }
|
||||
public enum Activation { IMMEDIATE, DELAYED, PROJECTILE, AREA, SELECTION }
|
||||
public enum TargetPolicy { SELF, HOSTILE, PROJECTILE_HOSTILE, ALLY, PARTY, AREA, SELECTION, EXCEPTIONAL_NONCOMBAT,
|
||||
EXCEPTIONAL_NONCOMBAT_SELECTION }
|
||||
@@ -25,7 +26,7 @@ public record AbilityDefinition(String id, String name, Material icon, List<Stri
|
||||
public AbilityDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(icon);
|
||||
Objects.requireNonNull(lore); Objects.requireNonNull(instructions); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(pointCost); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(pointCost); Objects.requireNonNull(category); Objects.requireNonNull(activation); Objects.requireNonNull(gesture);
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(parameters); Objects.requireNonNull(targetPolicy);
|
||||
Objects.requireNonNull(effect);
|
||||
lore = List.copyOf(lore); requirements = Map.copyOf(requirements); parameters = Map.copyOf(parameters);
|
||||
|
||||
@@ -39,6 +39,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AbilityScheduler scheduler;
|
||||
private final Consumer<AbilityReport> reports;
|
||||
private final AbilityFeedback feedback;
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Consumer<Failure> failures;
|
||||
private final Map<Key, Object> pending = new HashMap<>();
|
||||
private final Map<Key, Long> generations = new HashMap<>();
|
||||
@@ -49,10 +51,27 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
|
||||
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports,
|
||||
(definition, input, type, target, amount) -> { },
|
||||
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
|
||||
}
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, AbilityFeedback feedback, Consumer<Failure> failures) {
|
||||
this(registry, characters, sessions, parties, clock, nextTick, scheduler, reports, feedback,
|
||||
games.dmg.fantasystick.training.TrainingDamage.passthrough(), failures);
|
||||
}
|
||||
|
||||
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
|
||||
Consumer<AbilityReport> reports, AbilityFeedback feedback,
|
||||
games.dmg.fantasystick.training.TrainingDamage trainingDamage, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.scheduler = Objects.requireNonNull(scheduler); this.reports = Objects.requireNonNull(reports);
|
||||
this.feedback = Objects.requireNonNull(feedback); this.trainingDamage = Objects.requireNonNull(trainingDamage);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
@@ -221,6 +240,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
/** Bounded lifecycle sweep; plugin timers may call this without scanning future catalogues. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
sessions.refreshCooldownControls(64);
|
||||
int checked = 0;
|
||||
outer: for (var runtimes : List.copyOf(active.values())) {
|
||||
for (var runtime : List.copyOf(runtimes)) {
|
||||
@@ -325,11 +345,21 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
}
|
||||
owned.add(resource);
|
||||
}
|
||||
@Override public boolean trainingDummy(Entity target) { return trainingDamage.target(Objects.requireNonNull(target)); }
|
||||
@Override public void damage(org.bukkit.entity.LivingEntity target, double amount,
|
||||
org.bukkit.damage.DamageSource source) {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(source);
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(amount, source));
|
||||
measured.ifPresent(value -> report(AbilityReport.Type.DAMAGE, target, value));
|
||||
}
|
||||
@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()));
|
||||
var report = new AbilityReport(type, key.character(), input.session().session(), target.getUniqueId(), amount, clock.instant());
|
||||
reports.accept(report);
|
||||
try { feedback.report(definition, input, type, target, report.amount()); }
|
||||
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
}
|
||||
@Override public void finish() { close(); }
|
||||
@Override public void close() {
|
||||
|
||||
@@ -5,7 +5,9 @@ import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
|
||||
/** Bounded owned runtime supplied by the shared engine after durable cast authorization. */
|
||||
public interface AbilityExecution extends AutoCloseable {
|
||||
@@ -16,6 +18,8 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
boolean trainingDummy(Entity target);
|
||||
void damage(LivingEntity target, double amount, DamageSource source);
|
||||
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
|
||||
void finish();
|
||||
@Override void close();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import java.math.BigDecimal;
|
||||
import org.bukkit.entity.Entity;
|
||||
|
||||
/** Presentation sink for an authoritative applied ability outcome. */
|
||||
@FunctionalInterface
|
||||
public interface AbilityFeedback {
|
||||
void report(AbilityDefinition definition, ControlInput input, AbilityReport.Type type, Entity target, BigDecimal amount);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Deterministic bounded four-target lightning chain with exact decimal decay. */
|
||||
public final class ChainLightning implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double initialRange, double jumpRange,
|
||||
int additionalTargets, BigDecimal jumpMultiplier) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(jumpMultiplier);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(initialRange) || initialRange <= 0 || initialRange > 64
|
||||
|| !Double.isFinite(jumpRange) || jumpRange <= 0 || jumpRange > 64
|
||||
|| additionalTargets < 0 || additionalTargets > 63
|
||||
|| jumpMultiplier.signum() <= 0 || jumpMultiplier.compareTo(BigDecimal.ONE) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Chain Lightning settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(8), Duration.ofSeconds(18), 16, 6, 3, new BigDecimal("0.75"));
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public ChainLightning(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("chain_lightning", "Chain Lightning", Material.LIGHTNING_ROD,
|
||||
List.of("Strike for " + current.baseDamage() + " + INT modifier damage, then jump up to "
|
||||
+ current.additionalTargets() + " times.",
|
||||
"Each jump deals " + decimal(current.jumpMultiplier().movePointRight(2))
|
||||
+ "% of the previous defined damage within " + decimal(BigDecimal.valueOf(current.jumpRange())) + " blocks."),
|
||||
"Use while aiming at the first visible hostile within " + decimal(BigDecimal.valueOf(current.initialRange())) + " blocks.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(20), Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"initial-range", BigDecimal.valueOf(current.initialRange()).stripTrailingZeros(),
|
||||
"jump-range", BigDecimal.valueOf(current.jumpRange()).stripTrailingZeros(),
|
||||
"additional-targets", BigDecimal.valueOf(current.additionalTargets()),
|
||||
"jump-multiplier", current.jumpMultiplier()), AbilityDefinition.TargetPolicy.SELECTION, this);
|
||||
}
|
||||
|
||||
public static BigInteger initialDamage(BigInteger intelligence) { return initialDamage(BigInteger.valueOf(8), intelligence); }
|
||||
static BigInteger initialDamage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Chain Lightning damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && selectInitial(input, settings.initialRange()) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
var caster = input.player();
|
||||
if (input.target() instanceof LivingEntity supplied && eligible(caster, supplied)
|
||||
&& inRangeAndVisible(caster, supplied, range)) return supplied;
|
||||
try {
|
||||
var eye = caster.getEyeLocation();
|
||||
var hit = caster.getWorld().rayTraceEntities(eye, eye.getDirection(), range, entity ->
|
||||
entity instanceof LivingEntity target && eligible(caster, target) && inRangeAndVisible(caster, target, range));
|
||||
if (hit == null || !(hit.getHitEntity() instanceof LivingEntity target)) return null;
|
||||
return eligible(caster, target) && inRangeAndVisible(caster, target, range) ? target : null;
|
||||
} catch (RuntimeException unavailable) { return null; }
|
||||
}
|
||||
|
||||
private LivingEntity next(Player caster, LivingEntity previous, Set<UUID> visited, double range) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = previous.getNearbyEntities(range, range, range); }
|
||||
catch (RuntimeException unavailable) { return null; }
|
||||
var candidates = new ArrayList<LivingEntity>(); var seen = new HashSet<UUID>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && seen.add(target.getUniqueId()) && !visited.contains(target.getUniqueId())
|
||||
&& eligible(caster, target) && inRangeAndVisible(previous, target, range)) candidates.add(target);
|
||||
}
|
||||
var origin = previous.getLocation();
|
||||
candidates.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return candidates.isEmpty() ? null : candidates.getFirst();
|
||||
}
|
||||
|
||||
private boolean eligible(Player caster, LivingEntity target) {
|
||||
if (target == caster) return false;
|
||||
if (!(target instanceof Player player)) return true;
|
||||
if (sessions.active(player.getUniqueId()).filter(sessions::current).isEmpty()) return false;
|
||||
var sourceParty = parties.partyOf(caster.getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
private static boolean inRangeAndVisible(LivingEntity source, LivingEntity target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
caster.getWorld().spawnParticle(Particle.ELECTRIC_SPARK, location, 18, 0.3, 0.5, 0.3, 0.02);
|
||||
caster.getWorld().playSound(location, Sound.ENTITY_LIGHTNING_BOLT_THUNDER, 0.35f, 1.7f);
|
||||
} catch (RuntimeException ignored) { }
|
||||
}
|
||||
private static BigDecimal definiteNonnegative(BigDecimal value) { return value.signum() < 0 ? BigDecimal.ZERO : value; }
|
||||
private static double finite(BigDecimal value) {
|
||||
if (value.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0) return Double.MAX_VALUE;
|
||||
double result = value.doubleValue(); return Double.isFinite(result) ? result : Double.MAX_VALUE;
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(), p.get("initial-range").doubleValue(),
|
||||
p.get("jump-range").doubleValue(), p.get("additional-targets").intValueExact(), p.get("jump-multiplier"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() { closed = true; damaging = null; }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -17,9 +18,13 @@ 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;
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
@@ -33,10 +38,14 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent);
|
||||
return grant(execution, player, percent, () -> true);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
var grant = new Grant(execution, player, percent);
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -48,7 +57,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
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 (grant.player != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
@@ -65,7 +74,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
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.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,47 +15,50 @@ import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
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.entity.Snowball;
|
||||
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;
|
||||
|
||||
/** Fireball's owned snowball projectile, direct custom damage and bounded cleanup adapter. */
|
||||
/** Fireball's owned arrow flight, direct custom damage and bounded cleanup adapter. */
|
||||
public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
private static final double SPEED_PER_TICK = 1.0;
|
||||
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime) {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double range, Duration lifetime, double speedPerTick) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(lifetime);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 1024
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0) {
|
||||
|| lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofSeconds(60)) > 0
|
||||
|| !Double.isFinite(speedPerTick) || speedPerTick < 0.01 || speedPerTick > 64) {
|
||||
throw new IllegalArgumentException("Invalid Fireball settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(4), 32, Duration.ofSeconds(5));
|
||||
return new Settings(BigInteger.valueOf(6), Duration.ofMillis(1200), 32, Duration.ofSeconds(5), 3);
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private volatile Settings settings;
|
||||
private final Map<UUID, State> projectiles = new HashMap<>();
|
||||
private State damaging;
|
||||
private boolean closed;
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final Arrow projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin; this.intelligence = intelligence;
|
||||
State(Arrow 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;
|
||||
@@ -66,16 +69,19 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
public Fireball(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("fireball", "Fireball", Material.SNOWBALL,
|
||||
List.of("Launch a bounded fiery snowball.", "Deals " + settings.baseDamage() + " + INT modifier damage."),
|
||||
List.of("Launch a bounded fiery arrow flight.", "Deals " + current.baseDamage() + " + INT modifier damage."),
|
||||
"Use the equipped snowball; a miss still spends the cooldown.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, settings.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage()),
|
||||
"range", BigDecimal.valueOf(settings.range()).stripTrailingZeros(),
|
||||
"lifetime", BigDecimal.valueOf(settings.lifetime().toMillis(), 3).stripTrailingZeros()),
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
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()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
@@ -95,21 +101,24 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
@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("Fireball direction is unavailable");
|
||||
var projectile = player.launchProjectile(Snowball.class, direction.normalize().multiply(SPEED_PER_TICK));
|
||||
var projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
|
||||
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
|
||||
projectile.setVisualFire(true);
|
||||
var state = new State(projectile, execution, origin, execution.character().stats().get(Stat.INT));
|
||||
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 Fireball projectile identity");
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(settings.range() / SPEED_PER_TICK));
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
|
||||
execution.later(settings.lifetime(), execution::finish);
|
||||
execution.later(current.lifetime(), execution::finish);
|
||||
player.getWorld().spawnParticle(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@@ -121,13 +130,13 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
BigInteger amount = damage(settings.baseDamage(), state.intelligence);
|
||||
BigInteger amount = damage(state.settings.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target;
|
||||
damaging = state;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.execution.finish();
|
||||
@@ -137,7 +146,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
private boolean inRange(State state, Location current) {
|
||||
return current.getWorld() != null && state.origin.getWorld() != null
|
||||
&& current.getWorld().equals(state.origin.getWorld())
|
||||
&& current.distanceSquared(state.origin) <= settings.range() * settings.range();
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void containNativeDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Arrow arrow)) return;
|
||||
var state = projectiles.get(arrow.getUniqueId());
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -153,6 +169,10 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded instantaneous Frost Nova damage and ownership-aware Slowness. */
|
||||
public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double radius,
|
||||
int slownessAmplifier, Duration slownessDuration) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage); Objects.requireNonNull(cooldown); Objects.requireNonNull(slownessDuration);
|
||||
if (baseDamage.signum() < 0 || cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| slownessAmplifier < 0 || slownessAmplifier > 255
|
||||
|| slownessDuration.isNegative() || slownessDuration.isZero()
|
||||
|| slownessDuration.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Frost Nova settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(5), Duration.ofSeconds(12), 5, 1, Duration.ofSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (!owned) return;
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
private final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Map<AbilityExecution, LeaseGroup> active = new java.util.IdentityHashMap<>();
|
||||
private Attempt damaging;
|
||||
private boolean closed;
|
||||
|
||||
public FrostNova(Settings settings, EquipmentSessions sessions, PartyRoster parties) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("frost_nova", "Frost Nova", Material.SNOWBALL,
|
||||
List.of("Burst in a " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere for "
|
||||
+ current.baseDamage() + " + INT modifier damage.",
|
||||
"Applies Slowness " + level(current.slownessAmplifier()) + " for " + seconds(current.slownessDuration()) + " seconds."),
|
||||
"Use to affect exposed enemies; caster, party and ordinary players are excluded.",
|
||||
Map.of(Stat.INT, BigInteger.valueOf(16), Stat.DEX, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"slowness-amplifier", BigDecimal.valueOf(current.slownessAmplifier()),
|
||||
"slowness-duration-seconds", BigDecimal.valueOf(current.slownessDuration().toNanos()).movePointLeft(9)),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) { return damage(BigInteger.valueOf(5), intelligence); }
|
||||
static BigInteger damage(BigInteger base, BigInteger intelligence) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(intelligence);
|
||||
if (base.signum() < 0 || intelligence.signum() < 0) throw new IllegalArgumentException("Invalid Frost Nova damage input");
|
||||
var delta = intelligence.subtract(BigInteger.TEN); var divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
var modifier = divided[0]; if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var source = execution.input().player();
|
||||
Location origin = source.getEyeLocation().clone();
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
try { execution.damage(target, amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { source.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(radius, radius, radius); }
|
||||
catch (RuntimeException unavailable) { return List.of(); }
|
||||
var unique = new LinkedHashMap<UUID, LivingEntity>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof LivingEntity target && target != source) unique.putIfAbsent(target.getUniqueId(), target);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((LivingEntity target) -> distance(origin, target))
|
||||
.thenComparing(LivingEntity::getUniqueId));
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean eligible(AbilityExecution execution, LivingEntity target, Location origin, double radius) {
|
||||
if (target == execution.input().player() || !inside(origin, target, radius)) return false;
|
||||
try {
|
||||
if (!execution.input().player().hasLineOfSight(target) || !unobstructed(origin, target)) return false;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
if (!(target instanceof Player player)) return true;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null) return false;
|
||||
var sourceParty = parties.partyOf(execution.input().player().getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty == null || targetParty == null || !sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
var bounds = target.getBoundingBox();
|
||||
var center = bounds == null ? location.toVector().add(new org.bukkit.util.Vector(0, target.getHeight() / 2, 0))
|
||||
: bounds.getCenter();
|
||||
var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance,
|
||||
org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
}
|
||||
|
||||
private static boolean inside(Location origin, LivingEntity target, double radius) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
&& origin.distanceSquared(location) <= radius * radius; }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, LivingEntity target) {
|
||||
try { var location = target.getLocation(); return origin.getWorld() == location.getWorld()
|
||||
? origin.distanceSquared(location) : Double.POSITIVE_INFINITY; }
|
||||
catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("radius").doubleValue(), parameters.get("slowness-amplifier").intValueExact(),
|
||||
Duration.ofNanos(parameters.get("slowness-duration-seconds").movePointRight(9).longValueExact()));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) { case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; default -> Integer.toString(amplifier + 1); };
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; damaging = null;
|
||||
for (var group : List.copyOf(active.values())) { group.close(); group.execution.finish(); }
|
||||
active.clear();
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,13 @@ import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
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.entity.Snowball;
|
||||
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.potion.PotionEffect;
|
||||
@@ -45,12 +47,12 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(5), 32, Duration.ofSeconds(5),
|
||||
1, 0, Duration.ofSeconds(2));
|
||||
3, 0, Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final Snowball projectile;
|
||||
final Arrow projectile;
|
||||
final AbilityExecution execution;
|
||||
final Location origin;
|
||||
final BigInteger intelligence;
|
||||
@@ -58,7 +60,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
LivingEntity target;
|
||||
boolean accepted;
|
||||
boolean finished;
|
||||
State(Snowball projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
this.intelligence = intelligence; this.settings = settings;
|
||||
}
|
||||
@@ -112,7 +114,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
"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,
|
||||
Map.of(Stat.INT, BigInteger.valueOf(12), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
@@ -144,7 +146,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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 projectile = player.launchProjectile(Arrow.class, direction.normalize().multiply(current.speedPerTick()));
|
||||
projectile.setPickupStatus(AbstractArrow.PickupStatus.DISALLOWED);
|
||||
projectile.setDamage(0); projectile.setKnockbackStrength(0); projectile.setPierceLevel(0); projectile.setCritical(false);
|
||||
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");
|
||||
@@ -160,6 +164,13 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
player.getWorld().spawnParticle(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void containNativeDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getDamager() instanceof Arrow arrow)) return;
|
||||
var state = projectiles.get(arrow.getUniqueId());
|
||||
if (state != null && damaging != state) event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void hit(ProjectileHitEvent event) {
|
||||
var state = projectiles.remove(event.getEntity().getUniqueId());
|
||||
@@ -175,7 +186,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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); }
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
if (state.accepted && state.execution.stillCurrent()) {
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
|
||||
@@ -27,7 +27,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
throw new IllegalArgumentException("Invalid Great Sword settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(1200)); }
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(7), Duration.ofMillis(750)); }
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
@@ -50,7 +50,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
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,
|
||||
Map.of(Stat.STR, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.ATTACK, settings.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(settings.baseDamage())),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE, this);
|
||||
@@ -93,7 +93,7 @@ public final class GreatSword implements AbilityEffect, Listener, AutoCloseable
|
||||
.withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target);
|
||||
damaging = attempt;
|
||||
try { target.damage(nativeDamage, source); }
|
||||
try { execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
|
||||
/** Conserved, non-recursive native combat redirection for Guardian Bond. */
|
||||
public final class GuardianBond implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double range, Duration duration, BigDecimal redirectPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(redirectPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| redirectPercent.signum() < 0 || redirectPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Guardian Bond settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(20), 10, Duration.ofSeconds(6), BigDecimal.valueOf(30));
|
||||
}
|
||||
}
|
||||
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(target); Objects.requireNonNull(supportingCharacter);
|
||||
Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface public interface Damage {
|
||||
BigDecimal apply(Player guardian, Entity attacker, BigDecimal requested);
|
||||
}
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution;
|
||||
final Player guardian;
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
this.execution = execution; this.guardian = guardian; this.ally = ally;
|
||||
this.aided = aided; this.settings = settings;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
private static final ThreadLocal<Integer> REDIRECT_DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Support> supports) {
|
||||
this(settings, sessions, parties, null, supports);
|
||||
}
|
||||
|
||||
public GuardianBond(Settings settings, EquipmentSessions sessions, PartyRoster parties,
|
||||
Damage damage, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Guardian Bond sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
/** Bound active-link validation prevents an invalid link from becoming valid again later. */
|
||||
public void sweep() {
|
||||
if (closed) return;
|
||||
int checked = 0;
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
if (!current(state)) breakBond(state);
|
||||
if (++checked == 256) break;
|
||||
}
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("guardian_bond", "Guardian Bond", Material.IRON_CHESTPLATE,
|
||||
List.of("Redirect " + decimal(current.redirectPercent()) + "% of an aimed party ally's combat damage.",
|
||||
"Range " + decimal(BigDecimal.valueOf(current.range())) + " blocks for " + seconds(current.duration()) + " seconds."),
|
||||
"Use on an aimed active party ally; one guardian and one bond per ally.",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"redirect-percent", current.redirectPercent()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
if (closed || !(input.target() instanceof Player ally) || ally == input.player()) return false;
|
||||
return byGuardian.get(input.player().getUniqueId()) == null && byAlly.get(ally.getUniqueId()) == null
|
||||
&& eligible(input.player(), ally, settings.range()) && aided(ally) != null;
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof Player ally)) return;
|
||||
var guardian = execution.input().player(); var captured = from(execution.definition()); var aided = aided(ally);
|
||||
if (ally == guardian || aided == null || !eligible(guardian, ally, captured.range())
|
||||
|| byGuardian.containsKey(guardian.getUniqueId()) || byAlly.containsKey(ally.getUniqueId())) {
|
||||
execution.finish(); return;
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void redirect(EntityDamageByEntityEvent event) {
|
||||
if (closed || redirectedDamage() || transferring != 0 || !(event.getEntity() instanceof Player ally)) return;
|
||||
var state = byAlly.get(ally.getUniqueId());
|
||||
if (state == null) return;
|
||||
if (!current(state)) { breakBond(state); return; }
|
||||
double incoming = event.getDamage();
|
||||
if (!Double.isFinite(incoming) || incoming <= 0) return;
|
||||
BigDecimal requested = BigDecimal.valueOf(incoming)
|
||||
.multiply(state.settings.redirectPercent().movePointLeft(2));
|
||||
if (requested.signum() <= 0) return;
|
||||
Entity attacker = attacker(event);
|
||||
if (attacker == null) return;
|
||||
BigDecimal accepted;
|
||||
transferring++; REDIRECT_DEPTH.set(REDIRECT_DEPTH.get() + 1);
|
||||
try { accepted = damage.apply(state.guardian, attacker, requested); }
|
||||
catch (RuntimeException failure) { return; }
|
||||
finally {
|
||||
transferring--;
|
||||
int depth = REDIRECT_DEPTH.get() - 1;
|
||||
if (depth == 0) REDIRECT_DEPTH.remove(); else REDIRECT_DEPTH.set(depth);
|
||||
}
|
||||
if (accepted == null || accepted.signum() <= 0) return;
|
||||
BigDecimal maximum = requested.min(BigDecimal.valueOf(incoming));
|
||||
if (accepted.compareTo(maximum) > 0) accepted = maximum;
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
private BigDecimal nativeDamage(Player guardian, Entity attacker, BigDecimal requested) {
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
return transfer.accepted;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedRedirect(EntityDamageByEntityEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
public static boolean redirectedDamage() {
|
||||
int depth = REDIRECT_DEPTH.get();
|
||||
if (depth == 0) REDIRECT_DEPTH.remove();
|
||||
return depth > 0;
|
||||
}
|
||||
|
||||
private EquipmentSessions.Active aided(Player ally) {
|
||||
return sessions.active(ally.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
}
|
||||
|
||||
private boolean current(State state) {
|
||||
if (state.closed || !state.execution.stillCurrent() || state.ally != state.execution.input().target()
|
||||
|| !sessions.current(state.aided)
|
||||
|| !sessions.active(state.ally.getUniqueId()).filter(state.aided::equals).isPresent()
|
||||
|| !eligible(state.guardian, state.ally, state.settings.range())) return false;
|
||||
var guardianParty = parties.partyOf(state.guardian.getUniqueId()).orElse(null);
|
||||
var allyParty = parties.partyOf(state.ally.getUniqueId()).orElse(null);
|
||||
return guardianParty != null && allyParty != null && guardianParty.id().equals(allyParty.id());
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
try {
|
||||
var source = guardian.getLocation(); var target = ally.getLocation();
|
||||
return source.getWorld() == target.getWorld() && source.distanceSquared(target) <= range * range
|
||||
&& guardian.hasLineOfSight(ally);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
Entity causing = event.getDamageSource().getCausingEntity();
|
||||
if (causing != null) return causing;
|
||||
if (event.getDamager() instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter();
|
||||
if (shooter instanceof Entity entity) return entity;
|
||||
}
|
||||
return event.getDamager();
|
||||
}
|
||||
|
||||
private static double effectiveHealth(Player player) {
|
||||
return Math.max(0, player.getHealth()) + Math.max(0, player.getAbsorptionAmount());
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("range").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("redirect-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,13 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
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,
|
||||
"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.Category.ATK,
|
||||
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 current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){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();
|
||||
|
||||
@@ -5,7 +5,7 @@ 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);}
|
||||
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.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
|
||||
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
|
||||
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
|
||||
|
||||
@@ -83,7 +83,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
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,
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.PROJECTILE, ControlInput.Gesture.DRAW_RELEASE, current.recovery(),
|
||||
Map.of("base-damage", new BigDecimal(current.baseDamage()),
|
||||
"minimum-draw-ticks", BigDecimal.valueOf(current.minimumDrawTicks()),
|
||||
@@ -171,7 +171,7 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
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); }
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Mending Light's aimed party healing with persist-before-publication support evidence. */
|
||||
public final class MendingLight implements AbilityEffect {
|
||||
public record Settings(BigInteger baseHealing, Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseHealing); Objects.requireNonNull(cooldown);
|
||||
if (baseHealing.signum() < 0
|
||||
|| baseHealing.compareTo(BigDecimal.valueOf(Double.MAX_VALUE).toBigInteger()) > 0
|
||||
|| cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Mending Light settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.valueOf(6), Duration.ofSeconds(8), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public MendingLight(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.healingEvents = Objects.requireNonNull(healingEvents);
|
||||
this.nativeLimit = Objects.requireNonNull(nativeLimit); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("mending_light", "Mending Light", Material.GHAST_TEAR,
|
||||
List.of("Restore a party ally's missing fantasy health.",
|
||||
"Heals " + current.baseHealing() + " + WIS modifier within " + decimal(current.range()) + " blocks."),
|
||||
"Use while aiming at an injured active party ally; self and full-health targets are refused.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(12), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing()),
|
||||
"range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
if (target == null) return false;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
double health = target.player().getHealth();
|
||||
return attribute != null && Double.isFinite(health) && health >= 0
|
||||
&& Double.isFinite(attribute.getValue()) && health < attribute.getValue();
|
||||
}
|
||||
|
||||
public static BigInteger healing(BigInteger wisdom) { return healing(BigInteger.valueOf(6), wisdom); }
|
||||
|
||||
static BigInteger healing(BigInteger base, BigInteger wisdom) {
|
||||
Objects.requireNonNull(base); Objects.requireNonNull(wisdom);
|
||||
if (base.signum() < 0 || wisdom.signum() < 0) throw new IllegalArgumentException("Invalid Mending Light healing input");
|
||||
BigInteger delta = wisdom.subtract(BigInteger.TEN); BigInteger[] divided = delta.divideAndRemainder(BigInteger.TWO);
|
||||
BigInteger modifier = divided[0];
|
||||
if (delta.signum() < 0 && divided[1].signum() != 0) modifier = modifier.subtract(BigInteger.ONE);
|
||||
return base.add(modifier);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition());
|
||||
var target = target(execution.input(), configured.range());
|
||||
if (target == null) { execution.finish(); return; }
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
var expected = target.record().revision();
|
||||
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
|
||||
try {
|
||||
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(AbilityExecution execution, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty()) {
|
||||
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
|
||||
return;
|
||||
}
|
||||
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double current = target.player().getHealth(); double maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue());
|
||||
double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
|
||||
} finally { execution.finish(); }
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player) || player == input.player() || !inRange(input.player(), player, range)
|
||||
|| !sameParty(input.player(), player)) return null;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
|
||||
private boolean current(Player source, Target target, double range) {
|
||||
return sessions.current(target.active())
|
||||
&& sessions.currentRecord(target.active()).filter(record -> record.id().equals(target.record().id())).isPresent()
|
||||
&& inRange(source, target.player(), range) && sameParty(source, target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(parameters.get("base-healing").toBigIntegerExact(), definition.cooldown(),
|
||||
parameters.get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.damage.DamageType;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Bounded solid-top Meteor warning followed by one direct, terrain-safe cylinder impact. */
|
||||
public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
public record Settings(BigInteger baseDamage, Duration cooldown, double targetRange, Duration warning,
|
||||
double radius, double verticalRange) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(baseDamage);Objects.requireNonNull(cooldown);Objects.requireNonNull(warning);
|
||||
long warningTicks;
|
||||
try { warningTicks=Math.max(1,(warning.toNanos()+49_999_999L)/50_000_000L); }
|
||||
catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid Meteor warning",invalid);}
|
||||
if(baseDamage.signum()<0||cooldown.isNegative()||cooldown.compareTo(Duration.ofDays(365))>0
|
||||
||!Double.isFinite(targetRange)||targetRange<=0||targetRange>64
|
||||
||warning.isZero()||warning.isNegative()||warningTicks>63
|
||||
||!Double.isFinite(radius)||radius<=0||radius>64
|
||||
||!Double.isFinite(verticalRange)||verticalRange<0||verticalRange>64)
|
||||
throw new IllegalArgumentException("Invalid Meteor settings");
|
||||
}
|
||||
public static Settings defaults(){return new Settings(BigInteger.valueOf(14),Duration.ofSeconds(45),24,
|
||||
Duration.ofSeconds(2),5,4);}
|
||||
}
|
||||
private record Ground(Block block,Location origin){ }
|
||||
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
|
||||
private final class Warning implements AutoCloseable {
|
||||
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
|
||||
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
|
||||
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
|
||||
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
|
||||
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
|
||||
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);ground.origin().getWorld().spawnParticle(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
|
||||
void impactVisual(){try{var world=ground.origin().getWorld();world.spawnParticle(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);world.spawnParticle(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){if(closed)return;closed=true;warnings.remove(execution,this);}
|
||||
}
|
||||
|
||||
private volatile Settings settings;private final EquipmentSessions sessions;private final PartyRoster parties;
|
||||
private final Map<AbilityExecution,Warning> warnings=new IdentityHashMap<>();private Attempt damaging;private boolean closed;
|
||||
public Meteor(Settings settings,EquipmentSessions sessions,PartyRoster parties){this.settings=Objects.requireNonNull(settings);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);}
|
||||
public void configure(Settings settings){this.settings=Objects.requireNonNull(settings);}
|
||||
public AbilityDefinition definition(){var current=settings;return new AbilityDefinition("meteor","Meteor",Material.FIRE_CHARGE,
|
||||
List.of("Mark visible ground within "+decimal(current.targetRange())+" blocks for a "+seconds(current.warning())+"-second warning.","Impact exposed enemies in a "+decimal(current.radius())+"-block area for "+current.baseDamage()+" + INT modifier damage."),
|
||||
"Use while looking at a solid top surface; the warning is visible and the impact never changes terrain.",Map.of(Stat.INT,BigInteger.valueOf(26),Stat.WIS,BigInteger.valueOf(20),Stat.CON,BigInteger.valueOf(12)),BigInteger.ONE,AbilityDefinition.Category.ATK,
|
||||
AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,current.cooldown(),Map.of("base-damage",new BigDecimal(current.baseDamage()),"target-range",BigDecimal.valueOf(current.targetRange()).stripTrailingZeros(),"warning-seconds",nanos(current.warning()),"radius",BigDecimal.valueOf(current.radius()).stripTrailingZeros(),"vertical-range",BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros()),AbilityDefinition.TargetPolicy.AREA,this);}
|
||||
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
|
||||
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
|
||||
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
|
||||
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
|
||||
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
|
||||
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
|
||||
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
|
||||
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
|
||||
private static double finite(BigInteger value){return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(p.get("base-damage").toBigIntegerExact(),definition.cooldown(),p.get("target-range").doubleValue(),Duration.ofNanos(p.get("warning-seconds").movePointRight(9).longValueExact()),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue());}
|
||||
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}private static String seconds(Duration value){return decimal(nanos(value));}private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;damaging=null;for(var warning:List.copyOf(warnings.values())){warning.close();warning.execution.finish();}warnings.clear();}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
|
||||
/** Strongest deterministic Fantasy Stick outgoing combat bonus stage. */
|
||||
public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
public record Support(UUID target, UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) { }
|
||||
private final class Grant implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player recipient;
|
||||
final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
sequence = ++sequences; this.execution = execution; this.recipient = recipient;
|
||||
this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
var owned = grants.get(recipient.getUniqueId());
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Used> pending = new IdentityHashMap<>();
|
||||
private long sequences;
|
||||
private boolean closed;
|
||||
|
||||
public OutgoingDamageBonus(EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player recipient, BigDecimal percent, BooleanSupplier current) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(recipient);
|
||||
Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(1000)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid outgoing damage bonus");
|
||||
}
|
||||
var grant = new Grant(execution, recipient, percent, current);
|
||||
grants.computeIfAbsent(recipient.getUniqueId(), ignored -> new ArrayList<>()).add(grant); return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||
public void increase(EntityDamageByEntityEvent event) {
|
||||
if (closed || GuardianBond.redirectedDamage()) return;
|
||||
Entity attacker = attacker(event); if (!(attacker instanceof Player player)) return;
|
||||
var owned = grants.get(player.getUniqueId()); if (owned == null) return;
|
||||
Grant strongest = null;
|
||||
for (var grant : List.copyOf(owned)) {
|
||||
if (grant.recipient != player || !grant.valid()) continue;
|
||||
if (strongest == null || grant.percent.compareTo(strongest.percent) > 0
|
||||
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence) strongest = grant;
|
||||
}
|
||||
double original = event.getDamage();
|
||||
if (strongest == null || !Double.isFinite(original) || original <= 0) return;
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
Entity causing = event.getDamageSource().getCausingEntity(); if (causing != null) return causing;
|
||||
if (event.getDamager() instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter(); if (shooter instanceof Entity entity) return entity;
|
||||
}
|
||||
return event.getDamager();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var owned : List.copyOf(grants.values())) for (var grant : List.copyOf(owned)) grant.close();
|
||||
grants.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
|
||||
@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;
|
||||
if(closed||GuardianBond.redirectedDamage())return;Entity attacker=attacker(event);if(attacker==null)return;var owned=grants.get(attacker.getUniqueId());if(owned==null)return;
|
||||
Grant strongest=null;for(var grant:List.copyOf(owned)){if(grant.closed||grant.target!=attacker||!grant.execution.stillCurrent())continue;
|
||||
if(strongest==null||grant.percent.compareTo(strongest.percent)>0
|
||||
||grant.percent.compareTo(strongest.percent)==0&&grant.sequence>strongest.sequence)strongest=grant;}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Purify's self-or-aimed-party native harmful-effect removal. */
|
||||
public final class Purify implements AbilityEffect {
|
||||
public record Settings(Duration cooldown, double range) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(range) || range <= 0 || range > 64) {
|
||||
throw new IllegalArgumentException("Invalid Purify settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(Duration.ofSeconds(15), 12); }
|
||||
}
|
||||
|
||||
public record Support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
public Support {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (amount.signum() <= 0) throw new IllegalArgumentException("Support amount must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private record Target(Player player, EquipmentSessions.Active active) { }
|
||||
private static final List<PotionEffectType> HARMFUL = List.of(
|
||||
PotionEffectType.POISON, PotionEffectType.WITHER, PotionEffectType.SLOWNESS, PotionEffectType.WEAKNESS);
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Consumer<Support> supports;
|
||||
private volatile Settings settings;
|
||||
|
||||
public Purify(Settings settings, EquipmentSessions sessions, PartyRoster parties, Consumer<Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("purify", "Purify", Material.HONEY_BOTTLE,
|
||||
List.of("Remove Poison, Wither, Slowness and Weakness.",
|
||||
"Aimed party-ally range: " + decimal(current.range()) + " blocks; beneficial effects remain."),
|
||||
"Sneak+Use cleanses self; normal Use requires an aimed active party ally.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(14), Stat.CHR, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("range", BigDecimal.valueOf(current.range()).stripTrailingZeros()),
|
||||
AbilityDefinition.TargetPolicy.PARTY, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
var target = target(input, settings.range());
|
||||
return target != null && HARMFUL.stream().anyMatch(type -> target.player().getPotionEffect(type) != null);
|
||||
}
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (!execution.stillCurrent()) return;
|
||||
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
|
||||
if (target == null) return;
|
||||
int removed = 0;
|
||||
for (var type : HARMFUL) {
|
||||
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
|
||||
if (target.player().getPotionEffect(type) == null) continue;
|
||||
target.player().removePotionEffect(type);
|
||||
if (target.player().getPotionEffect(type) == null) removed++;
|
||||
}
|
||||
if (removed == 0) return;
|
||||
var amount = BigDecimal.valueOf(removed);
|
||||
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
|
||||
if (target.player() != execution.input().player()
|
||||
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
|
||||
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
|
||||
}
|
||||
}
|
||||
|
||||
private Target target(ControlInput input, double range) {
|
||||
if (!(input.target() instanceof Player player)) return null;
|
||||
if (input.player().isSneaking()) {
|
||||
if (player != input.player()) return null;
|
||||
} else if (player == input.player() || !inRange(input.player(), player, range) || !sameParty(input.player(), player)) {
|
||||
return null;
|
||||
}
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active == null ? null : new Target(player, active);
|
||||
}
|
||||
|
||||
private boolean current(ControlInput input, Target target, double range) {
|
||||
if (!sessions.current(target.active())) return false;
|
||||
if (target.player() == input.player()) return input.player().isSneaking();
|
||||
return !input.player().isSneaking() && inRange(input.player(), target.player(), range)
|
||||
&& sameParty(input.player(), target.player());
|
||||
}
|
||||
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
|
||||
private static boolean inRange(Player source, Player target, double range) {
|
||||
try {
|
||||
var from = source.getLocation(); var to = target.getLocation();
|
||||
return from.getWorld() == to.getWorld() && from.distanceSquared(to) <= range * range && source.hasLineOfSight(target);
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
return new Settings(definition.cooldown(), definition.parameters().get("range").doubleValue());
|
||||
}
|
||||
|
||||
private static String decimal(double value) { return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); }
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
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,
|
||||
Map.of(Stat.DEX, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("distance", BigDecimal.valueOf(current.distance()).stripTrailingZeros(),
|
||||
"speed", BigDecimal.valueOf(current.speedPerTick()).stripTrailingZeros()),
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Dynamic owned party sphere for Rallying Cry movement and outgoing damage benefits. */
|
||||
public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, Duration duration,
|
||||
int speedAmplifier, BigDecimal damageBonusPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(damageBonusPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| duration.isNegative() || duration.isZero() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| speedAmplifier < 0 || speedAmplifier > 255
|
||||
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Rallying Cry settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(25), 8, Duration.ofSeconds(6), 0, BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { breakArea(this); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) {
|
||||
if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
}
|
||||
for (var entry : eligible.entrySet()) {
|
||||
if (!recipients.containsKey(entry.getKey())) {
|
||||
var benefit = new Benefit(this, entry.getValue()); recipients.put(entry.getKey(), benefit);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean sourceCurrent() {
|
||||
return execution.stillCurrent() && sessions.active(source.getUniqueId())
|
||||
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
|
||||
}
|
||||
boolean eligible(Player player) {
|
||||
if (!sourceCurrent()) return false;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null || !inside(source, player, settings.radius())) return false;
|
||||
if (player == source) return active.equals(execution.input().session());
|
||||
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
var recipientParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty != null && recipientParty != null && sourceParty.id().equals(recipientParty.id());
|
||||
}
|
||||
Map<UUID, Player> candidates() {
|
||||
var result = new LinkedHashMap<UUID, Player>();
|
||||
if (eligible(source)) result.put(source.getUniqueId(), source);
|
||||
List<Entity> nearby;
|
||||
try { nearby = source.getNearbyEntities(settings.radius(), settings.radius(), settings.radius()); }
|
||||
catch (RuntimeException unavailable) { nearby = List.of(); }
|
||||
int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256 || result.size() == 64) break;
|
||||
if (entity instanceof Player player && eligible(player)) result.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
}
|
||||
}
|
||||
|
||||
private final class SpeedGrant implements AutoCloseable {
|
||||
final SpeedState state; final long sequence; final int amplifier; boolean closed;
|
||||
SpeedGrant(SpeedState state, int amplifier) { this.state = state; sequence = ++speedSequences; this.amplifier = amplifier; }
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class SpeedState {
|
||||
final Player player; final List<SpeedGrant> grants = new ArrayList<>(); PotionEffect owned;
|
||||
SpeedState(Player player) { this.player = player; }
|
||||
void refresh() {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(PotionEffectType.SPEED); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) owned = null;
|
||||
SpeedGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (!grant.closed && (strongest == null
|
||||
|| grant.amplifier > strongest.amplifier
|
||||
|| grant.amplifier == strongest.amplifier && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
if (strongest == null) {
|
||||
if (owned != null && same(current, owned) && !removeOwned()) return;
|
||||
owned = null; speeds.remove(player.getUniqueId(), this); return;
|
||||
}
|
||||
if (owned != null && same(current, owned) && owned.getAmplifier() != strongest.amplifier) {
|
||||
if (!removeOwned()) return;
|
||||
current = null;
|
||||
}
|
||||
if (current != null && current.getAmplifier() >= strongest.amplifier) return;
|
||||
var effect = new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION,
|
||||
strongest.amplifier, false, false, true);
|
||||
try { if (player.addPotionEffect(effect, true)) owned = effect; }
|
||||
catch (RuntimeException ignored) { owned = null; }
|
||||
}
|
||||
boolean removeOwned() {
|
||||
try {
|
||||
player.removePotionEffect(PotionEffectType.SPEED);
|
||||
if (same(player.getPotionEffect(PotionEffectType.SPEED), owned)) return false;
|
||||
} catch (RuntimeException failure) { return false; }
|
||||
owned = null; return true;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final OutgoingDamageBonus bonuses;
|
||||
private final Map<Long, Area> areas = new java.util.HashMap<>();
|
||||
private final Map<UUID, SpeedState> speeds = new java.util.HashMap<>();
|
||||
private long sequences; private long speedSequences;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
public RallyingCry(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Rallying Cry sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed) return; int checked = 0;
|
||||
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
|
||||
for (var state : List.copyOf(speeds.values())) state.refresh();
|
||||
}
|
||||
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("rallying_cry", "Rallying Cry", Material.GOAT_HORN,
|
||||
List.of("Grant nearby active party members Speed " + (current.speedAmplifier() + 1)
|
||||
+ " and " + decimal(current.damageBonusPercent()) + "% combat damage.",
|
||||
"A " + decimal(BigDecimal.valueOf(current.radius())) + "-block sphere lasting " + seconds(current.duration()) + " seconds."),
|
||||
"Use to rally yourself and nearby active party members; copies do not stack.",
|
||||
Map.of(Stat.CHR, BigInteger.valueOf(16), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"duration-seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),
|
||||
"speed-amplifier", BigDecimal.valueOf(current.speedAmplifier()),
|
||||
"damage-bonus-percent", current.damageBonusPercent()), AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite();
|
||||
}
|
||||
private static void closeResource(AutoCloseable resource) {
|
||||
try { resource.close(); } catch (Exception impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var parameters = definition.parameters();
|
||||
return new Settings(definition.cooldown(), parameters.get("radius").doubleValue(),
|
||||
Duration.ofNanos(parameters.get("duration-seconds").movePointRight(9).longValueExact()),
|
||||
parameters.get("speed-amplifier").intValueExact(), parameters.get("damage-bonus-percent"));
|
||||
}
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
private static String seconds(Duration duration) {
|
||||
return decimal(BigDecimal.valueOf(duration.toNanos()).movePointLeft(9));
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
|
||||
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
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),
|
||||
BigInteger.ONE, AbilityDefinition.Category.HEAL, 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);
|
||||
@@ -269,7 +269,7 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
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(),
|
||||
dead.unspentPoints(), dead.maximumHealth(), dead.learned(), dead.loadout(), dead.passives(), dead.cooldowns(), dead.restrictedKills(),
|
||||
false, dead.deathId(), dead.diedAt());
|
||||
ItemStack sheet = sheets.create(provisional);
|
||||
ItemStack source = view.target.getInventory().getItem(reservation.sourceSlot());
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
|
||||
/** Fixed bounded Sanctuary cylinder with delayed healing pulses and dynamic strongest mitigation. */
|
||||
public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double radius, double verticalRange, Duration duration,
|
||||
Duration pulseInterval, BigDecimal healingPerPulse, BigDecimal damageReductionPercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration); Objects.requireNonNull(pulseInterval);
|
||||
Objects.requireNonNull(healingPerPulse); Objects.requireNonNull(damageReductionPercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|
||||
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| pulseInterval.isZero() || pulseInterval.isNegative() || pulseInterval.compareTo(duration) > 0
|
||||
|| duration.toNanos() / pulseInterval.toNanos() > 64
|
||||
|| healingPerPulse.signum() < 0 || healingPerPulse.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
|| damageReductionPercent.signum() < 0 || damageReductionPercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Sanctuary settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(45), 6, 3, Duration.ofSeconds(6), Duration.ofSeconds(1),
|
||||
BigDecimal.valueOf(2), BigDecimal.valueOf(20));
|
||||
}
|
||||
}
|
||||
private record Target(Player player, EquipmentSessions.Active active, CharacterRecord record) { }
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
while (nextPulse.compareTo(elapsed) <= 0 && nextPulse.compareTo(captured.duration()) <= 0) {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
}
|
||||
void pulse() {
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
var target = target(player); if (target == null) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = player.getHealth(), maximum = attribute.getValue(), missing = maximum - current;
|
||||
double proposed = Math.min(missing, captured.healingPerPulse().doubleValue());
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event); double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
pending++; var expected = target.record().revision();
|
||||
characters.saveHealth(target.record().id(), expected, BigDecimal.valueOf(current + accepted), nativeLimit.get())
|
||||
.whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> completeHeal(this, target, expected, accepted, saved, failure)); }
|
||||
catch (RuntimeException rejected) { completePending(this); }
|
||||
});
|
||||
}
|
||||
List<Player> recipients() {
|
||||
List<Entity> nearby;
|
||||
try { nearby = new ArrayList<>(source.getWorld().getNearbyEntities(origin,
|
||||
captured.radius(), captured.verticalRange(), captured.radius())); }
|
||||
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
|
||||
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof Player player && recipientCurrent(player)) unique.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
|
||||
return result.size() <= 64 ? result : new ArrayList<>(result.subList(0, 64));
|
||||
}
|
||||
boolean recipientCurrent(Player player) {
|
||||
if (closed || !execution.stillCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|
||||
|| !unobstructed(origin, player)) return false;
|
||||
if (player == source) return sessions.active(player.getUniqueId()).filter(sessions::current)
|
||||
.map(active -> active.equals(execution.input().session())).orElse(false);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
return active != null && sameParty(source, player);
|
||||
}
|
||||
Target target(Player player) {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
return record == null ? null : new Target(player, active, record);
|
||||
}
|
||||
void visual() {
|
||||
try { source.getWorld().spawnParticle(Particle.END_ROD, origin, 12,
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final CharacterStorage characters; private final EquipmentSessions sessions; private final PartyRoster parties;
|
||||
private final CombatMitigation combatMitigation; private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents; private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<MendingLight.Support> supports; private final List<State> states = new ArrayList<>();
|
||||
private boolean closed;
|
||||
|
||||
public Sanctuary(Settings settings, CharacterStorage characters, EquipmentSessions sessions, PartyRoster parties,
|
||||
CombatMitigation combatMitigation, Consumer<Runnable> nextTick,
|
||||
Consumer<EntityRegainHealthEvent> healingEvents, Supplier<BigDecimal> nativeLimit,
|
||||
Consumer<MendingLight.Support> supports) {
|
||||
this.settings = Objects.requireNonNull(settings); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.parties = Objects.requireNonNull(parties);
|
||||
this.combatMitigation = Objects.requireNonNull(combatMitigation); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.healingEvents = Objects.requireNonNull(healingEvents); this.nativeLimit = Objects.requireNonNull(nativeLimit);
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("sanctuary", "Sanctuary", Material.SEA_LANTERN,
|
||||
List.of("Create a fixed " + decimal(current.radius()) + "-block sanctuary for " + seconds(current.duration()) + " seconds.",
|
||||
"Party members inside heal " + decimal(current.healingPerPulse()) + " each second and take "
|
||||
+ decimal(current.damageReductionPercent()) + "% less combat damage."),
|
||||
"Use to hold exposed ground; healing starts after one second and the area does not follow you.",
|
||||
Map.of(Stat.WIS, BigInteger.valueOf(24), Stat.CHR, BigInteger.valueOf(20), Stat.CON, BigInteger.TEN), BigInteger.ONE, AbilityDefinition.Category.HEAL,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
|
||||
"duration-seconds", nanos(current.duration()), "pulse-interval-seconds", nanos(current.pulseInterval()),
|
||||
"healing-per-pulse", current.healingPerPulse(), "damage-reduction-percent", current.damageReductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
@Override public boolean accepts(ControlInput input) { return !closed && input.target() == null; }
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; new State(execution, from(execution.definition())).start();
|
||||
}
|
||||
|
||||
private void completeHeal(State state, Target target, BigInteger expected, double accepted,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !state.execution.stillCurrent()
|
||||
|| !state.recipientCurrent(target.player())) return;
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
|
||||
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH); if (attribute == null) return;
|
||||
double current = target.player().getHealth(), maximum = attribute.getValue();
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
state.execution.report(AbilityReport.Type.HEALING, target.player(),
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
if (state.pending > 0) state.pending--; if (state.expired && state.pending == 0) state.execution.finish();
|
||||
}
|
||||
private State owner(Player player) {
|
||||
for (var state : List.copyOf(states)) if (!state.expired
|
||||
&& state.mitigation.containsKey(player.getUniqueId())) return state;
|
||||
return null;
|
||||
}
|
||||
private boolean sameParty(Player source, Player target) {
|
||||
var party = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
return party != null && party.members().contains(target.getUniqueId());
|
||||
}
|
||||
private static boolean inside(Location origin, Player player, double radius, double vertical) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ();
|
||||
return dx * dx + dz * dz <= radius * radius && Math.abs(at.getY() - origin.getY()) <= vertical;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean unobstructed(Location origin, Player player) {
|
||||
try { var world = origin.getWorld(); if (world == null) return false; var box = player.getBoundingBox();
|
||||
var center = box.getCenter(); var offset = center.clone().subtract(origin.toVector()); double distance = offset.length();
|
||||
if (!Double.isFinite(distance) || distance == 0) return distance == 0;
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
} catch (RuntimeException invalid) { return Double.POSITIVE_INFINITY; }
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) { var p = definition.parameters();
|
||||
return new Settings(definition.cooldown(), p.get("radius").doubleValue(), p.get("vertical-range").doubleValue(),
|
||||
duration(p.get("duration-seconds")), duration(p.get("pulse-interval-seconds")),
|
||||
p.get("healing-per-pulse"), p.get("damage-reduction-percent")); }
|
||||
private static Duration duration(BigDecimal seconds) { return Duration.ofNanos(seconds.movePointRight(9).longValueExact()); }
|
||||
private static BigDecimal nanos(Duration value) { return BigDecimal.valueOf(value.toNanos()).movePointLeft(9); }
|
||||
private static String seconds(Duration value) { return decimal(nanos(value)); }
|
||||
private static String decimal(double value) { return decimal(BigDecimal.valueOf(value)); }
|
||||
private static String decimal(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; for (var state : List.copyOf(states)) { state.close(); state.execution.finish(); } states.clear();
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ public final class SecondWind implements AbilityEffect {
|
||||
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,
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.HEAL,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("base-healing", new BigDecimal(current.baseHealing())),
|
||||
AbilityDefinition.TargetPolicy.SELF, this);
|
||||
@@ -105,8 +105,12 @@ public final class SecondWind implements AbilityEffect {
|
||||
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()));
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
double restored = publish - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
|
||||
player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
|
||||
@@ -35,7 +35,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
|| 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);
|
||||
return new Settings(BigInteger.valueOf(4), Duration.ofSeconds(3), 3, 0.8, 0.35);
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
@@ -61,7 +61,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
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(),
|
||||
BigInteger.ONE, AbilityDefinition.Category.ATK, 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())),
|
||||
@@ -91,7 +91,7 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
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); }
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
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,
|
||||
"Use to protect yourself; environmental damage is unchanged.", Map.of(Stat.CON, BigInteger.valueOf(14)), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
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);
|
||||
|
||||
@@ -20,9 +20,9 @@ public final class TemporaryHealth implements Listener,AutoCloseable{
|
||||
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()));}
|
||||
@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();
|
||||
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
|
||||
private void consume(Player target,double before,double ownedBefore,Grant owner,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;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);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,owner.execution.input().session().character(),aided,amount));}
|
||||
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;}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package games.dmg.fantasystick.ability;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import org.bukkit.Color;
|
||||
import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.attribute.AttributeModifier;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Fixed spectral War Banner cylinder with strongest damage and one owned knockback-resistance layer. */
|
||||
public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
public record Settings(Duration cooldown, double placementRange, double radius, double verticalRange,
|
||||
Duration duration, BigDecimal damageBonusPercent, BigDecimal knockbackResistancePercent) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(cooldown); Objects.requireNonNull(duration);
|
||||
Objects.requireNonNull(damageBonusPercent); Objects.requireNonNull(knockbackResistancePercent);
|
||||
if (cooldown.isNegative() || cooldown.compareTo(Duration.ofDays(365)) > 0
|
||||
|| !Double.isFinite(placementRange) || placementRange <= 0 || placementRange > 64
|
||||
|| !Double.isFinite(radius) || radius <= 0 || radius > 64
|
||||
|| !Double.isFinite(verticalRange) || verticalRange < 0 || verticalRange > 64
|
||||
|| duration.isZero() || duration.isNegative() || duration.compareTo(Duration.ofDays(1)) > 0
|
||||
|| damageBonusPercent.signum() < 0 || damageBonusPercent.compareTo(BigDecimal.valueOf(100)) > 0
|
||||
|| knockbackResistancePercent.signum() < 0
|
||||
|| knockbackResistancePercent.compareTo(BigDecimal.valueOf(100)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid War Banner settings");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() {
|
||||
return new Settings(Duration.ofSeconds(45), 12, 8, 4, Duration.ofSeconds(10),
|
||||
BigDecimal.valueOf(25), BigDecimal.valueOf(50));
|
||||
}
|
||||
}
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
|
||||
boolean closed;
|
||||
Area(AbilityExecution execution, Location origin, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { execution.finish(); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
|
||||
visual();
|
||||
}
|
||||
boolean sourceCurrent() {
|
||||
return execution.stillCurrent() && sessions.active(source.getUniqueId())
|
||||
.filter(active -> active.equals(execution.input().session()) && sessions.current(active)).isPresent();
|
||||
}
|
||||
boolean eligible(Player player) {
|
||||
if (!sourceCurrent() || !inside(origin, player, captured.radius(), captured.verticalRange())
|
||||
|| !unobstructed(origin, player)) return false;
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
if (active == null) return false;
|
||||
if (player == source) return active.equals(execution.input().session());
|
||||
var sourceParty = parties.partyOf(source.getUniqueId()).orElse(null);
|
||||
var targetParty = parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
return sourceParty != null && targetParty != null && sourceParty.id().equals(targetParty.id());
|
||||
}
|
||||
Map<UUID, Player> candidates() {
|
||||
List<Entity> nearby;
|
||||
try { nearby = new ArrayList<>(origin.getWorld().getNearbyEntities(origin,
|
||||
captured.radius(), captured.verticalRange(), captured.radius())); }
|
||||
catch (RuntimeException unavailable) { nearby = new ArrayList<>(); }
|
||||
nearby.addFirst(source); var unique = new LinkedHashMap<UUID, Player>(); int inspected = 0;
|
||||
for (var entity : nearby) {
|
||||
if (++inspected > 256) break;
|
||||
if (entity instanceof Player player && eligible(player)) unique.putIfAbsent(player.getUniqueId(), player);
|
||||
}
|
||||
var result = new ArrayList<>(unique.values());
|
||||
result.sort(Comparator.comparingDouble((Player player) -> distance(origin, player)).thenComparing(Player::getUniqueId));
|
||||
if (result.size() > 64) result.subList(64, result.size()).clear();
|
||||
var ordered = new LinkedHashMap<UUID, Player>(); for (var player : result) ordered.put(player.getUniqueId(), player); return ordered;
|
||||
}
|
||||
void visual() {
|
||||
try { origin.getWorld().spawnParticle(Particle.DUST, origin.clone().add(0, 1.5, 0), 16,
|
||||
0.25, 1.5, 0.25, 0, new Particle.DustOptions(Color.RED, 1.5f)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
BooleanSupplier current = () -> !closed && area.eligible(player);
|
||||
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
|
||||
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(resistance);
|
||||
}
|
||||
}
|
||||
|
||||
private final class ResistanceGrant implements AutoCloseable {
|
||||
final ResistanceState state; final long sequence = ++resistanceSequences; final BigDecimal percent;
|
||||
final BooleanSupplier current; boolean closed;
|
||||
ResistanceGrant(ResistanceState state, BigDecimal percent, BooleanSupplier current) {
|
||||
this.state = state; this.percent = percent; this.current = current;
|
||||
}
|
||||
boolean valid() { try { return !closed && current.getAsBoolean(); } catch (RuntimeException invalid) { return false; } }
|
||||
@Override public void close() { if (closed) return; closed = true; state.grants.remove(this); state.refresh(); }
|
||||
}
|
||||
private final class ResistanceState {
|
||||
final Player player; final List<ResistanceGrant> grants = new ArrayList<>(); AttributeModifier owned;
|
||||
ResistanceState(Player player) { this.player = player; }
|
||||
void refresh() {
|
||||
AttributeInstance attribute;
|
||||
try { attribute = player.getAttribute(Attribute.KNOCKBACK_RESISTANCE); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (attribute == null) return;
|
||||
ResistanceGrant strongest = null;
|
||||
for (var grant : List.copyOf(grants)) if (grant.valid() && (strongest == null
|
||||
|| grant.percent.compareTo(strongest.percent) > 0
|
||||
|| grant.percent.compareTo(strongest.percent) == 0 && grant.sequence < strongest.sequence)) strongest = grant;
|
||||
double desired = strongest == null ? 0 : strongest.percent.movePointLeft(2).doubleValue();
|
||||
if (owned != null && (!attribute.getModifiers().contains(owned) || Double.compare(owned.getAmount(), desired) != 0)) {
|
||||
attribute.removeModifier(owned); owned = null;
|
||||
}
|
||||
if (strongest != null && owned == null) {
|
||||
var key = new NamespacedKey("fantasystick", "war_banner_" + player.getUniqueId());
|
||||
for (var modifier : List.copyOf(attribute.getModifiers())) if (modifier.getKey().equals(key)) attribute.removeModifier(modifier);
|
||||
owned = new AttributeModifier(key, desired, AttributeModifier.Operation.ADD_NUMBER); attribute.addModifier(owned);
|
||||
}
|
||||
if (strongest == null) { resistances.remove(player.getUniqueId(), this); owned = null; }
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
private final EquipmentSessions sessions; private final PartyRoster parties; private final OutgoingDamageBonus bonuses;
|
||||
private final Map<Long, Area> areas = new java.util.HashMap<>(); private final Map<UUID, ResistanceState> resistances = new java.util.HashMap<>();
|
||||
private long sequences; private long resistanceSequences; private org.bukkit.scheduler.BukkitTask sweepTask; private boolean closed;
|
||||
|
||||
public WarBanner(Settings settings, EquipmentSessions sessions, PartyRoster parties, OutgoingDamageBonus bonuses) {
|
||||
this.settings = Objects.requireNonNull(settings); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties); this.bonuses = Objects.requireNonNull(bonuses);
|
||||
}
|
||||
public void configure(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin); if (closed || sweepTask != null) throw new IllegalStateException("War Banner sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
public void sweep() {
|
||||
if (closed) return; int checked = 0;
|
||||
for (var area : List.copyOf(areas.values())) { area.refresh(); if (++checked == 64) break; }
|
||||
for (var state : List.copyOf(resistances.values())) state.refresh();
|
||||
}
|
||||
public AbilityDefinition definition() {
|
||||
var current = settings;
|
||||
return new AbilityDefinition("war_banner", "War Banner", Material.RED_BANNER,
|
||||
List.of("Plant a spectral banner within " + decimal(current.placementRange()) + " blocks for " + seconds(current.duration()) + " seconds.",
|
||||
"Party members in its " + decimal(current.radius()) + "-block area gain " + decimal(current.damageBonusPercent())
|
||||
+ "% combat damage and " + decimal(current.knockbackResistancePercent()) + "% knockback resistance."),
|
||||
"Use while looking at the solid top surface where the fixed banner should stand; copies do not stack.",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(22), Stat.CHR, BigInteger.valueOf(22), Stat.CON, BigInteger.valueOf(12)), BigInteger.ONE, AbilityDefinition.Category.BUF,
|
||||
AbilityDefinition.Activation.DELAYED, ControlInput.Gesture.USE, current.cooldown(),
|
||||
Map.of("placement-range", BigDecimal.valueOf(current.placementRange()).stripTrailingZeros(),
|
||||
"radius", BigDecimal.valueOf(current.radius()).stripTrailingZeros(),
|
||||
"vertical-range", BigDecimal.valueOf(current.verticalRange()).stripTrailingZeros(),
|
||||
"duration-seconds", nanos(current.duration()), "damage-bonus-percent", current.damageBonusPercent(),
|
||||
"knockback-resistance-percent", current.knockbackResistancePercent()), AbilityDefinition.TargetPolicy.AREA, this);
|
||||
}
|
||||
@Override public boolean accepts(ControlInput input) {
|
||||
return !closed && input.target() == null && placement(input.player(), settings.placementRange()) != null;
|
||||
}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
|
||||
var origin = placement(execution.input().player(), captured.placementRange());
|
||||
if (origin == null) { execution.finish(); return; }
|
||||
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
|
||||
area.refresh(); execution.later(captured.duration(), execution::finish);
|
||||
}
|
||||
|
||||
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
var state = resistances.computeIfAbsent(player.getUniqueId(), ignored -> new ResistanceState(player));
|
||||
var grant = new ResistanceGrant(state, percent, current); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private static Location placement(Player player, double range) {
|
||||
try { var eye = player.getEyeLocation(); var hit = player.getWorld().rayTraceBlocks(eye, eye.getDirection(), range,
|
||||
FluidCollisionMode.NEVER, true);
|
||||
if (hit == null || hit.getHitBlock() == null || hit.getHitBlockFace() != BlockFace.UP
|
||||
|| !hit.getHitBlock().getType().isSolid()) return null;
|
||||
return hit.getHitBlock().getLocation().add(0.5, 1, 0.5);
|
||||
} catch (RuntimeException invalid) { return null; }
|
||||
}
|
||||
private static boolean inside(Location origin, Player player, double radius, double vertical) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return false;
|
||||
double dx=at.getX()-origin.getX(), dz=at.getZ()-origin.getZ();
|
||||
return dx*dx+dz*dz<=radius*radius && Math.abs(at.getY()-origin.getY())<=vertical;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static boolean unobstructed(Location origin, Player player) {
|
||||
try { var world=origin.getWorld(); if(world==null)return false;var offset=player.getBoundingBox().getCenter().subtract(origin.toVector());
|
||||
double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;
|
||||
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
|
||||
} catch(RuntimeException invalid){return false;}
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
|
||||
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
|
||||
} catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}
|
||||
}
|
||||
private static Settings from(AbilityDefinition definition) { var p=definition.parameters();return new Settings(definition.cooldown(),
|
||||
p.get("placement-range").doubleValue(),p.get("radius").doubleValue(),p.get("vertical-range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-bonus-percent"),p.get("knockback-resistance-percent")); }
|
||||
private static BigDecimal nanos(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9);}
|
||||
private static String seconds(Duration value){return decimal(nanos(value));}
|
||||
private static String decimal(double value){return decimal(BigDecimal.valueOf(value));}
|
||||
private static String decimal(BigDecimal value){return value.stripTrailingZeros().toPlainString();}
|
||||
private static void closeResource(AutoCloseable resource){try{resource.close();}catch(Exception impossible){throw new IllegalStateException(impossible);}}
|
||||
@Override public void close(){if(closed)return;closed=true;if(sweepTask!=null)sweepTask.cancel();for(var area:List.copyOf(areas.values()))area.execution.finish();areas.clear();for(var state:List.copyOf(resistances.values())){for(var grant:List.copyOf(state.grants))grant.close();}resistances.clear();}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Shared asynchronous, revalidated administrator teleport for durable adventure locations. */
|
||||
public final class AdventureAdminTeleport {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AdventureLocationPresentation locations;
|
||||
|
||||
public AdventureAdminTeleport(Plugin plugin,Consumer<Runnable> nextTick){
|
||||
this.server=Objects.requireNonNull(plugin).getServer();
|
||||
this.nextTick=Objects.requireNonNull(nextTick);
|
||||
this.locations=new AdventureLocationPresentation(server);
|
||||
}
|
||||
|
||||
public record Target(UUID id,String name,String type,UUID world,double x,double y,double z,float yaw,float pitch,boolean preserveFacing){
|
||||
public Target{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(type);Objects.requireNonNull(world);}
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current){
|
||||
Objects.requireNonNull(sender);Objects.requireNonNull(route);Objects.requireNonNull(expected);Objects.requireNonNull(current);
|
||||
if(!(sender instanceof Player player)){sender.sendMessage(route+" teleport requires a player.");return;}
|
||||
World world=server.getWorld(expected.world());
|
||||
if(world==null){sender.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
CompletableFuture<?> loading;
|
||||
try{loading=world.getChunkAtAsync(section(expected.x()),section(expected.z()));}
|
||||
catch(RuntimeException failure){sender.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
loading.whenComplete((chunk,loadFailure)->schedule(()->{
|
||||
if(loadFailure!=null){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(!currentPlayer(player)){player.sendMessage(route+" teleport refused: permission or player session changed.");return;}
|
||||
var fresh=current.get();
|
||||
if(fresh.isEmpty()||!fresh.orElseThrow().equals(expected)){
|
||||
player.sendMessage(route+" teleport refused: destination changed or is no longer registered.");return;
|
||||
}
|
||||
World currentWorld=server.getWorld(expected.world());
|
||||
if(currentWorld==null||currentWorld!=world){player.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
var facing=player.getLocation();
|
||||
var destination=new Location(world,expected.x(),expected.y(),expected.z(),
|
||||
expected.preserveFacing()?facing.getYaw():expected.yaw(),expected.preserveFacing()?facing.getPitch():expected.pitch());
|
||||
CompletableFuture<Boolean> moving;
|
||||
try{moving=player.teleportAsync(destination);}
|
||||
catch(RuntimeException failure){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
moving.whenComplete((moved,moveFailure)->schedule(()->{
|
||||
if(moveFailure!=null||!Boolean.TRUE.equals(moved)){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(player.isOnline())player.sendMessage("Teleported to "+expected.name()+" ("+expected.type()+") at "+locations.format(expected.world(),expected.x(),expected.y(),expected.z())+".");
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean currentPlayer(Player player){return player.isOnline()&&player.hasPermission(PERMISSION)&&server.getPlayer(player.getUniqueId())==player;}
|
||||
private void schedule(Runnable task){try{nextTick.accept(task);}catch(RuntimeException ignored){}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
|
||||
/** Readable presentation for authoritative UUID-backed adventure locations. */
|
||||
public final class AdventureLocationPresentation {
|
||||
private final Server server;
|
||||
|
||||
public AdventureLocationPresentation(Server server) {
|
||||
this.server = Objects.requireNonNull(server);
|
||||
}
|
||||
|
||||
public String format(UUID worldId, double x, double y, double z) {
|
||||
Objects.requireNonNull(worldId);
|
||||
World world = server.getWorld(worldId);
|
||||
String worldName = world == null || world.getName() == null || world.getName().isBlank()
|
||||
? "unavailable world" : world.getName();
|
||||
return worldName + " (" + block(x) + ", " + block(y) + ", " + block(z) + ")";
|
||||
}
|
||||
|
||||
private static int block(double coordinate) {
|
||||
return (int) Math.floor(coordinate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Registered command adapter for player inspection and acknowledged adventure administration. */
|
||||
public final class AdventureQuestCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestController controller;private final Consumer<Runnable> nextTick;private final AdventureLocationPresentation locations;private final AdventureAdminTeleport teleports;
|
||||
public AdventureQuestCommands(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestController controller,Consumer<Runnable> nextTick){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.nextTick=Objects.requireNonNull(nextTick);locations=new AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void adventure(CommandSender sender,String[] args){if(args.length==1){if(sender instanceof Player player)controller.inspect(player);else sender.sendMessage("Usage: /fantasystick adventure");return;}if(args.length==2&&args[1].equals("tracking")){if(sender instanceof Player player)controller.tracking(player,null);else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==3&&args[1].equals("tracking")&&(args[2].equals("on")||args[2].equals("off"))){if(sender instanceof Player player)controller.tracking(player,args[2].equals("on"));else sender.sendMessage("Quest tracking requires a player.");return;}if(args.length==2&&args[1].equals("reroll")){if(!authorized(sender))return;sender.sendMessage("Reroll expires every current adventure quest, encounter and reward claim. Run /fantasystick adventure reroll confirm to reset the day.");return;}if(args.length!=3||!args[1].equals("reroll")||!args[2].equals("confirm")){sender.sendMessage("Usage: /fantasystick adventure [tracking [on|off]|reroll confirm]");return;}if(!authorized(sender))return;var current=directory.snapshot().catalog();UUID expected=current==null?null:current.revision();directory.rerollToday(expected).whenComplete((result,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Adventure reset failed; the current generation is unchanged.");return;}switch(result.status()){case STALE->sender.sendMessage("Adventure reset refused: the generation changed or is still being published; inspect it and try again.");case APPLIED->{var catalog=result.catalog().orElseThrow();controller.publishPendingReset().whenComplete((published,publishFailure)->respond(sender,()->{if(publishFailure!=null||!published){sender.sendMessage("Adventure reset is saved but pending recovery; the new generation is not yet available.");return;}sender.sendMessage("Reset daily adventures: generation="+catalog.revision()+", offers="+catalog.sites().size()+", expires="+catalog.expiresAt()+".");}));}}}));}
|
||||
public void site(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageSite(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageSite(sender);return;}if(directory.snapshot().sites().stream().noneMatch(v->v.type()!=AdventureQuestState.SiteType.DUNGEON))sender.sendMessage("No adventure sites registered.");else directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.type().name().toLowerCase(Locale.ROOT)+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())+"; "+controller.arena(v).display()));}case"place"->{if(args.length<3||!(args[2].equals("camp")||args[2].equals("champion"))||!(sender instanceof Player player)){usageSite(sender);return;}var placement=placement(args);if(placement==null){usageSite(sender);return;}var location=player.getLocation();var type=args[2].equals("camp")?AdventureQuestState.SiteType.CAMP:AdventureQuestState.SiteType.CHAMPION;directory.placeSite(type,placement.name(),Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),placement.arena()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Placed "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+"; "+controller.arena(value).display()+".":"Adventure site save failed; desired state is unchanged.")));}case"remove"->{if(args.length!=3){usageSite(sender);return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}directory.removeSite(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Adventure site removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Adventure site not found.");else{controller.removeSiteLoaded(id);sender.sendMessage("Removed adventure site "+id+"; its quests expired.");}}));}case"teleport"->{if(args.length!=3){usageSite(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Adventure-site teleport requires a player.");return;}UUID id=parse(args[2]);if(id==null){usageSite(sender);return;}var site=site(id).orElse(null);if(site==null){sender.sendMessage("Adventure site not found.");return;}teleports.teleport(sender,"Adventure-site",target(site),()->site(id).map(AdventureQuestCommands::target));}default->usageSite(sender);}}
|
||||
public void giver(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usageGiver(sender);return;}switch(args[1]){case"list"->{if(args.length!=2){usageGiver(sender);return;}if(directory.snapshot().givers().isEmpty())sender.sendMessage("No Quest Givers registered.");else directory.snapshot().givers().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" at "+locations.format(v.world(),v.x(),v.y(),v.z())));}case"place"->{if(!(sender instanceof Player player)){usageGiver(sender);return;}String name=join(args,2);var location=player.getLocation();directory.placeGiver(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Quest Giver save failed; desired state is unchanged.");return;}var world=plugin.getServer().getWorld(value.world());if(world!=null&&world.isChunkLoaded(section(value.x()),section(value.z())))controller.reconcile(world.getChunkAt(section(value.x()),section(value.z())));sender.sendMessage("Placed Quest Giver "+value.id()+" ("+value.displayName()+") at "+locations.format(value.world(),value.x(),value.y(),value.z())+".");}));}case"remove"->{if(args.length!=3){usageGiver(sender);return;}UUID id=parse(args[2]);if(id==null){usageGiver(sender);return;}directory.removeGiver(id).whenComplete((removed,failure)->respond(sender,()->{if(failure!=null)sender.sendMessage("Quest Giver removal failed; desired state is unchanged.");else if(removed.isEmpty())sender.sendMessage("Quest Giver not found.");else{controller.removeGiverLoaded(id);sender.sendMessage("Removed Quest Giver "+id+".");}}));}default->usageGiver(sender);}}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(args.length==1){var values=new ArrayList<String>();values.add("adventure");if(sender.hasPermission(PERMISSION)){values.add("adventure-site");values.add("quest-giver");}return matching(values,args[0]);}if(args.length==2&&args[0].equals("adventure")){var values=new ArrayList<String>();if(sender instanceof Player player&&controller.trackingAvailable(player))values.add("tracking");if(sender.hasPermission(PERMISSION))values.add("reroll");return matching(values,args[1]);}if(args.length==3&&args[0].equals("adventure")&&args[1].equals("tracking")&&sender instanceof Player player&&controller.trackingAvailable(player))return matching(List.of("on","off"),args[2]);if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==3&&args[0].equals("adventure")&&args[1].equals("reroll"))return matching(List.of("confirm"),args[2]);if(args.length==2&&args[0].equals("adventure-site"))return matching(sender instanceof Player?List.of("list","place","remove","teleport"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("adventure-site")&&args[1].equals("place"))return matching(List.of("camp","champion"),args[2]);if(args.length==4&&args[0].equals("adventure-site")&&args[1].equals("place")&&(args[2].equals("camp")||args[2].equals("champion")))return matching(List.of("4","16","64"),args[3]);if(args.length==5&&args[0].equals("adventure-site")&&args[1].equals("place")&&whole(args[3])!=null)return matching(List.of("8","16","128"),args[4]);if(args.length==3&&args[0].equals("adventure-site")&&Set.of("remove","teleport").contains(args[1]))return matching(directory.snapshot().sites().stream().filter(v->v.type()!=AdventureQuestState.SiteType.DUNGEON).map(v->v.id().toString()).toList(),args[2]);if(args.length==2&&args[0].equals("quest-giver"))return matching(sender instanceof Player?List.of("list","place","remove"):List.of("list","remove"),args[1]);if(args.length==3&&args[0].equals("quest-giver")&&args[1].equals("remove"))return matching(directory.snapshot().givers().stream().map(v->v.id().toString()).toList(),args[2]);return List.of();}
|
||||
private Optional<AdventureQuestState.Site> site(UUID id){return directory.findSite(id).filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON);}
|
||||
private static AdventureAdminTeleport.Target target(AdventureQuestState.Site site){return new AdventureAdminTeleport.Target(site.id(),site.displayName(),site.type()==AdventureQuestState.SiteType.CAMP?"monster camp":"champion",site.world(),site.x(),site.y(),site.z(),0,0,true);}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private record Placement(String name,AdventureQuestState.Arena arena){}
|
||||
private static Placement placement(String[] args){if(args.length==3)return new Placement("",null);Integer first=whole(args[3]);if(first==null)return looksNumeric(args[3])?null:new Placement(join(args,3),null);if(args.length>4){Integer second=whole(args[4]);if(second!=null)return first>=8&&first<=128&&second>=8&&second<=128?new Placement(join(args,5),AdventureQuestState.Arena.rectangle(first,second)):null;if(looksNumeric(args[4]))return null;}return first>=4&&first<=64?new Placement(join(args,4),AdventureQuestState.Arena.circle(first)):null;}
|
||||
private static Integer whole(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static boolean looksNumeric(String value){return value.matches("[+-]?(?:\\d.*|\\.\\d+)");}
|
||||
private static String join(String[] args,int start){return start>=args.length?"":String.join(" ",Arrays.copyOfRange(args,start,args.length));}
|
||||
private static UUID parse(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(v->v.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usageSite(CommandSender sender){sender.sendMessage("Usage: /fantasystick adventure-site <place <camp|champion> [<radius>|<x-blocks> <z-blocks>] [name...]|remove <site-uuid>|teleport <site-uuid>|list>");}
|
||||
private static void usageGiver(CommandSender sender){sender.sendMessage("Usage: /fantasystick quest-giver <place [name...]|remove <giver-uuid>|list>");}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.RewardAllocator;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.boss.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.entity.PlayerLeashEntityEvent;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Native Quest Givers, shared camp/champion attempts, eligibility containment and idempotent completion rewards. */
|
||||
public final class AdventureQuestController implements Listener,AutoCloseable {
|
||||
private static final String GIVER="quest_giver",ENEMY="adventure_enemy",PROJECTILE="adventure_projectile";
|
||||
private final Plugin plugin;private final AdventureQuestDirectory directory;private final AdventureQuestSettings settings;
|
||||
private final AdventureQuestNavigation navigation;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;
|
||||
private final Consumer<Runnable> nextTick;private final Clock clock;private final java.util.random.RandomGenerator random;
|
||||
private final NamespacedKey typeKey,idKey,siteKey,catalogKey,menuSiteKey,menuCatalogKey,menuPageKey;
|
||||
private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Map<UUID,Attempt> attempts=new HashMap<>();private final Map<UUID,Instant> readyAt=new HashMap<>();private final Set<UUID> starting=new HashSet<>();
|
||||
private final List<AdventureResetParticipant> resetParticipants=new ArrayList<>();private CompletableFuture<Boolean> resetWork;
|
||||
private java.util.function.BiPredicate<UUID,Location> dungeonRoomPresence=(ignored,location)->false;
|
||||
private org.bukkit.scheduler.BukkitTask pulseTask;private boolean closed;
|
||||
private static final class MenuHolder implements InventoryHolder {final UUID catalog;final int page;MenuHolder(UUID catalog,int page){this.catalog=catalog;this.page=page;}@Override public Inventory getInventory(){return null;}}
|
||||
private static final class Attempt {final UUID id=UUID.randomUUID(),site,catalog;final AdventureQuestState.SiteType type;final Set<UUID> entities=new HashSet<>(),represented=new HashSet<>();final AdventureQuestState.Arena arena;final int resetSeconds;BossBar bossBar;LivingEntity champion;Instant absentSince;Attempt(UUID site,UUID catalog,AdventureQuestState.SiteType type,AdventureQuestState.Arena arena,int resetSeconds){this.site=site;this.catalog=catalog;this.type=type;this.arena=arena;this.resetSeconds=resetSeconds;}}
|
||||
public AdventureQuestController(Plugin plugin,AdventureQuestDirectory directory,AdventureQuestSettings settings,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,Clock clock,java.util.random.RandomGenerator random){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.settings=Objects.requireNonNull(settings);navigation=new AdventureQuestNavigation(plugin.getServer());this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.clock=Objects.requireNonNull(clock);this.random=Objects.requireNonNull(random);typeKey=new NamespacedKey(plugin,"managed_npc_type");idKey=new NamespacedKey(plugin,"quest_giver_id");siteKey=new NamespacedKey(plugin,"adventure_site_id");catalogKey=new NamespacedKey(plugin,"adventure_catalog_id");menuSiteKey=new NamespacedKey(plugin,"quest_offer_site");menuCatalogKey=new NamespacedKey(plugin,"quest_offer_catalog");menuPageKey=new NamespacedKey(plugin,"quest_offer_page");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}pulseTask=plugin.getServer().getScheduler().runTaskTimer(plugin,this::pulse,20,20);publishPendingReset();}
|
||||
public boolean giver(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&GIVER.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean enemy(Entity entity){var data=entity.getPersistentDataContainer();return data!=null&&ENEMY.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();return enemy(entity)||data!=null&&PROJECTILE.equals(data.get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,AdventureQuestState.Giver>();for(var giver:directory.snapshot().givers())if(giver.world().equals(chunk.getWorld().getUID())&§ion(giver.x())==chunk.getX()&§ion(giver.z())==chunk.getZ())desired.put(giver.id(),giver);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(managed(entity)){entity.remove();continue;}if(!giver(entity))continue;UUID id=id(entity,idKey);var value=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||value==null||!retained.add(id)){entity.remove();continue;}configure(villager,value);var expected=location(value);var current=villager.getLocation();if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawnGiver(value);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawnGiver(value);}
|
||||
public void removeGiverLoaded(UUID id){for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)&&id.equals(id(entity,idKey)))entity.remove();}
|
||||
public void removeSiteLoaded(UUID id){var attempt=attempts.remove(id);if(attempt!=null)cleanup(attempt);readyAt.remove(id);refreshTrackingPlayers();}
|
||||
public boolean hasActiveEncounter(){return !attempts.isEmpty()||!starting.isEmpty();}
|
||||
public AdventureQuestState.Arena arena(AdventureQuestState.Site site){return site.arenaOrDefault(settings.rules().radius());}
|
||||
public void registerResetParticipant(AdventureResetParticipant participant){if(closed)throw new IllegalStateException("Adventure controller closed");resetParticipants.add(Objects.requireNonNull(participant));}
|
||||
public void registerDungeonRoomPresence(java.util.function.BiPredicate<UUID,Location> presence){if(closed)throw new IllegalStateException("Adventure controller closed");dungeonRoomPresence=Objects.requireNonNull(presence);}
|
||||
public CompletionStage<Boolean> publishPendingReset(){var reset=directory.snapshot().reset();if(reset==null)return CompletableFuture.completedFuture(true);if(resetWork!=null)return resetWork;for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();starting.clear();readyAt.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();refreshTrackingPlayers();var work=resetParticipants.stream().map(participant->{try{return participant.reset(reset).toCompletableFuture();}catch(RuntimeException failure){return CompletableFuture.<Void>failedFuture(failure);}}).toArray(CompletableFuture[]::new);resetWork=CompletableFuture.allOf(work).thenCompose(ignored->directory.completeReset(reset.operation())).whenComplete((ignored,failure)->resetWork=null);return resetWork;}
|
||||
private void spawnGiver(AdventureQuestState.Giver value){var world=plugin.getServer().getWorld(value.world());if(world==null||!world.isChunkLoaded(section(value.x()),section(value.z())))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,AdventureQuestState.Giver value){var data=villager.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,GIVER);data.set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.displayName()));villager.setCustomNameVisible(true);}
|
||||
public void open(Player player){open(player,0);}
|
||||
private void open(Player player,int requestedPage){var context=context(player);if(context==null){player.sendMessage("Quest Giver refused: actively play a living fantasy character first.");return;}var pending=directory.pendingClaim(context.character);if(pending.isPresent()){var record=sessions.currentRecord(context.active).orElse(null);if(record==null){player.sendMessage("Reward claim recovery requires the exact active character.");return;}attemptClaim(player,context.active,pending.orElseThrow(),record);return;}directory.ensureToday().whenComplete((catalog,failure)->schedule(()->{var currentContext=context(player);if(failure!=null||currentContext==null||!currentContext.active.equals(context.active)){player.sendMessage("Daily quests are unavailable.");return;}if(directory.snapshot().reset()!=null){publishPendingReset();player.sendMessage("Daily adventure reset is completing; try again.");return;}int pages=Math.max(1,(catalog.sites().size()+44)/45),page=Math.max(0,Math.min(requestedPage,pages-1));int count=Math.min(45,Math.max(0,catalog.sites().size()-page*45));int size=pages>1?54:Math.max(9,((count+8)/9)*9);var inventory=plugin.getServer().createInventory(new MenuHolder(catalog.revision(),page),size,net.kyori.adventure.text.Component.text("Daily Adventure Quests"));int slot=0;for(UUID siteId:catalog.sites().stream().skip(page*45L).limit(45).toList()){var site=directory.findSite(siteId).orElse(null);var item=new ItemStack(Material.PAPER);var meta=item.getItemMeta();String name=site==null?"Unavailable site":site.displayName();meta.displayName(net.kyori.adventure.text.Component.text(name));var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog.revision(),siteId,context.character);boolean rewardsReady=unclaimed.xp().signum()>0||unclaimed.coins().signum()>0;String state=rewardsReady?"Rewards ready — click to claim":active!=null&&active.site().equals(siteId)?"Active — right-click to abandon":active!=null?"Another quest is active":directory.characterClaimed(catalog.revision(),siteId,context.character)?directory.rewardClaimed(catalog.revision(),siteId,context.character)?"Already claimed":"Completed with nothing owed":site==null||!directory.offerOpen(siteId)||!directory.available(site)?"Unavailable / cleared":"Available — click to accept";String kind=site==null?"Unavailable":switch(site.type()){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon (maximum)";};var reward=site==null?new AdventureQuestDirectory.Reward(BigInteger.ZERO,BigInteger.ZERO):directory.reward(site);var rewardXp=rewardsReady?unclaimed.xp():active!=null&&active.site().equals(siteId)?active.rewardXp():reward.xp();var rewardCoins=rewardsReady?unclaimed.coins():active!=null&&active.site().equals(siteId)?active.rewardCoins():reward.coins();String rewardLabel=rewardsReady?"Banked reward: ":site!=null&&site.type()==AdventureQuestState.SiteType.DUNGEON?"Maximum reward: ":"Reward: ";meta.lore(List.of(net.kyori.adventure.text.Component.text(kind),net.kyori.adventure.text.Component.text(rewardLabel+rewardXp+" XP, "+rewardCoins+" coins"),net.kyori.adventure.text.Component.text("Expires: "+catalog.expiresAt()),net.kyori.adventure.text.Component.text(state)));meta.getPersistentDataContainer().set(menuSiteKey,PersistentDataType.STRING,siteId.toString());meta.getPersistentDataContainer().set(menuCatalogKey,PersistentDataType.STRING,catalog.revision().toString());item.setItemMeta(meta);inventory.setItem(slot++,item);}if(pages>1){if(page>0)inventory.setItem(45,pageItem(page-1,"Previous page"));if(page+1<pages)inventory.setItem(53,pageItem(page+1,"Next page"));}player.openInventory(inventory);}));}
|
||||
private ItemStack pageItem(int page,String name){var item=new ItemStack(Material.ARROW);var meta=item.getItemMeta();meta.displayName(net.kyori.adventure.text.Component.text(name));meta.getPersistentDataContainer().set(menuPageKey,PersistentDataType.INTEGER,page);item.setItemMeta(meta);return item;}
|
||||
private record Context(EquipmentSessions.Active active,UUID character,UUID partyId,boolean leader,Set<UUID> members){}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null)return new Context(active,record.id(),null,true,Set.of(record.id()));return new Context(active,record.id(),party.id(),party.leaderCharacter().equals(record.id()),Set.copyOf(party.characters()));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(PlayerInteractEntityEvent event){if(!giver(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked(),idKey);if(id!=null&&directory.findGiver(id).isPresent())open(event.getPlayer());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof MenuHolder holder))return;event.setCancelled(true);if(!(event.getWhoClicked() instanceof Player player)||event.getCurrentItem()==null)return;var data=event.getCurrentItem().getPersistentDataContainer();Integer page=data.get(menuPageKey,PersistentDataType.INTEGER);if(page!=null){open(player,page);return;}UUID site=parse(data.get(menuSiteKey,PersistentDataType.STRING)),catalog=parse(data.get(menuCatalogKey,PersistentDataType.STRING));if(site==null||catalog==null||!holder.catalog.equals(catalog))return;var context=context(player);if(context==null){player.sendMessage("Quest action refused: stale fantasy session.");return;}var active=directory.activeFor(context.character,context.partyId).orElse(null);var unclaimed=directory.unclaimed(catalog,site,context.character);if(unclaimed.xp().signum()>0||unclaimed.coins().signum()>0){claim(player,context,catalog,site);return;}if(active!=null){if(!active.site().equals(site)){player.sendMessage("Quest action refused: another quest is active.");return;}if(!event.isRightClick()){player.sendMessage("This quest is active; right-click its Paper to abandon it.");return;}directory.abandon(active.id(),active.unit()).whenComplete((changed,failure)->schedule(()->{refreshTracking(player);player.sendMessage(failure==null&&changed?"Adventure quest abandoned.":"Quest abandonment failed; retained state is unchanged.");open(player);}));return;}if(directory.characterClaimed(catalog,site,context.character)){player.sendMessage(directory.rewardClaimed(catalog,site,context.character)?"Adventure rewards were already claimed.":"Adventure complete with no reward owed.");return;}if(!directory.offerOpen(site)||directory.findSite(site).filter(directory::available).isEmpty()){player.sendMessage("Quest action refused: this offer is unavailable.");return;}if(context.partyId!=null&&!context.leader){player.sendMessage("Only the active party leader may accept a party quest.");return;}var type=context.partyId==null?AdventureQuestState.UnitType.SOLO:AdventureQuestState.UnitType.PARTY;var unit=context.partyId==null?context.character:context.partyId;directory.accept(catalog,site,type,unit,context.members).whenComplete((result,failure)->schedule(()->{if(failure!=null){player.sendMessage("Quest acceptance failed; retained state is unchanged.");return;}if(result.status()!=AdventureQuestDirectory.Acceptance.APPLIED){player.sendMessage("Quest acceptance refused: "+result.status().name().toLowerCase(Locale.ROOT)+".");return;}var location=directory.findSite(site).orElseThrow();refreshTracking(player);player.sendMessage("Accepted "+location.displayName()+"; expires "+result.quest().orElseThrow().expiresAt()+". Follow the Quest Compass.");}));}
|
||||
private void claim(Player player,Context expected,UUID catalog,UUID site){directory.beginClaim(catalog,site,expected.character).whenComplete((operation,failure)->schedule(()->{if(failure!=null||operation.isEmpty()){player.sendMessage("Reward claim failed; retained rewards are unchanged.");return;}var current=context(player);if(current==null||!current.active.equals(expected.active)||!current.character.equals(operation.orElseThrow().character())){player.sendMessage("Reward claim paused: actively play the exact living character and try again.");return;}var record=sessions.currentRecord(current.active).orElse(null);if(record==null){player.sendMessage("Reward claim paused: the current character changed.");return;}attemptClaim(player,current.active,operation.orElseThrow(),record);}));}
|
||||
private void attemptClaim(Player player,EquipmentSessions.Active active,AdventureQuestState.RewardClaim operation,games.dmg.fantasystick.persistence.CharacterRecord record){characters.awardCombatReward(record.id(),record.revision(),operation.id(),operation.xp(),operation.coins(),progression,games.dmg.fantasystick.progression.CombatRewardRules.defaults(),null).whenComplete((award,failure)->{if(failure!=null){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}if(award.status()==CharacterStorage.CombatAwardStatus.STALE&&award.record().isPresent()){attemptClaim(player,active,operation,award.record().orElseThrow());return;}if(award.record().isEmpty()){schedule(()->player.sendMessage("Reward claim failed; retained rewards are unchanged."));return;}var saved=award.record().orElseThrow();directory.finishClaim(operation.id()).whenComplete((finished,finishFailure)->schedule(()->{if(finishFailure!=null||!finished){player.sendMessage("Reward claim is pending recovery; retry at a Quest Giver.");return;}var holder=sessions.activeCharacter(saved.id()).filter(sessions::current).orElse(null);if(award.status()!=CharacterStorage.CombatAwardStatus.DUPLICATE&&holder!=null)sessions.acknowledgeProgress(holder,saved.revision().subtract(BigInteger.ONE),saved);var currentPlayer=holder==null?null:plugin.getServer().getPlayer(holder.player());var current=currentPlayer==null?null:context(currentPlayer);if(current!=null&¤t.character.equals(operation.character())){currentPlayer.sendMessage("Claimed "+operation.xp()+" fantasy XP and "+operation.coins()+" character coins.");open(currentPlayer);}}));});}
|
||||
public boolean trackingAvailable(Player player){var context=context(player);return context!=null&&directory.activeFor(context.character,context.partyId).filter(quest->quest.snapshot().contains(context.character)).isPresent();}
|
||||
public void tracking(Player player,Boolean enabled){var context=context(player);if(context==null){player.sendMessage("Quest tracking refused: actively play a living fantasy character first.");return;}var quest=directory.activeFor(context.character,context.partyId).filter(value->value.snapshot().contains(context.character)).orElse(null);if(quest==null){player.sendMessage("Quest tracking refused: this character has no eligible active quest.");refreshTracking(player);return;}if(enabled==null){player.sendMessage("Quest tracking is "+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+" for the current adventure quest.");return;}directory.setTracking(quest.id(),context.character,enabled).whenComplete((status,failure)->schedule(()->{if(failure!=null||status==AdventureQuestDirectory.TrackingStatus.INELIGIBLE){player.sendMessage("Quest tracking change failed; retained state is unchanged.");return;}refreshTracking(player);player.sendMessage("Quest tracking is now "+(enabled?"on.":"off."));}));}
|
||||
public void inspect(Player player){var context=context(player);if(context==null){player.sendMessage("No active fantasy character.");return;}var quest=directory.activeFor(context.character,context.partyId).orElse(null);if(quest==null){player.sendMessage("No active adventure quest.");return;}var site=directory.findSite(quest.site()).orElse(null);if(site==null){player.sendMessage("Active quest site is unavailable.");return;}player.sendMessage(site.displayName()+"; state="+(attempts.containsKey(site.id())?"active":"waiting")+"; expires "+quest.expiresAt()+"; Quest Compass guidance="+(directory.trackingEnabled(quest.id(),context.character)?"on":"off")+".");}
|
||||
private void pulse(){if(closed)return;directory.ensureToday().whenComplete((catalog,failure)->{if(failure==null)schedule(()->{if(directory.snapshot().reset()!=null)publishPendingReset();});});if(directory.snapshot().reset()!=null){publishPendingReset();return;}directory.expireParties(parties::exists);refreshTrackingPlayers(true);showArenaBoundaries();for(var attempt:new ArrayList<>(attempts.values())){var catalog=directory.snapshot().catalog();if(catalog==null||!catalog.revision().equals(attempt.catalog)||!clock.instant().isBefore(catalog.expiresAt())){cleanup(attempt);attempts.remove(attempt.site);readyAt.remove(attempt.site);continue;}contain(attempt);syncBossBar(attempt);boolean present=eligibleInside(attempt.site,attempt.arena).findAny().isPresent();if(present)attempt.absentSince=null;else if(attempt.absentSince==null)attempt.absentSince=clock.instant();else if(!clock.instant().isBefore(attempt.absentSince.plusSeconds(attempt.resetSeconds))){cleanup(attempt);attempts.remove(attempt.site);readyAt.put(attempt.site,clock.instant());}}for(var quest:directory.active()){if(directory.findSite(quest.site()).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON)continue;if(attempts.containsKey(quest.site())||starting.contains(quest.site()))continue;var ready=readyAt.get(quest.site());if(ready!=null&&clock.instant().isBefore(ready))continue;if(eligibleInside(quest.site()).findAny().isPresent())start(quest.site(),quest.catalog());}}
|
||||
private void showArenaBoundaries(){for(UUID siteId:directory.active().stream().map(AdventureQuestState.Quest::site).distinct().toList()){var site=directory.findSite(siteId).orElse(null);if(site==null||site.type()==AdventureQuestState.SiteType.DUNGEON)continue;var world=plugin.getServer().getWorld(site.world());if(world==null)continue;var center=new Location(world,site.x(),site.y(),site.z());var points=perimeter(arena(site));for(var player:plugin.getServer().getOnlinePlayers()){if(eligible(player,siteId)==null)continue;var viewer=player.getLocation();if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world())||viewer.distanceSquared(center)>64d*64d)continue;for(var point:points)player.spawnParticle(Particle.END_ROD,new Location(world,site.x()+point[0],site.y()+0.2,site.z()+point[1]),1,0,0,0,0);}}}
|
||||
private static List<double[]> perimeter(AdventureQuestState.Arena arena){var result=new ArrayList<double[]>();if(arena.shape()==AdventureQuestState.ArenaShape.CIRCLE){int count=Math.min(256,Math.max(16,(int)Math.ceil(Math.PI*arena.xBlocks())));for(int index=0;index<count;index++){double angle=2*Math.PI*index/count;result.add(new double[]{Math.cos(angle)*arena.xBlocks(),Math.sin(angle)*arena.xBlocks()});}}else{double halfX=arena.xBlocks()/2d,halfZ=arena.zBlocks()/2d;edge(result,-halfX,halfX,-halfZ,true);edge(result,-halfX,halfX,halfZ,true);edge(result,-halfZ,halfZ,-halfX,false);edge(result,-halfZ,halfZ,halfX,false);}return result.size()<=256?List.copyOf(result):List.copyOf(result.subList(0,256));}
|
||||
private static void edge(List<double[]> result,double from,double to,double fixed,boolean xAxis){for(double value=from;value<to;value+=2)result.add(xAxis?new double[]{value,fixed}:new double[]{fixed,value});result.add(xAxis?new double[]{to,fixed}:new double[]{fixed,to});}
|
||||
private void contain(Attempt attempt){var site=directory.findSite(attempt.site).orElse(null);if(site==null)return;var world=plugin.getServer().getWorld(site.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity))&&!inside(entity.getLocation(),site,attempt.arena)){relocating.add(entity);try{entity.teleport(new Location(world,site.x(),site.y(),site.z()));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site){var value=directory.findSite(site).orElse(null);return value==null?java.util.stream.Stream.empty():eligibleInside(site,arena(value));}
|
||||
private java.util.stream.Stream<Player> eligibleInside(UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return java.util.stream.Stream.empty();return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,site)!=null).filter(player->inside(player.getLocation(),value,arena));}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID site){var context=context(player);if(context==null)return null;var quest=directory.activeFor(context.character,context.partyId).orElse(null);return quest!=null&&quest.site().equals(site)&&quest.snapshot().contains(context.character)?quest:null;}
|
||||
private void start(UUID site,UUID catalog){var value=directory.findSite(site).orElse(null);if(value==null||value.type()==AdventureQuestState.SiteType.DUNGEON)return;if(value.type()==AdventureQuestState.SiteType.CHAMPION){startChampion(value,catalog);return;}starting.add(site);directory.roster(site).whenComplete((roster,failure)->schedule(()->{starting.remove(site);if(failure!=null||closed||attempts.containsKey(site))return;var current=directory.findSite(site).orElse(null);if(current==null)return;var world=plugin.getServer().getWorld(current.world());if(world==null||!world.isChunkLoaded(section(current.x()),section(current.z())))return;var rules=settings.rules();var attempt=new Attempt(site,catalog,current.type(),arena(current),rules.resetSeconds());attempts.put(site,attempt);for(int index=0;index<roster.size();index++)spawnEnemy(world,current,roster.get(index),attempt,index);}));}
|
||||
private void startChampion(AdventureQuestState.Site site,UUID catalog){if(closed||attempts.containsKey(site.id())||starting.contains(site.id()))return;var world=plugin.getServer().getWorld(site.world());if(world==null||!world.isChunkLoaded(section(site.x()),section(site.z())))return;starting.add(site.id());try{var rules=settings.rules();var attempt=new Attempt(site.id(),catalog,site.type(),arena(site),rules.resetSeconds());attempt.bossBar=plugin.getServer().createBossBar("Daybreak Champion",BarColor.RED,BarStyle.SOLID);attempts.put(site.id(),attempt);world.spawn(new Location(world,site.x(),site.y(),site.z()),Vindicator.class,champion->{configureEnemy(champion,attempt);var maximum=Objects.requireNonNull(champion.getAttribute(Attribute.MAX_HEALTH));maximum.setBaseValue(rules.championHealth());champion.setHealth(rules.championHealth());Objects.requireNonNull(champion.getAttribute(Attribute.ATTACK_DAMAGE)).setBaseValue(rules.championDamage());champion.customName(net.kyori.adventure.text.Component.text("Daybreak Champion"));champion.setCustomNameVisible(true);attempt.champion=champion;});syncBossBar(attempt);}catch(RuntimeException failure){var attempt=attempts.remove(site.id());if(attempt!=null)cleanup(attempt);plugin.getLogger().log(java.util.logging.Level.WARNING,"Champion attempt could not start at site "+site.id(),failure);}finally{starting.remove(site.id());}}
|
||||
private void spawnEnemy(World world,AdventureQuestState.Site site,org.bukkit.entity.EntityType type,Attempt attempt,int index){var location=new Location(world,site.x()+(index%3)-1,site.y(),site.z()+(index/3));switch(type){case HUSK->world.spawn(location,Husk.class,mob->configureEnemy(mob,attempt));case PILLAGER->world.spawn(location,Pillager.class,mob->configureEnemy(mob,attempt));case VINDICATOR->world.spawn(location,Vindicator.class,mob->configureEnemy(mob,attempt));case WITCH->world.spawn(location,Witch.class,mob->configureEnemy(mob,attempt));default->throw new IllegalArgumentException("Unsupported camp entity");}}
|
||||
private void configureEnemy(Mob mob,Attempt attempt){mob.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,ENEMY);mob.getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,attempt.site.toString());mob.getPersistentDataContainer().set(catalogKey,PersistentDataType.STRING,attempt.catalog.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Husk husk){husk.setShouldBurnInDay(false);husk.setCanBreakDoors(false);}if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}attempt.entities.add(mob.getUniqueId());}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(enemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),site(event.getEntity()))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID site=site(by.getDamager());if(!(event.getEntity() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!enemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);if(player==null)return;var quest=eligible(player,site(event.getEntity()));if(quest!=null){var attempt=attempts.get(quest.site());if(attempt!=null){attempt.represented.add(quest.id());if(attempt.champion==event.getEntity()&&attempt.bossBar!=null){double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,(attempt.champion.getHealth()-event.getFinalDamage())/maximum)));}}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST)public void death(EntityDeathEvent event){if(!enemy(event.getEntity()))return;event.getDrops().clear();event.setDroppedExp(0);UUID site=site(event.getEntity());var attempt=site==null?null:attempts.get(site);if(attempt==null)return;attempt.entities.remove(event.getEntity().getUniqueId());if(attempt.entities.isEmpty())clear(attempt);}
|
||||
private void clear(Attempt attempt){attempts.remove(attempt.site);if(attempt.bossBar!=null)attempt.bossBar.removeAll();readyAt.put(attempt.site,clock.instant().plusSeconds(attempt.resetSeconds));for(UUID questId:Set.copyOf(attempt.represented)){var quest=directory.findQuest(questId).orElse(null);if(quest==null)continue;var recipients=recipients(quest,attempt.site,attempt.arena);if(!recipients.isEmpty())award(quest,recipients);} }
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site){var value=directory.findSite(site).orElse(null);return value==null?List.of():recipients(quest,site,arena(value));}
|
||||
private List<UUID> recipients(AdventureQuestState.Quest quest,UUID site,AdventureQuestState.Arena arena){var value=directory.findSite(site).orElse(null);if(value==null)return List.of();var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(directory.characterClaimed(quest.catalog(),quest.site(),character))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),value,arena))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
private void award(AdventureQuestState.Quest quest,List<UUID> recipients){var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(quest.rewardXp(),List.of(unit),random);var coins=RewardAllocator.allocate(quest.rewardCoins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));directory.bankAndComplete(quest.id(),quest.id(),rewards).whenComplete((banked,failure)->schedule(()->{if(failure!=null||!banked){plugin.getLogger().log(java.util.logging.Level.WARNING,"Adventure completion reward banking failed for quest "+quest.id(),failure);return;}for(UUID character:recipients){var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);var player=active==null?null:plugin.getServer().getPlayer(active.player());if(player!=null)player.sendMessage("Adventure complete: rewards are ready at any Quest Giver.");}refreshTrackingPlayers();}));}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(org.bukkit.event.entity.PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID site=site(event.getPotion());for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||site==null||eligible(player,site)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(enemy(event.getEntity())&&event.getTarget()!=null){UUID site=site(event.getEntity());if(!(event.getTarget() instanceof Player player)||site==null||eligible(player,site)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(org.bukkit.event.entity.ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&enemy(shooter)){event.getEntity().getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,PROJECTILE);event.getEntity().getPersistentDataContainer().set(siteKey,PersistentDataType.STRING,Objects.requireNonNull(site(shooter)).toString());}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity())||giver(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(EntityBreedEvent event){if(giver(event.getMother())||giver(event.getFather()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(giver(event.getEntered())||managed(event.getEntered()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(PlayerLeashEntityEvent event){if(giver(event.getEntity())||managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if((giver(event.getEntity())||managed(event.getEntity()))&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(enemy(entity)){UUID site=site(entity);var attempt=site==null?null:attempts.remove(site);if(attempt!=null){cleanup(attempt);readyAt.put(site,clock.instant());}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void giverDamage(EntityDamageEvent event){if(giver(event.getEntity()))event.setCancelled(true);}
|
||||
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof org.bukkit.entity.Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
|
||||
private UUID site(Entity entity){return parse(entity.getPersistentDataContainer().get(siteKey,PersistentDataType.STRING));}
|
||||
private void syncBossBar(Attempt attempt){if(attempt.bossBar==null||attempt.champion==null)return;var viewers=eligibleInside(attempt.site,attempt.arena).collect(java.util.stream.Collectors.toSet());for(var player:plugin.getServer().getOnlinePlayers())if(viewers.contains(player))attempt.bossBar.addPlayer(player);else attempt.bossBar.removePlayer(player);double maximum=Objects.requireNonNull(attempt.champion.getAttribute(Attribute.MAX_HEALTH)).getValue();attempt.bossBar.setProgress(Math.max(0,Math.min(1,attempt.champion.getHealth()/maximum)));}
|
||||
private void cleanup(Attempt attempt){if(attempt.bossBar!=null)attempt.bossBar.removeAll();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(enemy(entity)&&attempt.site.equals(site(entity)))entity.remove();attempt.entities.clear();}
|
||||
private void refreshTrackingPlayers(){refreshTrackingPlayers(false);}
|
||||
private void refreshTrackingPlayers(boolean actionBar){for(var value:plugin.getServer().getOnlinePlayers())refreshTracking(value,actionBar);}
|
||||
private void refreshTracking(Player player){refreshTracking(player,false);}
|
||||
private void refreshTracking(Player player,boolean actionBar){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead()||player.isDead()){sessions.restoreBook(active);return;}var party=parties.partyOf(player.getUniqueId()).orElse(null);UUID partyId=party==null?null:party.id();var quest=directory.activeFor(record.id(),partyId).filter(value->value.snapshot().contains(record.id())).orElse(null);var site=quest==null?null:directory.findSite(quest.site()).filter(directory::available).orElse(null);if(quest==null||site==null||!directory.trackingEnabled(quest.id(),record.id())){sessions.restoreBook(active);return;}var location=player.getLocation().clone();boolean arrived=site.type()==AdventureQuestState.SiteType.DUNGEON?dungeonRoomPresence.test(site.id(),location):inside(location,site,arena(site));boolean published=sessions.presentBook(active,book->navigation.compass(book,quest,site,location));if(published&&actionBar)navigation.guidance(player,site,arrived).ifPresent(player::sendActionBar);}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
private static boolean inside(Location location,AdventureQuestState.Site site,AdventureQuestState.Arena arena){return location.getWorld()!=null&&location.getWorld().getUID().equals(site.world())&&arena.contains(location.getX()-site.x(),location.getZ()-site.z());}
|
||||
private Location location(AdventureQuestState.Giver value){return new Location(plugin.getServer().getWorld(value.world()),value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private static UUID parse(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static UUID id(Entity entity,NamespacedKey key){return parse(entity.getPersistentDataContainer().get(key,PersistentDataType.STRING));}
|
||||
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
|
||||
@Override public void close(){for(var player:plugin.getServer().getOnlinePlayers()){sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(sessions::restoreBook);}closed=true;if(pulseTask!=null)pulseTask.cancel();for(var attempt:new ArrayList<>(attempts.values()))cleanup(attempt);attempts.clear();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(giver(entity)||enemy(entity))entity.remove();}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureQuestSettings;
|
||||
import games.dmg.fantasystick.persistence.YamlAdventureQuestRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Serialized durable daily catalog, acceptance, roster and completion authority. */
|
||||
public final class AdventureQuestDirectory implements AutoCloseable {
|
||||
public enum Acceptance { APPLIED, STALE, UNAVAILABLE, CONFLICT, CLAIMED }
|
||||
public enum RerollStatus { APPLIED, STALE }
|
||||
public enum TrackingStatus { APPLIED, UNCHANGED, INELIGIBLE }
|
||||
public record AcceptResult(Acceptance status,Optional<AdventureQuestState.Quest> quest) { }
|
||||
public record RerollResult(RerollStatus status,Optional<AdventureQuestState.Catalog> catalog) { }
|
||||
public record Reward(java.math.BigInteger xp,java.math.BigInteger coins) {public Reward{Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0)throw new IllegalArgumentException("Invalid reward");}}
|
||||
private final YamlAdventureQuestRepository repository;private final Clock clock;private final Supplier<AdventureQuestSettings.Rules> settings;private final java.util.random.RandomGenerator random;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-persistence"));
|
||||
private volatile AdventureQuestState snapshot;private volatile java.util.function.Function<AdventureQuestState.Site,Reward> dungeonRewards=ignored->new Reward(java.math.BigInteger.ZERO,java.math.BigInteger.ZERO);private volatile java.util.function.Predicate<AdventureQuestState.Site> dungeonAvailable=ignored->true;private volatile java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> dungeonSites=List::of;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdventureQuestDirectory(YamlAdventureQuestRepository repository,Clock clock,Supplier<AdventureQuestSettings.Rules> settings,java.util.random.RandomGenerator random)throws IOException{this.repository=Objects.requireNonNull(repository);this.clock=Objects.requireNonNull(clock);this.settings=Objects.requireNonNull(settings);this.random=Objects.requireNonNull(random);snapshot=repository.load();}
|
||||
public AdventureQuestState snapshot(){return snapshot;}
|
||||
public void registerDungeonRewards(java.util.function.Function<AdventureQuestState.Site,Reward> rewards){dungeonRewards=Objects.requireNonNull(rewards);}
|
||||
public void registerDungeonAvailability(java.util.function.Predicate<AdventureQuestState.Site> available){dungeonAvailable=Objects.requireNonNull(available);}
|
||||
public void registerDungeonSites(java.util.function.Supplier<? extends Collection<AdventureQuestState.Site>> sites){dungeonSites=Objects.requireNonNull(sites);}
|
||||
public boolean available(AdventureQuestState.Site site){return site.type()!=AdventureQuestState.SiteType.DUNGEON||dungeonAvailable.test(site);}
|
||||
public boolean offerOpen(UUID site){var catalog=snapshot.catalog();return catalog!=null&&catalog.sites().contains(site)&&!catalog.closed().contains(site);}
|
||||
public Reward reward(AdventureQuestState.Site site){var rules=settings.get();return switch(site.type()){case CAMP->new Reward(rules.campRewardXp(),rules.campRewardCoins());case CHAMPION->new Reward(rules.championRewardXp(),rules.championRewardCoins());case DUNGEON->dungeonRewards.apply(site);};}
|
||||
public Optional<AdventureQuestState.Site> findSite(UUID id){return site(snapshot,id);}
|
||||
public Optional<AdventureQuestState.Giver> findGiver(UUID id){return snapshot.givers().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<AdventureQuestState.Quest> findQuest(UUID id){return snapshot.quests().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z){return placeSite(type,name,world,x,y,z,null);}
|
||||
public CompletableFuture<AdventureQuestState.Site> placeSite(AdventureQuestState.SiteType type,String name,UUID world,double x,double y,double z,AdventureQuestState.Arena arena){var value=new AdventureQuestState.Site(UUID.randomUUID(),type,name,world,x,y,z,arena);return mutate(state->{var sites=new ArrayList<>(state.sites());sites.add(value);return result(copy(state,List.copyOf(sites),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()!=AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();var quests=state.quests().stream().filter(v->!v.site().equals(id)).toList();var rosters=new HashMap<>(state.rosters());rosters.remove(id);return result(copy(state,sites,state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),rosters),found);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Site>> removeDungeonSite(UUID id){return mutate(state->{var found=state.sites().stream().filter(v->v.id().equals(id)&&v.type()==AdventureQuestState.SiteType.DUNGEON).findFirst();if(found.isEmpty())return result(state,Optional.empty());var sites=state.sites().stream().filter(v->!v.id().equals(id)).toList();return result(copy(state,sites,state.givers(),state.catalog(),state.quests().stream().filter(v->!v.site().equals(id)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Giver> placeGiver(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new AdventureQuestState.Giver(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);return mutate(state->{var values=new ArrayList<>(state.givers());values.add(value);return result(copy(state,state.sites(),List.copyOf(values),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),value);});}
|
||||
public CompletableFuture<Optional<AdventureQuestState.Giver>> removeGiver(UUID id){return mutate(state->{var found=state.givers().stream().filter(v->v.id().equals(id)).findFirst();if(found.isEmpty())return result(state,Optional.empty());return result(copy(state,state.sites(),state.givers().stream().filter(v->!v.id().equals(id)).toList(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),found);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> appendOffer(UUID site){return mutate(state->{if(state.reset()!=null||site(state,site).filter(v->v.type()==AdventureQuestState.SiteType.DUNGEON).isEmpty()||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC))))throw new IllegalArgumentException("Dungeon offer unavailable");if(state.catalog().sites().contains(site)){if(!state.catalog().closed().contains(site))return result(state,state.catalog());var closed=new HashSet<>(state.catalog().closed());closed.remove(site);var reopened=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);return result(copy(state,state.sites(),state.givers(),reopened,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),reopened);}if(state.catalog().sites().size()>=256)throw new IllegalArgumentException("Adventure offer limit reached");var offers=new ArrayList<>(state.catalog().sites());offers.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),offers,state.catalog().closed());return result(copy(state,state.sites(),state.givers(),catalog,state.quests(),state.unitClaims(),state.characterClaims(),state.rosters()),catalog);});}
|
||||
public CompletableFuture<Boolean> closeOffer(UUID site){return mutate(state->{if(state.catalog()==null||!state.catalog().sites().contains(site)||state.catalog().closed().contains(site))return result(state,false);var closed=new HashSet<>(state.catalog().closed());closed.add(site);var catalog=new AdventureQuestState.Catalog(state.catalog().day(),state.catalog().revision(),state.catalog().createdAt(),state.catalog().sites(),closed);var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();return result(copy(state,state.sites(),state.givers(),catalog,quests,state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<AdventureQuestState.Catalog> ensureToday(){return mutate(state->{if(state.reset()!=null)return result(state,state.catalog());var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));if(state.catalog()!=null&&state.catalog().day().equals(today))return result(expire(state),state.catalog());var prior=state.catalog();var catalog=roll(state,today,prior==null?Set.of():Set.copyOf(prior.sites()));var reset=prior==null?null:new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,catalog);});}
|
||||
public CompletableFuture<RerollResult> rerollToday(){var current=snapshot.catalog();return rerollToday(current==null?null:current.revision());}
|
||||
public CompletableFuture<RerollResult> rerollToday(UUID expectedRevision){return mutate(original->{var state=expire(original);var current=state.catalog();UUID actual=current==null?null:current.revision();if(state.reset()!=null||!Objects.equals(expectedRevision,actual))return result(state,new RerollResult(RerollStatus.STALE,Optional.ofNullable(current)));var today=LocalDate.now(clock.withZone(ZoneOffset.UTC));boolean sameDay=current!=null&¤t.day().equals(today);var catalog=roll(state,today,sameDay?Set.copyOf(current.sites()):Set.of());var reset=new AdventureQuestState.Reset(UUID.randomUUID(),catalog.revision(),clock.instant());var next=copyReset(state,state.sites(),state.givers(),catalog,List.of(),Set.of(),Set.of(),Map.of(),reset);return result(next,new RerollResult(RerollStatus.APPLIED,Optional.of(catalog)));});}
|
||||
public CompletableFuture<Boolean> completeReset(UUID operation){return mutate(state->{if(state.reset()==null||!state.reset().operation().equals(operation))return result(state,false);return result(copyReset(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),null),true);});}
|
||||
public CompletableFuture<AcceptResult> accept(UUID catalog,UUID site,AdventureQuestState.UnitType type,UUID unit,Set<UUID> members){return mutate(original->{var state=expire(original);var current=state.catalog();if(state.reset()!=null||current==null||!current.day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!current.revision().equals(catalog))return result(state,new AcceptResult(Acceptance.STALE,Optional.empty()));if(!current.sites().contains(site)||current.closed().contains(site)||site(state,site).filter(this::available).isEmpty())return result(state,new AcceptResult(Acceptance.UNAVAILABLE,Optional.empty()));boolean unitClaimed=state.unitClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.type()==type&&claim.unit().equals(unit));boolean characterClaimed=members.stream().anyMatch(member->state.characterClaims().stream().anyMatch(claim->claim.site().equals(site)&&claim.character().equals(member)));if(unitClaimed||characterClaimed)return result(state,new AcceptResult(Acceptance.CLAIMED,Optional.empty()));boolean conflict=state.quests().stream().anyMatch(q->q.unit().equals(unit)||type==AdventureQuestState.UnitType.PARTY&&q.unitType()==AdventureQuestState.UnitType.SOLO&&members.contains(q.unit()));if(conflict)return result(state,new AcceptResult(Acceptance.CONFLICT,Optional.empty()));var offered=site(state,site).orElseThrow();var reward=reward(offered);var quest=new AdventureQuestState.Quest(UUID.randomUUID(),catalog,site,type,unit,members,current.expiresAt(),reward.xp(),reward.coins());var quests=new ArrayList<>(state.quests());quests.add(quest);var tracking=new HashSet<>(state.tracking());for(UUID character:members)tracking.add(new AdventureQuestState.Tracking(quest.id(),character,true));return result(copyTracking(state,state.sites(),state.givers(),current,List.copyOf(quests),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),new AcceptResult(Acceptance.APPLIED,Optional.of(quest)));});}
|
||||
public CompletableFuture<Integer> expireOfferQuests(UUID site){return mutate(state->{var quests=state.quests().stream().filter(value->!value.site().equals(site)).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
public CompletableFuture<Boolean> abandon(UUID quest,UUID unit){return mutate(state->{var found=state.quests().stream().filter(q->q.id().equals(quest)&&q.unit().equals(unit)).findFirst();if(found.isEmpty())return result(state,false);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(quest)).toList(),state.unitClaims(),state.characterClaims(),state.rosters()),true);});}
|
||||
public CompletableFuture<List<EntityType>> roster(UUID site){return mutate(state->{state=expire(state);if(state.reset()!=null||state.catalog()==null||!state.catalog().day().equals(LocalDate.now(clock.withZone(ZoneOffset.UTC)))||!state.catalog().sites().contains(site)||find(state.sites(),site).filter(v->v.type()==AdventureQuestState.SiteType.CAMP).isEmpty())throw new IllegalArgumentException("Unavailable camp");var existing=state.rosters().get(site);if(existing!=null)return result(state,existing);var rolled=CampRoster.roll(settings.get().campEnemies(),random);var rosters=new HashMap<>(state.rosters());rosters.put(site,rolled);return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),rosters),rolled);});}
|
||||
public CompletableFuture<Boolean> complete(UUID questId,Set<UUID> recipients){return mutate(state->{var quest=state.quests().stream().filter(q->q.id().equals(questId)).findFirst().orElse(null);if(quest==null)return result(state,false);var units=new HashSet<>(state.unitClaims());units.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));var characters=new HashSet<>(state.characterClaims());for(UUID recipient:recipients)characters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),recipient));return result(copy(state,state.sites(),state.givers(),state.catalog(),state.quests().stream().filter(q->!q.id().equals(questId)).toList(),units,characters,state.rosters()),true);});}
|
||||
public List<AdventureQuestState.Quest> active(){return expireView(snapshot).quests();}
|
||||
public boolean characterClaimed(UUID catalog,UUID site,UUID character){return snapshot.characterClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.character().equals(character));}
|
||||
public boolean claimed(UUID catalog,UUID site,UUID character,UUID party){var state=snapshot;return state.characterClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.character().equals(character))||(party!=null&&state.unitClaims().stream().anyMatch(claim->claim.catalog().equals(catalog)&&claim.site().equals(site)&&claim.type()==AdventureQuestState.UnitType.PARTY&&claim.unit().equals(party)));}
|
||||
public Optional<AdventureQuestState.Quest> activeFor(UUID character,UUID party){return active().stream().filter(q->q.unitType()==AdventureQuestState.UnitType.SOLO?q.unit().equals(character):party!=null&&q.unit().equals(party)&&q.snapshot().contains(character)).findFirst();}
|
||||
public boolean trackingEnabled(UUID quest,UUID character){var active=findQuest(quest).filter(value->value.snapshot().contains(character));if(active.isEmpty())return false;return snapshot.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).map(AdventureQuestState.Tracking::enabled).findFirst().orElse(true);}
|
||||
public CompletableFuture<TrackingStatus> setTracking(UUID quest,UUID character,boolean enabled){return mutate(original->{var state=expire(original);var active=state.quests().stream().filter(value->value.id().equals(quest)&&value.snapshot().contains(character)).findFirst();if(active.isEmpty())return result(state,TrackingStatus.INELIGIBLE);var current=state.tracking().stream().filter(value->value.quest().equals(quest)&&value.character().equals(character)).findFirst();if(current.map(AdventureQuestState.Tracking::enabled).orElse(true)==enabled&¤t.isPresent())return result(state,TrackingStatus.UNCHANGED);var tracking=new HashSet<>(state.tracking());tracking.removeIf(value->value.quest().equals(quest)&&value.character().equals(character));tracking.add(new AdventureQuestState.Tracking(quest,character,enabled));return result(copyTracking(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),tracking),TrackingStatus.APPLIED);});}
|
||||
public CompletableFuture<Boolean> bank(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,false);}
|
||||
public CompletableFuture<Boolean> bankAndComplete(UUID quest,UUID source,Map<UUID,Reward> rewards){return bank(quest,source,rewards,true);}
|
||||
private CompletableFuture<Boolean> bank(UUID questId,UUID source,Map<UUID,Reward> rewards,boolean complete){Objects.requireNonNull(source);var requested=Map.copyOf(rewards);return mutate(original->{var state=expire(original);var quest=state.quests().stream().filter(value->value.id().equals(questId)).findFirst().orElse(null);if(quest==null||!quest.snapshot().containsAll(requested.keySet())||complete&&requested.isEmpty())return result(state,false);var allocations=new ArrayList<>(state.rewards().allocations());for(var entry:requested.entrySet()){var reward=Objects.requireNonNull(entry.getValue());if(reward.xp().signum()==0&&reward.coins().signum()==0)continue;UUID id=rewardId(quest,source,entry.getKey());var existing=allocations.stream().filter(value->value.id().equals(id)).findFirst().orElse(null);if(existing!=null){if(!existing.xp().equals(reward.xp())||!existing.coins().equals(reward.coins()))throw new IllegalStateException("Conflicting adventure reward allocation");continue;}allocations.add(new AdventureQuestState.RewardAllocation(id,quest.catalog(),quest.site(),quest.id(),source,entry.getKey(),reward.xp(),reward.coins()));}var quests=state.quests();var units=state.unitClaims();var characters=state.characterClaims();if(complete){quests=state.quests().stream().filter(value->!value.id().equals(quest.id())).toList();var changedUnits=new HashSet<>(units);changedUnits.add(new AdventureQuestState.UnitClaim(quest.catalog(),quest.site(),quest.unitType(),quest.unit()));units=Set.copyOf(changedUnits);var changedCharacters=new HashSet<>(characters);for(UUID character:requested.keySet())changedCharacters.add(new AdventureQuestState.CharacterClaim(quest.catalog(),quest.site(),character));characters=Set.copyOf(changedCharacters);}var ledger=new AdventureQuestState.RewardLedger(allocations,state.rewards().operations(),state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),quests,units,characters,state.rosters(),ledger),true);});}
|
||||
public Reward unclaimed(UUID catalog,UUID site,UUID character){BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(var value:snapshot.rewards().allocations())if(value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)){xp=xp.add(value.xp());coins=coins.add(value.coins());}return new Reward(xp,coins);}
|
||||
public boolean rewardClaimed(UUID catalog,UUID site,UUID character){return snapshot.rewards().receipts().contains(new AdventureQuestState.RewardReceipt(catalog,site,character));}
|
||||
public Optional<AdventureQuestState.RewardClaim> pendingClaim(UUID character){return snapshot.rewards().operations().stream().filter(value->value.character().equals(character)).findFirst();}
|
||||
public CompletableFuture<Optional<AdventureQuestState.RewardClaim>> beginClaim(UUID catalog,UUID site,UUID character){return mutate(original->{var state=expire(original);var current=state.catalog();if(current==null||!current.revision().equals(catalog)||!current.sites().contains(site)||!clock.instant().isBefore(current.expiresAt()))return result(state,Optional.empty());var pending=state.rewards().operations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).findFirst();if(pending.isPresent())return result(state,pending);var allocations=state.rewards().allocations().stream().filter(value->value.catalog().equals(catalog)&&value.site().equals(site)&&value.character().equals(character)).toList();if(allocations.isEmpty())return result(state,Optional.empty());BigInteger xp=allocations.stream().map(AdventureQuestState.RewardAllocation::xp).reduce(BigInteger.ZERO,BigInteger::add),coins=allocations.stream().map(AdventureQuestState.RewardAllocation::coins).reduce(BigInteger.ZERO,BigInteger::add);var ids=allocations.stream().map(AdventureQuestState.RewardAllocation::id).collect(java.util.stream.Collectors.toSet());UUID operation=UUID.nameUUIDFromBytes((catalog+":"+site+":"+character+":"+ids.stream().sorted().map(UUID::toString).collect(java.util.stream.Collectors.joining(","))).getBytes(java.nio.charset.StandardCharsets.UTF_8));var claim=new AdventureQuestState.RewardClaim(operation,catalog,site,character,ids,xp,coins);var operations=new ArrayList<>(state.rewards().operations());operations.add(claim);var ledger=new AdventureQuestState.RewardLedger(state.rewards().allocations(),operations,state.rewards().receipts());return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),Optional.of(claim));});}
|
||||
public CompletableFuture<Boolean> finishClaim(UUID operation){return mutate(state->{var claim=state.rewards().operations().stream().filter(value->value.id().equals(operation)).findFirst().orElse(null);if(claim==null)return result(state,false);var allocations=state.rewards().allocations().stream().filter(value->!claim.allocations().contains(value.id())).toList();var operations=state.rewards().operations().stream().filter(value->!value.id().equals(operation)).toList();var receipts=new HashSet<>(state.rewards().receipts());receipts.add(new AdventureQuestState.RewardReceipt(claim.catalog(),claim.site(),claim.character()));var ledger=new AdventureQuestState.RewardLedger(allocations,operations,receipts);return result(copyRewards(state,state.sites(),state.givers(),state.catalog(),state.quests(),state.unitClaims(),state.characterClaims(),state.rosters(),ledger),true);});}
|
||||
public CompletableFuture<Integer> expireParties(java.util.function.Predicate<UUID> exists){return mutate(state->{var quests=state.quests().stream().filter(q->q.unitType()!=AdventureQuestState.UnitType.PARTY||exists.test(q.unit())).toList();int removed=state.quests().size()-quests.size();return removed==0?result(state,0):result(copy(state,state.sites(),state.givers(),state.catalog(),quests,state.unitClaims(),state.characterClaims(),state.rosters()),removed);});}
|
||||
private AdventureQuestState expire(AdventureQuestState state){var view=expireView(state);return view.equals(state)?state:view;}
|
||||
private AdventureQuestState expireView(AdventureQuestState state){if(state.catalog()==null||clock.instant().isBefore(state.catalog().expiresAt()))return state;return new AdventureQuestState(state.sites(),state.givers(),state.catalog(),List.of(),state.unitClaims(),state.characterClaims(),state.rosters(),Set.of(),pendingRewards(state.rewards()),state.reset());}
|
||||
private AdventureQuestState.Catalog roll(AdventureQuestState state,LocalDate day,Set<UUID> previous){var candidates=candidateSites(state);var alternatives=candidates.stream().map(AdventureQuestState.Site::id).filter(id->!previous.contains(id)).collect(java.util.stream.Collectors.toCollection(ArrayList::new));var old=candidates.stream().map(AdventureQuestState.Site::id).filter(previous::contains).collect(java.util.stream.Collectors.toCollection(ArrayList::new));shuffle(alternatives);shuffle(old);int count=Math.min(settings.get().dailySiteCount(),candidates.size());var selected=new ArrayList<UUID>();selected.addAll(alternatives.subList(0,Math.min(count,alternatives.size())));if(selected.size()<count)selected.addAll(old.subList(0,count-selected.size()));return new AdventureQuestState.Catalog(day,UUID.randomUUID(),clock.instant(),selected);}
|
||||
private void shuffle(List<UUID> values){for(int i=values.size()-1;i>0;i--){int j=random.nextInt(i+1);var v=values.get(i);values.set(i,values.get(j));values.set(j,v);}}
|
||||
private List<AdventureQuestState.Site> candidateSites(AdventureQuestState state){var values=new LinkedHashMap<UUID,AdventureQuestState.Site>();state.sites().stream().filter(value->value.type()!=AdventureQuestState.SiteType.DUNGEON).forEach(value->values.put(value.id(),value));dungeonSites.get().forEach(value->values.put(value.id(),value));return List.copyOf(values.values());}
|
||||
private Optional<AdventureQuestState.Site> site(AdventureQuestState state,UUID id){var stored=find(state.sites(),id);return stored.isPresent()?stored:dungeonSites.get().stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
private static Optional<AdventureQuestState.Site> find(List<AdventureQuestState.Site> sites,UUID id){return sites.stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
private static AdventureQuestState copy(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),original.rewards(),original.reset());}
|
||||
private static AdventureQuestState copyTracking(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Set<AdventureQuestState.Tracking> tracking){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(tracking,quests),original.rewards(),original.reset());}
|
||||
private static AdventureQuestState copyRewards(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.RewardLedger rewards){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),rewards,original.reset());}
|
||||
private static AdventureQuestState copyReset(AdventureQuestState original,List<AdventureQuestState.Site> sites,List<AdventureQuestState.Giver> givers,AdventureQuestState.Catalog catalog,List<AdventureQuestState.Quest> quests,Set<AdventureQuestState.UnitClaim> unitClaims,Set<AdventureQuestState.CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,AdventureQuestState.Reset reset){return new AdventureQuestState(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,validTracking(original.tracking(),quests),pendingRewards(original.rewards()),reset);}
|
||||
private static AdventureQuestState.RewardLedger pendingRewards(AdventureQuestState.RewardLedger rewards){var retainedIds=rewards.operations().stream().flatMap(value->value.allocations().stream()).collect(java.util.stream.Collectors.toSet());return new AdventureQuestState.RewardLedger(rewards.allocations().stream().filter(value->retainedIds.contains(value.id())).toList(),rewards.operations(),Set.of());}
|
||||
private static Set<AdventureQuestState.Tracking> validTracking(Set<AdventureQuestState.Tracking> tracking,List<AdventureQuestState.Quest> quests){var active=quests.stream().collect(java.util.stream.Collectors.toMap(AdventureQuestState.Quest::id,value->value));return tracking.stream().filter(value->{var quest=active.get(value.quest());return quest!=null&&quest.snapshot().contains(value.character());}).collect(java.util.stream.Collectors.toUnmodifiableSet());}
|
||||
private static UUID rewardId(AdventureQuestState.Quest quest,UUID source,UUID character){return UUID.nameUUIDFromBytes((quest.catalog()+":"+quest.site()+":"+quest.id()+":"+source+":"+character).getBytes(java.nio.charset.StandardCharsets.UTF_8));}
|
||||
private record Result<T>(AdventureQuestState state,T value){}
|
||||
private static <T>Result<T> result(AdventureQuestState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<AdventureQuestState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Adventure directory closed"));return future;}pending++;}worker.execute(()->{try{var result=operation.apply(snapshot);if(result.state()!=snapshot){repository.save(result.state());snapshot=result.state();}future.complete(result.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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,61 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.CompassMeta;
|
||||
|
||||
/** Pure presentation of one accepted quest through the authentic bound book control. */
|
||||
public final class AdventureQuestNavigation {
|
||||
private static final String[] BEARINGS={"N","NE","E","SE","S","SW","W","NW"};
|
||||
private final Server server;
|
||||
|
||||
public AdventureQuestNavigation(Server server){this.server=Objects.requireNonNull(server);}
|
||||
|
||||
public ItemStack compass(ItemStack ordinaryBook,AdventureQuestState.Quest quest,AdventureQuestState.Site site,Location viewer){
|
||||
Objects.requireNonNull(ordinaryBook);Objects.requireNonNull(quest);Objects.requireNonNull(site);Objects.requireNonNull(viewer);
|
||||
var item=ordinaryBook.clone();
|
||||
item.setType(Material.COMPASS);
|
||||
var meta=item.getItemMeta();
|
||||
meta.displayName(Component.text("Quest Compass"));
|
||||
var lore=new java.util.ArrayList<Component>();
|
||||
lore.add(Component.text(site.displayName()));
|
||||
lore.add(Component.text(type(site.type())));
|
||||
World target=server.getWorld(site.world());
|
||||
lore.add(Component.text("World: "+worldName(target)));
|
||||
if(target!=null&&viewer.getWorld()!=null&&viewer.getWorld().getUID().equals(site.world()))lore.add(Component.text("Distance: "+distance(viewer,site)+" blocks"));
|
||||
lore.add(Component.text("Expires: "+quest.expiresAt()));
|
||||
meta.lore(List.copyOf(lore));
|
||||
meta.setMaxStackSize(1);
|
||||
if(meta instanceof CompassMeta compass){
|
||||
compass.setLodestoneTracked(false);
|
||||
if(target!=null)compass.setLodestone(new Location(target,site.x(),site.y(),site.z()));
|
||||
}
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public Optional<Component> guidance(Player player,AdventureQuestState.Site site,boolean arrived){
|
||||
Objects.requireNonNull(player);Objects.requireNonNull(site);
|
||||
if(arrived)return Optional.empty();
|
||||
World target=server.getWorld(site.world());
|
||||
var viewer=player.getLocation();
|
||||
if(target==null)return Optional.of(Component.text("Travel to unavailable world"));
|
||||
if(viewer.getWorld()==null||!viewer.getWorld().getUID().equals(site.world()))return Optional.of(Component.text("Travel to "+worldName(target)));
|
||||
long vertical=Math.round(site.y()-viewer.getY());
|
||||
String height=vertical>0?"↑ +"+vertical:vertical<0?"↓ "+vertical:"↔ 0";
|
||||
return Optional.of(Component.text(site.displayName()+" — "+distance(viewer,site)+" blocks "+bearing(viewer,site)+" · "+height));
|
||||
}
|
||||
|
||||
static long distance(Location viewer,AdventureQuestState.Site site){return Math.round(Math.hypot(site.x()-viewer.getX(),site.z()-viewer.getZ()));}
|
||||
static String bearing(Location viewer,AdventureQuestState.Site site){double angle=Math.atan2(site.x()-viewer.getX(),-(site.z()-viewer.getZ()));int index=(int)Math.round(angle/(Math.PI/4));return BEARINGS[Math.floorMod(index,8)];}
|
||||
private static String type(AdventureQuestState.SiteType type){return switch(type){case CAMP->"Monster camp";case CHAMPION->"Champion";case DUNGEON->"Dungeon entrance";};}
|
||||
private static String worldName(World world){return world==null||world.getName()==null||world.getName().isBlank()?"unavailable world":world.getName();}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Immutable durable authority for registered daily-adventure locations, offers, quests and claims. */
|
||||
public record AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,
|
||||
Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,
|
||||
Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,RewardLedger rewards,Reset reset) {
|
||||
public enum SiteType { CAMP, CHAMPION, DUNGEON }
|
||||
public enum UnitType { SOLO, PARTY }
|
||||
public enum ArenaShape { CIRCLE, RECTANGLE }
|
||||
public record Arena(ArenaShape shape,int xBlocks,int zBlocks) {
|
||||
public Arena {
|
||||
Objects.requireNonNull(shape);
|
||||
if (shape == ArenaShape.CIRCLE && (xBlocks < 4 || xBlocks > 64 || zBlocks != xBlocks))
|
||||
throw new IllegalArgumentException("Invalid circular arena");
|
||||
if (shape == ArenaShape.RECTANGLE && (xBlocks < 8 || xBlocks > 128 || zBlocks < 8 || zBlocks > 128))
|
||||
throw new IllegalArgumentException("Invalid rectangular arena");
|
||||
}
|
||||
public static Arena circle(int radius){return new Arena(ArenaShape.CIRCLE,radius,radius);}
|
||||
public static Arena rectangle(int xBlocks,int zBlocks){return new Arena(ArenaShape.RECTANGLE,xBlocks,zBlocks);}
|
||||
public boolean contains(double xOffset,double zOffset){return shape==ArenaShape.CIRCLE?xOffset*xOffset+zOffset*zOffset<=xBlocks*(double)xBlocks:Math.abs(xOffset)<=xBlocks/2d&&Math.abs(zOffset)<=zBlocks/2d;}
|
||||
public String display(){return shape==ArenaShape.CIRCLE?"circle radius="+xBlocks:"rectangle x="+xBlocks+" z="+zBlocks;}
|
||||
}
|
||||
public record Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z,Arena arena) {
|
||||
public Site { Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(world);finite(x,y,z); }
|
||||
public Site(UUID id,SiteType type,String name,UUID world,double x,double y,double z){this(id,type,name,world,x,y,z,null);}
|
||||
public String displayName(){return name.isBlank()?switch(type){case CAMP->"Monster Camp";case CHAMPION->"Champion Site";case DUNGEON->"Dungeon";}:name;}
|
||||
public Arena arenaOrDefault(int radius){return arena==null?Arena.circle(radius):arena;}
|
||||
}
|
||||
public record Giver(UUID id,String name,UUID world,double x,double y,double z,float yaw,float pitch) {
|
||||
public Giver { Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(world);finite(x,y,z);if(!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid facing"); }
|
||||
public String displayName(){return name.isBlank()?"Quest Giver":name;}
|
||||
}
|
||||
public record Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites,Set<UUID> closed) {
|
||||
public Catalog(LocalDate day,UUID revision,Instant createdAt,List<UUID> sites){this(day,revision,createdAt,sites,Set.of());}
|
||||
public Catalog { Objects.requireNonNull(day);Objects.requireNonNull(revision);Objects.requireNonNull(createdAt);sites=List.copyOf(sites);closed=Set.copyOf(closed);if(new HashSet<>(sites).size()!=sites.size())throw new IllegalArgumentException("Duplicate offer");if(!new HashSet<>(sites).containsAll(closed))throw new IllegalArgumentException("Unknown closed offer"); }
|
||||
public Instant expiresAt(){return day.plusDays(1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant();}
|
||||
}
|
||||
public record Quest(UUID id,UUID catalog,UUID site,UnitType unitType,UUID unit,Set<UUID> snapshot,
|
||||
Instant expiresAt,BigInteger rewardXp,BigInteger rewardCoins) {
|
||||
public Quest { Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(unitType);Objects.requireNonNull(unit);snapshot=Set.copyOf(snapshot);Objects.requireNonNull(expiresAt);Objects.requireNonNull(rewardXp);Objects.requireNonNull(rewardCoins);if(snapshot.isEmpty()||rewardXp.signum()<0||rewardCoins.signum()<0||unitType==UnitType.SOLO&&(!snapshot.equals(Set.of(unit))))throw new IllegalArgumentException("Invalid quest"); }
|
||||
}
|
||||
public record UnitClaim(UUID catalog,UUID site,UnitType type,UUID unit) {
|
||||
public UnitClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(type);Objects.requireNonNull(unit);}
|
||||
}
|
||||
public record CharacterClaim(UUID catalog,UUID site,UUID character) {
|
||||
public CharacterClaim {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}
|
||||
}
|
||||
public record Tracking(UUID quest,UUID character,boolean enabled) {
|
||||
public Tracking {Objects.requireNonNull(quest);Objects.requireNonNull(character);}
|
||||
}
|
||||
public record RewardAllocation(UUID id,UUID catalog,UUID site,UUID quest,UUID source,UUID character,BigInteger xp,BigInteger coins) {
|
||||
public RewardAllocation {Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(quest);Objects.requireNonNull(source);Objects.requireNonNull(character);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid reward allocation");}
|
||||
}
|
||||
public record RewardClaim(UUID id,UUID catalog,UUID site,UUID character,Set<UUID> allocations,BigInteger xp,BigInteger coins) {
|
||||
public RewardClaim {Objects.requireNonNull(id);Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);allocations=Set.copyOf(allocations);Objects.requireNonNull(xp);Objects.requireNonNull(coins);if(allocations.isEmpty()||xp.signum()<0||coins.signum()<0||xp.signum()==0&&coins.signum()==0)throw new IllegalArgumentException("Invalid reward claim");}
|
||||
}
|
||||
public record RewardReceipt(UUID catalog,UUID site,UUID character) {public RewardReceipt {Objects.requireNonNull(catalog);Objects.requireNonNull(site);Objects.requireNonNull(character);}}
|
||||
public record RewardLedger(List<RewardAllocation> allocations,List<RewardClaim> operations,Set<RewardReceipt> receipts) {
|
||||
public RewardLedger {allocations=List.copyOf(allocations);operations=List.copyOf(operations);receipts=Set.copyOf(receipts);unique(allocations.stream().map(RewardAllocation::id).toList());unique(operations.stream().map(RewardClaim::id).toList());var allocated=allocations.stream().collect(java.util.stream.Collectors.toMap(RewardAllocation::id,value->value));var operationKeys=new HashSet<String>();for(var operation:operations){if(!operationKeys.add(operation.catalog()+":"+operation.site()+":"+operation.character()))throw new IllegalArgumentException("Duplicate offer reward operation");BigInteger xp=BigInteger.ZERO,coins=BigInteger.ZERO;for(UUID id:operation.allocations()){var value=allocated.get(id);if(value==null||!value.catalog().equals(operation.catalog())||!value.site().equals(operation.site())||!value.character().equals(operation.character()))throw new IllegalArgumentException("Invalid reward operation allocation");xp=xp.add(value.xp());coins=coins.add(value.coins());}if(!xp.equals(operation.xp())||!coins.equals(operation.coins()))throw new IllegalArgumentException("Invalid reward operation total");}}
|
||||
public static RewardLedger empty(){return new RewardLedger(List.of(),List.of(),Set.of());}
|
||||
}
|
||||
public record Reset(UUID operation,UUID generation,Instant startedAt) {
|
||||
public Reset {Objects.requireNonNull(operation);Objects.requireNonNull(generation);Objects.requireNonNull(startedAt);}
|
||||
}
|
||||
public AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,Set.of(),RewardLedger.empty(),null);}
|
||||
public AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Reset reset){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,Set.of(),RewardLedger.empty(),reset);}
|
||||
public AdventureQuestState(List<Site> sites,List<Giver> givers,Catalog catalog,List<Quest> quests,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,Map<UUID,List<EntityType>> rosters,Set<Tracking> tracking,Reset reset){this(sites,givers,catalog,quests,unitClaims,characterClaims,rosters,tracking,RewardLedger.empty(),reset);}
|
||||
public AdventureQuestState {
|
||||
sites=List.copyOf(sites);givers=List.copyOf(givers);quests=List.copyOf(quests);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);tracking=Set.copyOf(tracking);Objects.requireNonNull(rewards);
|
||||
var copied=new LinkedHashMap<UUID,List<EntityType>>();rosters.forEach((key,value)->copied.put(key,List.copyOf(value)));rosters=Map.copyOf(copied);
|
||||
unique(sites.stream().map(Site::id).toList());unique(givers.stream().map(Giver::id).toList());unique(quests.stream().map(Quest::id).toList());
|
||||
var trackingKeys=new HashSet<String>();for(var value:tracking){var quest=quests.stream().filter(candidate->candidate.id().equals(value.quest())).findFirst().orElseThrow(()->new IllegalArgumentException("Unknown tracked quest"));if(!quest.snapshot().contains(value.character())||!trackingKeys.add(value.quest()+":"+value.character()))throw new IllegalArgumentException("Invalid quest tracking");}
|
||||
}
|
||||
public static AdventureQuestState empty(){return new AdventureQuestState(List.of(),List.of(),null,List.of(),Set.of(),Set.of(),Map.of(),Set.of(),RewardLedger.empty(),null);}
|
||||
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
|
||||
private static void finite(double... values){for(double value:values)if(!Double.isFinite(value))throw new IllegalArgumentException("Invalid position");}
|
||||
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
/** Durable extension point for state that must join the authoritative adventure-day reset. */
|
||||
@FunctionalInterface
|
||||
public interface AdventureResetParticipant {
|
||||
CompletionStage<Void> reset(AdventureQuestState.Reset reset);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Fixed weighted monster-camp roster generation with reviewed guarantees and caps. */
|
||||
public final class CampRoster {
|
||||
private CampRoster() { }
|
||||
|
||||
public static List<EntityType> roll(int count, java.util.random.RandomGenerator random) {
|
||||
Objects.requireNonNull(random);
|
||||
return roll(count, () -> random.nextInt(100), random::nextInt);
|
||||
}
|
||||
|
||||
static List<EntityType> roll(int count, IntSupplier percent, IntUnaryOperator shuffleIndex) {
|
||||
Objects.requireNonNull(percent); Objects.requireNonNull(shuffleIndex);
|
||||
if (count < 4 || count > 64) throw new IllegalArgumentException("Camp enemy count must be 4 through 64");
|
||||
int guaranteed = (count + 1) / 2;
|
||||
int pillagerCap = count / 2;
|
||||
int vindicatorCap = Math.max(1, count / 4);
|
||||
int pillagers = 0, vindicators = 0, witches = 0;
|
||||
var result = new ArrayList<EntityType>(count);
|
||||
for (int index = 0; index < guaranteed; index++) result.add(EntityType.HUSK);
|
||||
while (result.size() < count) {
|
||||
int roll = percent.getAsInt();
|
||||
if (roll < 0 || roll >= 100) throw new IllegalArgumentException("Camp roll must be 0 through 99");
|
||||
EntityType selected;
|
||||
if (roll < 40) selected = EntityType.HUSK;
|
||||
else if (roll < 75 && pillagers < pillagerCap) { selected = EntityType.PILLAGER; pillagers++; }
|
||||
else if (roll < 95 && roll >= 75 && vindicators < vindicatorCap) { selected = EntityType.VINDICATOR; vindicators++; }
|
||||
else if (roll >= 95 && witches < 1) { selected = EntityType.WITCH; witches++; }
|
||||
else selected = EntityType.HUSK;
|
||||
result.add(selected);
|
||||
}
|
||||
for (int index = result.size() - 1; index > 0; index--) {
|
||||
int other = shuffleIndex.applyAsInt(index + 1);
|
||||
if (other < 0 || other > index) throw new IllegalArgumentException("Invalid shuffle index");
|
||||
var value = result.get(index); result.set(index, result.get(other)); result.set(other, value);
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,15 @@ public final class BookPagination {
|
||||
private BookPagination() { }
|
||||
|
||||
public static List<Component> pages(String text) {
|
||||
var lines = lines(text);
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
static List<String> lines(String text) {
|
||||
var lines = new ArrayList<String>();
|
||||
var line = new StringBuilder();
|
||||
int width = 0;
|
||||
@@ -28,10 +37,6 @@ public final class BookPagination {
|
||||
line.appendCodePoint(codePoint); width += advance;
|
||||
}
|
||||
lines.add(line.toString());
|
||||
var pages = new ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
}
|
||||
@@ -27,23 +27,35 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private final Consumer<Failure> failures;
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry abilities;
|
||||
private final games.dmg.fantasystick.ability.AbilityEngine abilityEngine;
|
||||
private final games.dmg.fantasystick.passive.PassiveRegistry passives;
|
||||
private final games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private final org.bukkit.Server server;
|
||||
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Object> adminPending = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
|
||||
private static final int VOLUME_SIZE = 30; // Reserve room for linked ability details, navigation and party pages.
|
||||
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
|
||||
private static final net.kyori.adventure.key.Key ADMIN_PAGES = net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages");
|
||||
private static final net.kyori.adventure.key.Key STATS = net.kyori.adventure.key.Key.key("fantasystick", "character_stat");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlock");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_ASSIGN = net.kyori.adventure.key.Key.key("fantasystick", "ability_assign");
|
||||
private static final net.kyori.adventure.key.Key ABILITY_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "ability_unlearn");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLOCK = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlock");
|
||||
private static final net.kyori.adventure.key.Key PASSIVE_UNLEARN = net.kyori.adventure.key.Key.key("fantasystick", "passive_unlearn");
|
||||
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
|
||||
private static final java.util.regex.Pattern STAT_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):(STR|DEX|CON|INT|WIS|CHR)\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})(?::([0-6]))?\"");
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int previous, int next) { }
|
||||
private record StatView(String nonce, ControlInput input, java.math.BigInteger revision) { }
|
||||
private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids) { }
|
||||
private final Map<UUID, View> views = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, StatView> statViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, AbilityView> abilityViews = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, PassiveView> passiveViews = new ConcurrentHashMap<>();
|
||||
|
||||
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -51,7 +63,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names) {
|
||||
this(characters, rules, sessions, nextTick, failures, parties, server, names,
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null);
|
||||
new games.dmg.fantasystick.ability.AbilityRegistry(), null,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
@@ -60,6 +73,18 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine) {
|
||||
this(characters, rules, sessions, nextTick, failures, parties, server, names, abilities, abilityEngine,
|
||||
new games.dmg.fantasystick.passive.PassiveRegistry(), null);
|
||||
}
|
||||
|
||||
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures, games.dmg.fantasystick.party.PartyRoster parties,
|
||||
org.bukkit.Server server, java.util.function.Function<UUID, Component> names,
|
||||
games.dmg.fantasystick.ability.AbilityRegistry abilities,
|
||||
games.dmg.fantasystick.ability.AbilityEngine abilityEngine,
|
||||
games.dmg.fantasystick.passive.PassiveRegistry passives,
|
||||
games.dmg.fantasystick.passive.PassiveEngine passiveEngine) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.rules = Objects.requireNonNull(rules);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
@@ -67,11 +92,78 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
this.abilities = Objects.requireNonNull(abilities);
|
||||
this.abilityEngine = abilityEngine;
|
||||
this.passives = Objects.requireNonNull(passives);
|
||||
this.passiveEngine = passiveEngine;
|
||||
this.server = Objects.requireNonNull(server);
|
||||
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) { load(input, 0); }
|
||||
|
||||
public void openReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target) {
|
||||
Objects.requireNonNull(administrator); Objects.requireNonNull(target);
|
||||
if (closed) return;
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
|
||||
return;
|
||||
}
|
||||
UUID targetId = target.getUniqueId();
|
||||
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
|
||||
if (active == null) {
|
||||
administrator.sendMessage("Character view refused: the target has no current active character.");
|
||||
return;
|
||||
}
|
||||
loadReadOnly(administrator, target, active, 0);
|
||||
}
|
||||
|
||||
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset) {
|
||||
if (closed) return;
|
||||
UUID administratorId = administrator.getUniqueId();
|
||||
UUID targetId = target.getUniqueId();
|
||||
Object ticket = new Object();
|
||||
adminViews.remove(administratorId);
|
||||
adminPending.put(administratorId, ticket);
|
||||
characters.find(active.character()).whenComplete((record, failure) -> {
|
||||
if (closed || adminPending.get(administratorId) != ticket) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !adminPending.remove(administratorId, ticket)) return;
|
||||
var currentTarget = server.getPlayer(targetId);
|
||||
var current = currentTarget == null ? null : sessions.active(targetId)
|
||||
.filter(sessions::current).orElse(null);
|
||||
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
|
||||
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
|
||||
return;
|
||||
}
|
||||
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
|
||||
administrator.sendMessage("Character view refused: the target character session changed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
report(new Failure(administratorId, active.character(), failure));
|
||||
administrator.sendMessage("Character view could not be loaded; no state was changed.");
|
||||
return;
|
||||
}
|
||||
if (record.isEmpty() || record.orElseThrow().dead()) {
|
||||
administrator.sendMessage("Character view refused: the target character is unavailable.");
|
||||
return;
|
||||
}
|
||||
try { administrator.openBook(renderReadOnly(record.orElseThrow(), administrator,
|
||||
currentTarget, active, offset)); }
|
||||
catch (RuntimeException error) {
|
||||
adminViews.remove(administratorId);
|
||||
report(new Failure(administratorId, active.character(), error));
|
||||
administrator.sendMessage("Character view could not be opened; no state was changed.");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException rejected) {
|
||||
adminPending.remove(administratorId, ticket);
|
||||
report(new Failure(administratorId, active.character(), rejected));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
@@ -80,6 +172,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
Object ticket = new Object();
|
||||
pending.put(owner, ticket);
|
||||
characters.find(input.session().character()).whenComplete((record, failure) -> {
|
||||
@@ -107,88 +200,356 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
|
||||
var first = new StringBuilder(record.name()).append("\n\n");
|
||||
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
String learned = record.learned().isEmpty() ? "none" : String.join(", ", record.learned().stream().sorted().toList());
|
||||
String second = "Level: " + record.level() + "\nXP: " + xp + "\nUnspent points: " + record.unspentPoints()
|
||||
+ "\nHealth: " + java.math.BigDecimal.valueOf(input.player().getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString()
|
||||
+ "\n\nAbilities: " + learned;
|
||||
return renderBook(record, input.player(), input, offset, null, null);
|
||||
}
|
||||
|
||||
private ItemStack renderReadOnly(CharacterRecord record, org.bukkit.entity.Player administrator,
|
||||
org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int offset) {
|
||||
return renderBook(record, target, null, offset, administrator, active);
|
||||
}
|
||||
|
||||
private ItemStack renderBook(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
ControlInput input, int offset, org.bukkit.entity.Player administrator,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active adminActive) {
|
||||
boolean interactive = input != null;
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
meta.setTitle("Character");
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
var all = BookPagination.pages(first + "\n" + second);
|
||||
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, all.size());
|
||||
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
|
||||
var statView = new StatView(UUID.randomUUID().toString(), input, record.revision());
|
||||
statViews.put(input.session().player(), statView);
|
||||
Component purchases = Component.text("Spend 1 point\n\n");
|
||||
for (var stat : Stat.values()) {
|
||||
purchases = purchases.append(Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + statView.nonce() + ":" + stat + "\""))))
|
||||
.append(Component.newline());
|
||||
}
|
||||
pages.add(purchases);
|
||||
var definitions = abilities.definitions();
|
||||
if (!definitions.isEmpty() && end == all.size()) {
|
||||
var abilityView = new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
|
||||
var statView = interactive ? new StatView(UUID.randomUUID().toString(), input, record.revision()) : null;
|
||||
var allOverview = overviewPages(record, subject, statView);
|
||||
int start = Math.min(offset, (allOverview.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
|
||||
int end = Math.min(start + VOLUME_SIZE, allOverview.size());
|
||||
var pages = new java.util.ArrayList<Component>(allOverview.subList(start, end));
|
||||
if(end==allOverview.size())pages.add(gearPage(record));
|
||||
if (interactive && record.unspentPoints().signum() > 0 && start == 0)
|
||||
statViews.put(input.session().player(), statView);
|
||||
|
||||
var definitions = abilities.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.ability.AbilityDefinition::category)
|
||||
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.ability.AbilityDefinition::id))
|
||||
.toList();
|
||||
var passiveDefinitions = passives.definitions().stream()
|
||||
.sorted(java.util.Comparator.comparing(games.dmg.fantasystick.passive.PassiveDefinition::name)
|
||||
.thenComparing(games.dmg.fantasystick.passive.PassiveDefinition::id))
|
||||
.toList();
|
||||
if ((!definitions.isEmpty() || !passiveDefinitions.isEmpty()) && end == allOverview.size()) {
|
||||
var abilityView = interactive ? new AbilityView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
definitions.stream().map(games.dmg.fantasystick.ability.AbilityDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()));
|
||||
abilityViews.put(input.session().player(), abilityView);
|
||||
for (var definition : definitions) pages.addAll(abilityPages(record, definition, abilityView));
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
|
||||
if (interactive && !definitions.isEmpty()) abilityViews.put(input.session().player(), abilityView);
|
||||
var passiveView = interactive ? new PassiveView(UUID.randomUUID().toString(), input, record.revision(),
|
||||
passiveDefinitions.stream().map(games.dmg.fantasystick.passive.PassiveDefinition::id)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet())) : null;
|
||||
if (interactive && !passiveDefinitions.isEmpty()) passiveViews.put(input.session().player(), passiveView);
|
||||
var passiveNames = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::name));
|
||||
var placeholderPassiveDetails = passiveDefinitions.stream()
|
||||
.map(definition -> passiveDetailPages(record, definition, passiveNames, passiveView, 1)).toList();
|
||||
var placeholderTargets = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, ignored -> 1));
|
||||
var passivePlaceholderTargets = passiveDefinitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id, ignored -> 1));
|
||||
var placeholderDetails = definitions.stream()
|
||||
.map(definition -> abilityPages(record, definition, abilityView, 1)).toList();
|
||||
int activeCount = activePages(record, definitions, placeholderTargets).size();
|
||||
int passiveCount = passiveSlotPages(record, passiveDefinitions, passivePlaceholderTargets).size();
|
||||
var placeholderIndex = indexPages(definitions, placeholderTargets);
|
||||
int indexCount = placeholderIndex.pages().size();
|
||||
var placeholderPassiveIndex = passiveIndexPages(passiveDefinitions, passivePlaceholderTargets);
|
||||
int passiveIndexCount = placeholderPassiveIndex.pages().size();
|
||||
int indexStart = pages.size() + activeCount + passiveCount;
|
||||
var indexTargets = new java.util.HashMap<String, Integer>();
|
||||
placeholderIndex.relativePageByAbility().forEach((id, page) -> indexTargets.put(id, indexStart + page + 1));
|
||||
int passiveIndexStart = indexStart + indexCount;
|
||||
var passiveIndexTargets = new java.util.HashMap<String, Integer>();
|
||||
placeholderPassiveIndex.relativePageByPassive().forEach((id, page) ->
|
||||
passiveIndexTargets.put(id, passiveIndexStart + page + 1));
|
||||
var targets = new java.util.HashMap<String, Integer>();
|
||||
int target = pages.size() + activeCount + passiveCount + indexCount + passiveIndexCount + 1;
|
||||
for (int index = 0; index < definitions.size(); index++) {
|
||||
targets.put(definitions.get(index).id(), target);
|
||||
target += placeholderDetails.get(index).size();
|
||||
}
|
||||
var passiveTargets = new java.util.HashMap<String, Integer>();
|
||||
for (int index = 0; index < passiveDefinitions.size(); index++) {
|
||||
passiveTargets.put(passiveDefinitions.get(index).id(), target);
|
||||
target += placeholderPassiveDetails.get(index).size();
|
||||
}
|
||||
var index = indexPages(definitions, targets);
|
||||
var details = definitions.stream()
|
||||
.map(definition -> abilityPages(record, definition, abilityView, indexTargets.get(definition.id()))).toList();
|
||||
var passiveIndex = passiveIndexPages(passiveDefinitions, passiveTargets);
|
||||
var passiveDetails = passiveDefinitions.stream().map(definition -> passiveDetailPages(record, definition,
|
||||
passiveNames, passiveView, passiveIndexTargets.get(definition.id()))).toList();
|
||||
pages.addAll(activePages(record, definitions, targets));
|
||||
pages.addAll(passiveSlotPages(record, passiveDefinitions, passiveTargets));
|
||||
pages.addAll(index.pages());
|
||||
pages.addAll(passiveIndex.pages());
|
||||
details.forEach(pages::addAll);
|
||||
passiveDetails.forEach(pages::addAll);
|
||||
}
|
||||
if (all.size() > VOLUME_SIZE) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < all.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
|
||||
if (interactive) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < allOverview.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Next pages", view, 1));
|
||||
} else {
|
||||
var view = new AdminView(UUID.randomUUID().toString(), administrator.getUniqueId(),
|
||||
subject, adminActive, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < allOverview.size() ? end : -1);
|
||||
adminViews.put(administrator.getUniqueId(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(adminPageButton("Previous pages", view, 0));
|
||||
if (view.next() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(adminPageButton("Next pages", view, 1));
|
||||
}
|
||||
pages.add(navigation);
|
||||
}
|
||||
pages.addAll(partyControls.pages(input));
|
||||
if (end == allOverview.size()) pages.addAll(interactive
|
||||
? partyControls.pages(input) : partyControls.readOnlyPages(subject.getUniqueId()));
|
||||
meta.pages(pages);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private Component gearPage(CharacterRecord record){return Component.text("Gear\n\nHead: Cosmetic\nChestplate: "+tier(record.gear().chestplate())+"\nLeggings: "+tier(record.gear().leggings())+"\nBoots: "+tier(record.gear().boots()));}
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier){String value=tier.name().toLowerCase(java.util.Locale.ROOT);return Character.toUpperCase(value.charAt(0))+value.substring(1);}
|
||||
|
||||
private java.util.List<Component> overviewPages(CharacterRecord record, org.bukkit.entity.Player subject,
|
||||
StatView view) {
|
||||
var builder = new PageBuilder();
|
||||
builder.text(record.name());
|
||||
builder.blank();
|
||||
for (var stat : Stat.values()) {
|
||||
Component action = view != null && record.unspentPoints().signum() > 0
|
||||
? statButton(stat, view) : null;
|
||||
builder.inline(stat + ": " + record.stats().get(stat), action);
|
||||
}
|
||||
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
|
||||
String xp = record.level() >= rules.maximumLevel() ? "MAX"
|
||||
: record.experience().remainder(rules.xpPerLevel()) + "/" + rules.xpPerLevel();
|
||||
builder.text("Level: " + record.level());
|
||||
builder.text("XP: " + xp);
|
||||
builder.text("Unspent points: " + record.unspentPoints());
|
||||
builder.text("Coins: " + record.coins());
|
||||
builder.text("Health: " + java.math.BigDecimal.valueOf(subject.getHealth()).min(maximum).stripTrailingZeros().toPlainString()
|
||||
+ "/" + maximum.stripTrailingZeros().toPlainString());
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static Component statButton(Stat stat, StatView view) {
|
||||
return Component.text("[+1 " + stat + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(STATS,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + stat + "\"")));
|
||||
}
|
||||
|
||||
private static java.util.List<Component> activePages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.ability.AbilityDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder();
|
||||
builder.text("Active abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.loadout().size(); slot++) {
|
||||
String id = record.loadout().get(slot);
|
||||
var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 3) + ": No Ability");
|
||||
else if (definition == null) builder.text((slot + 3) + ": " + id);
|
||||
else builder.link((slot + 3) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
var unassigned = record.learned().stream().filter(id -> !record.loadout().contains(id)).sorted().toList();
|
||||
if (!unassigned.isEmpty()) {
|
||||
builder.blank(); builder.text("Unassigned learned:");
|
||||
for (var id : unassigned) {
|
||||
var definition = byId.get(id);
|
||||
if (definition == null) builder.text(id);
|
||||
else builder.link("", definition.name(), targets.get(id));
|
||||
}
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveSlotPages(CharacterRecord record,
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
|
||||
var byId = definitions.stream().collect(java.util.stream.Collectors.toMap(
|
||||
games.dmg.fantasystick.passive.PassiveDefinition::id, java.util.function.Function.identity()));
|
||||
var builder = new PageBuilder(); builder.text("Passive abilities"); builder.blank();
|
||||
for (int slot = 0; slot < record.passives().size(); slot++) {
|
||||
String id = record.passives().get(slot); var definition = byId.get(id);
|
||||
if (id.isEmpty()) builder.text((slot + 1) + ": No Passive");
|
||||
else if (definition == null) builder.text((slot + 1) + ": " + id);
|
||||
else builder.link((slot + 1) + ": ", definition.name(), targets.get(id));
|
||||
}
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private record AbilityIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByAbility) {
|
||||
private AbilityIndex { pages = java.util.List.copyOf(pages); relativePageByAbility = Map.copyOf(relativePageByAbility); }
|
||||
}
|
||||
|
||||
private static AbilityIndex indexPages(
|
||||
java.util.List<games.dmg.fantasystick.ability.AbilityDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
var relativePages = new java.util.HashMap<String, Integer>();
|
||||
for (var category : games.dmg.fantasystick.ability.AbilityDefinition.Category.values()) {
|
||||
var grouped = definitions.stream().filter(definition -> definition.category() == category).toList();
|
||||
int cursor = 0;
|
||||
while (cursor < grouped.size()) {
|
||||
var builder = new PageBuilder(); builder.text("Ability index"); builder.text(category.name());
|
||||
int usedLines = 0;
|
||||
while (cursor < grouped.size()) {
|
||||
var definition = grouped.get(cursor);
|
||||
int linkLines = BookPagination.lines(definition.name()).size();
|
||||
if (usedLines > 0 && usedLines + linkLines > 11) break;
|
||||
relativePages.put(definition.id(), pages.size());
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
usedLines += linkLines; cursor++;
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
}
|
||||
return new AbilityIndex(pages, relativePages);
|
||||
}
|
||||
|
||||
private record PassiveIndex(java.util.List<Component> pages, Map<String, Integer> relativePageByPassive) {
|
||||
private PassiveIndex { pages = java.util.List.copyOf(pages); relativePageByPassive = Map.copyOf(relativePageByPassive); }
|
||||
}
|
||||
|
||||
private static PassiveIndex passiveIndexPages(
|
||||
java.util.List<games.dmg.fantasystick.passive.PassiveDefinition> definitions, Map<String, Integer> targets) {
|
||||
var pages = new java.util.ArrayList<Component>(); var relativePages = new java.util.HashMap<String, Integer>();
|
||||
int cursor = 0;
|
||||
while (cursor < definitions.size()) {
|
||||
var builder = new PageBuilder(); builder.text("Passive index"); builder.blank();
|
||||
int usedLines = 0;
|
||||
while (cursor < definitions.size()) {
|
||||
var definition = definitions.get(cursor);
|
||||
int linkLines = BookPagination.lines(definition.name()).size();
|
||||
if (usedLines > 0 && usedLines + linkLines > 11) break;
|
||||
relativePages.put(definition.id(), pages.size());
|
||||
builder.link("", definition.name(), targets.get(definition.id()));
|
||||
usedLines += linkLines; cursor++;
|
||||
}
|
||||
pages.addAll(builder.pages());
|
||||
}
|
||||
return new PassiveIndex(pages, relativePages);
|
||||
}
|
||||
|
||||
private static java.util.List<Component> passiveDetailPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.passive.PassiveDefinition definition, Map<String, String> names, PassiveView view,
|
||||
int indexPage) {
|
||||
String requirements = definition.requirements().entrySet().stream().sorted(Map.Entry.comparingByKey())
|
||||
.map(entry -> entry.getKey() + " " + entry.getValue()).collect(java.util.stream.Collectors.joining(", "));
|
||||
if (!definition.prerequisites().isEmpty()) {
|
||||
String prerequisiteNames = definition.prerequisites().stream().map(id -> names.getOrDefault(id, id)).sorted()
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
requirements = requirements.isEmpty() ? prerequisiteNames : prerequisiteNames + ", " + requirements;
|
||||
}
|
||||
boolean learned = record.passives().contains(definition.id());
|
||||
boolean prerequisites = record.passives().containsAll(definition.prerequisites());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.passives().contains("");
|
||||
String state = learned ? "Learned" : !prerequisites || !stats ? "Unavailable: requirements"
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: passive slots" : "Available";
|
||||
var builder = new PageBuilder(); builder.text(definition.name()); builder.text(requirements);
|
||||
if (view == null) builder.text(state);
|
||||
else if (learned) builder.component(passiveButton("Unlearn", PASSIVE_UNLEARN, view, definition.id()));
|
||||
else if (prerequisites && stats && points && slot) builder.component(passiveButton("Learn", PASSIVE_UNLOCK, view, definition.id()));
|
||||
else builder.text(state);
|
||||
builder.blank(); definition.description().forEach(builder::text); builder.blank(); builder.text(state);
|
||||
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static java.util.List<Component> abilityPages(CharacterRecord record,
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view) {
|
||||
games.dmg.fantasystick.ability.AbilityDefinition definition, AbilityView view, int indexPage) {
|
||||
String requirements = definition.requirements().isEmpty() ? "none" : definition.requirements().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + " " + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
boolean learned = record.learned().contains(definition.id());
|
||||
boolean stats = definition.requirements().entrySet().stream()
|
||||
.allMatch(entry -> record.stats().get(entry.getKey()).compareTo(entry.getValue()) >= 0);
|
||||
boolean points = record.unspentPoints().compareTo(definition.pointCost()) >= 0;
|
||||
boolean slot = record.loadout().contains("");
|
||||
String state = learned ? "Learned" : !stats ? "Unavailable: requirements"
|
||||
: record.unspentPoints().compareTo(definition.pointCost()) < 0 ? "Unavailable: points" : "Available";
|
||||
String parameters = definition.parameters().isEmpty() ? "" : "\nParameters: " + definition.parameters().entrySet().stream()
|
||||
: !points ? "Unavailable: points" : !slot ? "Unavailable: slots" : "Available";
|
||||
var builder = new PageBuilder();
|
||||
builder.text(definition.name());
|
||||
builder.text(definition.category().name());
|
||||
builder.text(requirements);
|
||||
if (view == null) builder.text(state);
|
||||
else if (learned) builder.component(abilityButton("Unlearn", ABILITY_UNLEARN, view, definition.id()));
|
||||
else if (stats && points && slot) builder.component(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id()));
|
||||
else builder.text(state);
|
||||
builder.blank();
|
||||
definition.lore().forEach(builder::text);
|
||||
builder.blank(); builder.text(definition.instructions());
|
||||
builder.text("Cooldown: " + durationSeconds(definition.cooldown()) + "s");
|
||||
if (!definition.parameters().isEmpty()) builder.text("Parameters: " + definition.parameters().entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()).map(entry -> entry.getKey() + "=" + entry.getValue())
|
||||
.collect(java.util.stream.Collectors.joining(", "));
|
||||
String text = definition.name() + "\nIcon: " + definition.icon().name()
|
||||
+ "\n\n" + String.join("\n", definition.lore()) + "\n\n" + definition.instructions()
|
||||
+ "\nRequires: " + requirements + "\nCost: " + definition.pointCost()
|
||||
+ "\nCooldown: " + durationSeconds(definition.cooldown()) + "s" + parameters + "\n\n" + state;
|
||||
var pages = new java.util.ArrayList<>(BookPagination.pages(text));
|
||||
if (!learned && stats && record.unspentPoints().compareTo(definition.pointCost()) >= 0) {
|
||||
pages.add(Component.text("Ability action\n\n").append(abilityButton("Learn", ABILITY_UNLOCK, view, definition.id(), null)));
|
||||
} else if (learned) {
|
||||
Component action = Component.text("Assign ability\n\nSlot:\n");
|
||||
for (int slot = 0; slot < 7; slot++) {
|
||||
if (slot > 0) action = action.append(slot == 4 ? Component.newline() : Component.space());
|
||||
action = action.append(abilityButton(Integer.toString(slot + 3), ABILITY_ASSIGN, view, definition.id(), slot));
|
||||
.collect(java.util.stream.Collectors.joining(", ")));
|
||||
builder.text(state);
|
||||
builder.component(Component.text("[Back to index]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(indexPage)));
|
||||
return builder.pages();
|
||||
}
|
||||
|
||||
private static final class PageBuilder {
|
||||
private final java.util.List<Component> lines = new java.util.ArrayList<>();
|
||||
void blank() { lines.add(Component.empty()); }
|
||||
void text(String text) { BookPagination.lines(text).forEach(line -> lines.add(Component.text(line))); }
|
||||
void component(Component component) { lines.add(component); }
|
||||
void inline(String text, Component action) {
|
||||
var wrapped = BookPagination.lines(text);
|
||||
for (int index = 0; index < wrapped.size() - 1; index++) lines.add(Component.text(wrapped.get(index)));
|
||||
var last = Component.text(wrapped.getLast());
|
||||
if (action != null && displayWidth(wrapped.getLast()) + 6 + 48 <= 108) {
|
||||
lines.add(last.append(Component.space()).append(action));
|
||||
} else {
|
||||
lines.add(last);
|
||||
if (action != null) lines.add(action);
|
||||
}
|
||||
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);
|
||||
void link(String prefix, String label, int page) {
|
||||
if (displayWidth(prefix + label) <= 108) {
|
||||
lines.add(Component.text(prefix).append(Component.text(label, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page))));
|
||||
return;
|
||||
}
|
||||
for (var part : BookPagination.lines(prefix + label)) {
|
||||
lines.add(Component.text(part, net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.changePage(page)));
|
||||
}
|
||||
}
|
||||
private static int displayWidth(String text) {
|
||||
return text.codePoints().map(codePoint -> codePoint < 128 ? 6 : 9).sum();
|
||||
}
|
||||
java.util.List<Component> pages() {
|
||||
var pages = new java.util.ArrayList<Component>();
|
||||
for (int start = 0; start < lines.size(); start += 13) {
|
||||
Component page = Component.empty();
|
||||
for (int index = start; index < Math.min(start + 13, lines.size()); index++) {
|
||||
if (index > start) page = page.append(Component.newline());
|
||||
page = page.append(lines.get(index));
|
||||
}
|
||||
pages.add(page);
|
||||
}
|
||||
if (pages.isEmpty()) pages.add(Component.empty());
|
||||
return java.util.List.copyOf(pages);
|
||||
}
|
||||
}
|
||||
|
||||
private static String durationSeconds(java.time.Duration duration) {
|
||||
@@ -197,8 +558,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
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) + "\"";
|
||||
String ability) {
|
||||
String payload = "\"" + view.nonce() + ":" + ability + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
}
|
||||
|
||||
private static Component passiveButton(String label, net.kyori.adventure.key.Key action, PassiveView view,
|
||||
String passive) {
|
||||
String payload = "\"" + view.nonce() + ":" + passive + "\"";
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_GREEN)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(action,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder(payload)));
|
||||
@@ -210,6 +579,28 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
private static Component adminPageButton(String label, AdminView view, int direction) {
|
||||
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
|
||||
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(ADMIN_PAGES,
|
||||
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void adminPageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
|
||||
var administrator = connection.getPlayer();
|
||||
var view = adminViews.get(administrator.getUniqueId());
|
||||
if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) return;
|
||||
String raw = event.getTag().string();
|
||||
if (raw.length() > 64) return;
|
||||
var match = PAGE_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !adminViews.remove(administrator.getUniqueId(), view)) return;
|
||||
loadReadOnly(administrator, view.target(), view.active(), offset);
|
||||
}
|
||||
|
||||
@org.bukkit.event.EventHandler
|
||||
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
if (closed || !PAGES.equals(event.getIdentifier())
|
||||
@@ -228,7 +619,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private void report(ControlInput input, Throwable failure) {
|
||||
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
|
||||
report(new Failure(input.session().player(), input.session().character(), failure));
|
||||
}
|
||||
|
||||
private void report(Failure failure) {
|
||||
try { failures.accept(failure); }
|
||||
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
|
||||
}
|
||||
|
||||
@@ -292,9 +687,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
@org.bukkit.event.EventHandler
|
||||
public void abilityAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = ABILITY_UNLOCK.equals(event.getIdentifier());
|
||||
boolean assign = ABILITY_ASSIGN.equals(event.getIdentifier());
|
||||
boolean unlearn = ABILITY_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || abilityEngine == null || !unlock && !assign && !unlearn
|
||||
if (closed || abilityEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer();
|
||||
@@ -304,12 +698,10 @@ 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))
|
||||
|| 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());
|
||||
: abilityEngine.unlearn(view.input(), ability, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
@@ -317,22 +709,66 @@ 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, unlearn, ability, mutation.status()));
|
||||
player.sendMessage(abilityMessage(unlock, ability, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, boolean unlearn, String ability,
|
||||
@org.bukkit.event.EventHandler
|
||||
public void passiveAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
|
||||
boolean unlock = PASSIVE_UNLOCK.equals(event.getIdentifier());
|
||||
boolean unlearn = PASSIVE_UNLEARN.equals(event.getIdentifier());
|
||||
if (closed || passiveEngine == null || !unlock && !unlearn
|
||||
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|
||||
|| event.getTag() == null) return;
|
||||
var player = connection.getPlayer(); var view = passiveViews.get(player.getUniqueId());
|
||||
if (view == null || view.input().player() != player) return;
|
||||
String raw = event.getTag().string(); if (raw.length() > 120) return;
|
||||
var match = ABILITY_PAYLOAD.matcher(raw);
|
||||
if (!match.matches() || !view.nonce().equals(match.group(1)) || !view.ids().contains(match.group(2))
|
||||
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
|
||||
String passive = match.group(2);
|
||||
var operation = unlock ? passiveEngine.purchase(view.input(), passive, view.revision())
|
||||
: passiveEngine.unlearn(view.input(), passive, view.revision());
|
||||
operation.whenComplete((mutation, failure) -> {
|
||||
if (closed || !view.input().stillCurrent()) return;
|
||||
if (failure != null) {
|
||||
report(view.input(), failure);
|
||||
player.sendMessage("Passive change could not be confirmed; reopen the character after session recovery.");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(passiveMessage(unlock, passive, mutation.status()));
|
||||
load(view.input(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
private static String passiveMessage(boolean unlock, String passive,
|
||||
CharacterStorage.PassiveMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + passive + " in the first available passive slot."
|
||||
: "Unlearned " + passive + " and refunded 1 point.";
|
||||
case REQUIREMENTS -> "The passive prerequisites or purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case PASSIVE_SLOTS_FULL -> "All three passive slots are occupied; unlearn a passive before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + passive + ".";
|
||||
case NOT_LEARNED -> "This character no longer knows " + passive + ".";
|
||||
case REQUIRED_BY_LEARNED -> "Another learned passive requires " + passive + ".";
|
||||
case STALE -> "That character page is stale; reopen it before changing passives.";
|
||||
case UNAVAILABLE -> "That character or passive is unavailable.";
|
||||
};
|
||||
}
|
||||
|
||||
private static String abilityMessage(boolean unlock, String ability,
|
||||
CharacterStorage.AbilityMutationStatus status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> unlock ? "Learned " + ability + "."
|
||||
: unlearn ? "Unlearned " + ability + " and refunded 1 point." : "Assigned " + ability + ".";
|
||||
case UNCHANGED -> ability + " is already assigned there.";
|
||||
case APPLIED -> unlock ? "Learned " + ability + " and assigned it to the first available slot."
|
||||
: "Unlearned " + ability + " and refunded 1 point.";
|
||||
case UNCHANGED -> ability + " is unchanged.";
|
||||
case REQUIREMENTS -> "The purchased base-stat requirements are not met.";
|
||||
case NO_POINTS -> "This character does not have enough unspent points.";
|
||||
case LOADOUT_FULL -> "All seven ability slots are occupied; unlearn an ability before learning another.";
|
||||
case ALREADY_LEARNED -> "This character already knows " + ability + ".";
|
||||
case NOT_LEARNED -> unlearn ? "This character no longer knows " + ability + "."
|
||||
: "Learn " + ability + " before assigning it.";
|
||||
case NOT_LEARNED -> "This character no longer knows " + ability + ".";
|
||||
case STALE -> "That character page is stale; reopen it before changing abilities.";
|
||||
case COOLDOWN -> "That ability is still on cooldown.";
|
||||
case UNAVAILABLE -> "That character or ability is unavailable.";
|
||||
@@ -346,13 +782,16 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
|
||||
var owner = event.getPlayer().getUniqueId();
|
||||
pending.remove(owner);
|
||||
adminPending.remove(owner);
|
||||
adminViews.remove(owner);
|
||||
views.remove(owner);
|
||||
statViews.remove(owner);
|
||||
abilityViews.remove(owner);
|
||||
passiveViews.remove(owner);
|
||||
partyControls.forget(owner);
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true; pending.clear(); views.clear(); statViews.clear(); abilityViews.clear(); partyControls.close();
|
||||
closed = true; pending.clear(); adminPending.clear(); adminViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); partyControls.close();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
|
||||
public List<Component> pages(ControlInput input) { return render(input, Menu.ROOT, 0); }
|
||||
|
||||
/** Informational party pages with no registered action or retained viewer state. */
|
||||
public List<Component> readOnlyPages(UUID account) {
|
||||
if (closed) return List.of();
|
||||
return rootPages(Objects.requireNonNull(account));
|
||||
}
|
||||
|
||||
private List<Component> render(ControlInput input, Menu menu, int offset) {
|
||||
if (closed || !input.stillCurrent()) return List.of();
|
||||
var build = new Pages(input);
|
||||
@@ -61,30 +67,7 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
var party = parties.partyOf(owner);
|
||||
if (menu == Menu.PLAYERS && (party.isEmpty() || !party.orElseThrow().leader().equals(owner))) menu = Menu.ROOT;
|
||||
if (menu == Menu.ROOT) {
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
summary = summary.append(build.button("Create party", () -> mutate(input, () -> parties.create(owner).isPresent())))
|
||||
.append(Component.newline()).append(build.button("Invitations", () -> show(input, Menu.INVITATIONS, 0)));
|
||||
build.pages.add(summary);
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
boolean leader = group.leader().equals(owner);
|
||||
summary = summary.append(Component.text(group.members().size() + "/6 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())
|
||||
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
|
||||
}
|
||||
build.pages.add(summary);
|
||||
for (var member : group.members()) {
|
||||
Component row = Component.text(member.equals(group.leader()) ? "Leader\n\n" : "Member\n\n").append(names.apply(member));
|
||||
if (leader && !member.equals(owner)) row = row.append(Component.text("\n\n"))
|
||||
.append(build.button("Kick", () -> mutate(input, () -> parties.kick(owner, group.id(), member))));
|
||||
build.pages.add(row);
|
||||
}
|
||||
}
|
||||
build.pages.addAll(rootPages(owner));
|
||||
} else if (menu == Menu.PLAYERS) {
|
||||
var group = party.orElseThrow();
|
||||
var candidates = server.getOnlinePlayers().stream().filter(player -> player.isOnline())
|
||||
@@ -120,6 +103,26 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
return List.copyOf(build.pages);
|
||||
}
|
||||
|
||||
private List<Component> rootPages(UUID owner) {
|
||||
var pages = new ArrayList<Component>();
|
||||
var party = parties.partyOf(owner);
|
||||
Component summary = Component.text("Party\n\n");
|
||||
if (party.isEmpty()) {
|
||||
pages.add(summary.append(Component.text("No party.\n\nVisit a Party\nRegistrar.\nCreate, join, or\nmanage one.")));
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
summary = summary.append(Component.text(group.characters().size() + " members\n" + group.members().size()
|
||||
+ " active\n\nManage this party\nonly at a Party\nRegistrar."));
|
||||
pages.add(summary);
|
||||
for (var member : group.characters()) {
|
||||
Component label = parties.activePlayer(member).map(names)
|
||||
.orElse(Component.text("Character\n" + member.toString().substring(0, 8)));
|
||||
pages.add(Component.text(member.equals(group.leaderCharacter()) ? "Leader\n\n" : "Member\n\n").append(label));
|
||||
}
|
||||
}
|
||||
return List.copyOf(pages);
|
||||
}
|
||||
|
||||
private static int start(int requested, int size) {
|
||||
return Math.min(Math.max(0, requested), Math.max(0, (size - 1) / PAGE_SIZE * PAGE_SIZE));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package games.dmg.fantasystick.combat;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.presentation.CombatFeedback;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
@@ -23,6 +25,8 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
private static final Duration CADENCE = Duration.ofSeconds(1);
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final CombatFeedback feedback;
|
||||
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
|
||||
private final Map<UUID, Instant> readyAt = new HashMap<>();
|
||||
private Pending pending;
|
||||
private boolean closed;
|
||||
@@ -31,19 +35,31 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
final ControlInput input;
|
||||
final LivingEntity target;
|
||||
final Instant attackedAt;
|
||||
final boolean knockback;
|
||||
boolean accepted;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt;
|
||||
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
|
||||
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
|
||||
}
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions) {
|
||||
this(sessions, Clock.systemUTC());
|
||||
this(sessions, Clock.systemUTC(), null);
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock) {
|
||||
this(sessions, clock, null);
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback) {
|
||||
this(sessions, clock, feedback, games.dmg.fantasystick.training.TrainingDamage.passthrough());
|
||||
}
|
||||
|
||||
public BasicAttack(EquipmentSessions sessions, Clock clock, CombatFeedback feedback,
|
||||
games.dmg.fantasystick.training.TrainingDamage trainingDamage) {
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.feedback = feedback;
|
||||
this.trainingDamage = Objects.requireNonNull(trainingDamage);
|
||||
}
|
||||
|
||||
@Override public void accept(ControlInput input) {
|
||||
@@ -57,30 +73,43 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
|
||||
if (ready != null && now.isBefore(ready)) return;
|
||||
BigInteger damage = damage(record.stats().get(Stat.STR));
|
||||
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
|
||||
var attempt = new Pending(input, target, now);
|
||||
var attempt = new Pending(input, target, now,
|
||||
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
|
||||
pending = attempt;
|
||||
try {
|
||||
target.damage(nativeDamage, input.player());
|
||||
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
|
||||
measured.ifPresent(amount -> {
|
||||
attempt.accepted = true;
|
||||
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
|
||||
});
|
||||
} finally {
|
||||
if (pending == attempt) pending = null;
|
||||
if (attempt.accepted) readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.accepted) {
|
||||
readyAt.put(input.session().character(), attempt.attackedAt.plus(CADENCE));
|
||||
if (attempt.knockback && !trainingDamage.target(target) && input.stillCurrent()
|
||||
&& sessions.current(input.session())) {
|
||||
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static BigInteger damage(BigInteger strength) {
|
||||
Objects.requireNonNull(strength);
|
||||
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).max(BigInteger.ONE);
|
||||
return strength.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.ONE).max(BigInteger.ONE);
|
||||
}
|
||||
|
||||
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = pending;
|
||||
if (closed || attempt == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
|
||||
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
|
||||
attempt.accepted = true;
|
||||
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
|
||||
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
|
||||
@@ -19,12 +19,26 @@ public final class CharacterFactory {
|
||||
"Hollow", "Lark", "Mist", "Reed", "Stone", "Thorn", "Vale", "Willow"};
|
||||
private final Supplier<UUID> identities;
|
||||
private final RandomGenerator names;
|
||||
private final Supplier<games.dmg.fantasystick.progression.CoinRules> coins;
|
||||
private final RandomGenerator coinRandom;
|
||||
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault()); }
|
||||
public CharacterFactory() { this(UUID::randomUUID, RandomGenerator.getDefault(),
|
||||
games.dmg.fantasystick.progression.CoinRules::defaults, RandomGenerator.getDefault()); }
|
||||
|
||||
public CharacterFactory(Supplier<games.dmg.fantasystick.progression.CoinRules> coins) {
|
||||
this(UUID::randomUUID, RandomGenerator.getDefault(), coins, RandomGenerator.getDefault());
|
||||
}
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names) {
|
||||
this(identities, names, games.dmg.fantasystick.progression.CoinRules::defaults, names);
|
||||
}
|
||||
|
||||
CharacterFactory(Supplier<UUID> identities, RandomGenerator names,
|
||||
Supplier<games.dmg.fantasystick.progression.CoinRules> coins, RandomGenerator coinRandom) {
|
||||
this.identities = Objects.requireNonNull(identities);
|
||||
this.names = Objects.requireNonNull(names);
|
||||
this.coins = Objects.requireNonNull(coins);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
}
|
||||
|
||||
public synchronized CharacterRecord create(ProgressionRules rules) {
|
||||
@@ -34,6 +48,7 @@ public final class CharacterFactory {
|
||||
+ FAMILY_NAMES[names.nextInt(FAMILY_NAMES.length)];
|
||||
return new CharacterRecord(initial.characterId(), name, initial.revision(), initial.stats(),
|
||||
initial.level(), initial.experience(), initial.unspentPoints(), initial.currentHealth(),
|
||||
Set.of(), Collections.nCopies(7, ""), Map.of(), false, null, null);
|
||||
Set.of(), Collections.nCopies(7, ""), java.util.List.of("", "", ""), Map.of(), Map.of(),
|
||||
false, null, null, coins.get().starting(coinRandom), true, Set.of());
|
||||
}
|
||||
}
|
||||
@@ -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"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.*;
|
||||
import games.dmg.fantasystick.adventure.AdventureAdminTeleport;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Registered command adapter for durable dungeon authoring and catalog administration. */
|
||||
public final class DungeonCommands {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
private final Plugin plugin;private final DungeonDirectory directory;private final DungeonController controller;private final games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures;private final Consumer<Runnable> nextTick;private final games.dmg.fantasystick.adventure.AdventureLocationPresentation locations;private final Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters;private final Predicate<UUID> activeCharacter;private final Predicate<UUID> partyCharacter;private final CharacterSheets sheets=new CharacterSheets();private final AdventureAdminTeleport teleports;
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
public void execute(CommandSender sender,String[] args){if(!authorized(sender))return;if(args.length<2){usage(sender);return;}try{switch(args[1]){case"create"->create(sender,args);case"list"->list(sender,args);case"remove"->remove(sender,args);case"edit"->edit(sender,args);case"entrance"->entrance(sender,args);case"room"->room(sender,args);case"boss"->bossCommand(sender,args);case"teleport"->teleport(sender,args);case"enable"->enable(sender,args);case"disable"->disable(sender,args);case"open"->open(sender,args);case"close"->close(sender,args);default->usage(sender);}}catch(IllegalArgumentException invalid){sender.sendMessage("Dungeon command refused: "+invalid.getMessage()+".");}}
|
||||
private void create(CommandSender sender,String[] args){if(args.length<3||!(sender instanceof Player player)){usage(sender);return;}var location=player.getLocation();String name=join(args,2);if(name.isBlank()){usage(sender);return;}directory.create(name,Objects.requireNonNull(location.getWorld()).getUID(),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Created disabled dungeon "+value.id()+" ("+value.name()+").":"Dungeon creation failed; desired state is unchanged.")));}
|
||||
private void list(CommandSender sender,String[] args){if(args.length!=2){usage(sender);return;}if(directory.snapshot().dungeons().isEmpty()){sender.sendMessage("No dungeons registered.");return;}directory.snapshot().dungeons().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(value->sender.sendMessage(value.id()+" "+value.name()+" at "+locations.format(value.world(),value.entrance().x(),value.entrance().y(),value.entrance().z())+"; "+(value.enabled()?"enabled":"disabled")+"; rooms="+value.rooms().size()+"; maximum="+value.maximumXp()+" XP, "+value.maximumCoins()+" coins."));}
|
||||
private void remove(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}if(directory.custodyForDungeon(id).isPresent()){sender.sendMessage("Recover character boss custody before removing this dungeon.");return;}controller.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->directory.remove(id)).whenComplete((removed,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon removal failed; desired state is retained.":removed.isPresent()?"Removed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void edit(CommandSender sender,String[] args){if(!(sender instanceof Player player)){usage(sender);return;}if(args.length==3&&args[2].equals("done")){sender.sendMessage(controller.done(player.getUniqueId())?"Dungeon editing ended; the dungeon remains disabled.":"No dungeon is being edited.");return;}if(args.length<4||args.length>5||!args[2].equals("start")||args.length==5&&!args[4].equals("confirm")){usage(sender);return;}UUID id=id(args[3]);if(id==null){usage(sender);return;}controller.start(player,id,args.length==5).whenComplete((status,failure)->respond(sender,()->{if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon edit transition failed",failure);sender.sendMessage("Dungeon edit transition failed; retained state is authoritative.");return;}sender.sendMessage(switch(status){case STARTED->"Editing dungeon "+id+"; it is disabled.";case CONFIRM_REQUIRED->"Dungeon has offered, accepted or progressed work; repeat with exact confirm to close and expire it.";case BUSY->"Dungeon editing refused: the editor or dungeon already has an editing lease.";case NOT_FOUND->"Dungeon not found.";});}));}
|
||||
private void entrance(CommandSender sender,String[] args){if(args.length!=3||!args[2].equals("set")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;var location=player.getLocation();if(location.getWorld()==null||!directory.find(dungeon).orElseThrow().world().equals(location.getWorld().getUID())){sender.sendMessage("Dungeon entrance must remain in its world.");return;}directory.setEntrance(dungeon,location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Updated dungeon entrance."));}
|
||||
private void room(CommandSender sender,String[] args){if(!(sender instanceof Player player)||args.length<3){usage(sender);return;}UUID dungeon=editing(player);if(dungeon==null)return;switch(args[2]){case"create"->roomCreate(sender,player,dungeon,args);case"list"->{if(args.length!=3){usage(sender);return;}var value=directory.find(dungeon).orElseThrow();if(value.rooms().isEmpty())sender.sendMessage("No rooms defined.");else value.rooms().stream().sorted(Comparator.comparing(v->v.id().toString())).forEach(v->sender.sendMessage(v.id()+" "+v.displayName()+" "+v.bounds()+"; mobs="+v.mobs().size()+"; effects="+v.effects().size()+"; reward="+v.reward().xp()+" XP, "+v.reward().coins()+" coins; boss="+(v.boss()==null?"none":v.boss().type().name().toLowerCase(Locale.ROOT))));}case"remove"->{if(args.length!=4){usage(sender);return;}UUID room=id(args[3]);if(room==null){usage(sender);return;}directory.removeRoom(dungeon,room).whenComplete((value,failure)->{if(failure==null&&value.isPresent())controller.definitionChanged(dungeon);respond(sender,()->sender.sendMessage(failure!=null?"Room removal refused: it would overlap, disconnect or could not be saved.":value.isPresent()?"Removed room "+room+".":"Room not found."));});}case"mob"->mob(sender,player,dungeon,args);case"effect"->effect(sender,player,dungeon,args);case"reward"->reward(sender,player,dungeon,args);case"boss"->boss(sender,player,dungeon,args);default->usage(sender);}}
|
||||
private void roomCreate(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length<6){usage(sender);return;}var location=player.getLocation();int x=location.getBlockX(),y=location.getBlockY()-1,z=location.getBlockZ();if(args[3].equals("corner")){if(args.length<7){usage(sender);return;}Integer xs=integer(args[4]),zs=integer(args[5]),height=integer(args[6]);if(xs==null||zs==null||height==null){usage(sender);return;}directory.addCornerRoom(dungeon,join(args,7),x,y,z,xs,zs,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}if(args[3].equals("radius")){Integer radius=integer(args[4]),height=integer(args[5]);if(radius==null||height==null){usage(sender);return;}directory.addRadiusRoom(dungeon,join(args,6),x,y,z,radius,height).whenComplete((value,failure)->roomSaved(sender,dungeon,value,failure));return;}usage(sender);}
|
||||
private void roomSaved(CommandSender sender,UUID dungeon,DungeonState.Room value,Throwable failure){if(failure==null)controller.definitionChanged(dungeon);String message=failure==null?"Created room "+value.id()+" ("+value.displayName()+").":roomFailure(failure);respond(sender,()->sender.sendMessage(message));}
|
||||
private static String roomFailure(Throwable failure){Throwable cause=failure;while((cause instanceof java.util.concurrent.CompletionException||cause instanceof java.util.concurrent.ExecutionException)&&cause.getCause()!=null)cause=cause.getCause();String detail=cause.getMessage();if(cause instanceof IllegalArgumentException&&"Rooms overlap".equals(detail))return "Room creation refused: it overlaps an existing room.";if(cause instanceof IllegalArgumentException&&detail!=null&&detail.startsWith("Room exceeds configured link distance of ")){String blocks=detail.substring("Room exceeds configured link distance of ".length()).replace(" blocks","");return "Room creation refused: nearest room is farther than the configured "+blocks+"-block link distance.";}if(cause instanceof IllegalArgumentException&&(detail!=null&&(detail.equals("Room dimensions out of range")||detail.equals("Room limit reached"))))return "Room creation refused: dimensions or room limit are out of range.";if(cause instanceof java.io.IOException)return "Room creation failed: persistence is unavailable; desired state is unchanged.";return "Room creation refused: geometry validation failed; desired state is unchanged.";}
|
||||
private void mob(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=5&&args[3].equals("add")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living mob type.");return;}var location=player.getLocation();directory.addMob(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Added mob slot "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeMob(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed mob slot "+id+".":"Mob slot not found."));return;}if(args.length==4&&args[3].equals("list")){if(room.mobs().isEmpty())sender.sendMessage("No ordinary mob slots in this room.");else room.mobs().forEach(value->sender.sendMessage(value.id()+" "+value.type().name().toLowerCase(Locale.ROOT)+" "+value.name()));return;}usage(sender);}
|
||||
private void effect(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length>=7&&args[3].equals("add")){DungeonState.EffectTarget target;try{target=DungeonState.EffectTarget.valueOf(args[4].toUpperCase(Locale.ROOT));}catch(IllegalArgumentException invalid){usage(sender);return;}String key=args[5].contains(":")?args[5]:"minecraft:"+args[5];var namespaced=NamespacedKey.fromString(key);Integer level=integer(args[6]);boolean particles=args.length<8||Boolean.parseBoolean(args[7]);if(namespaced==null||PotionEffectType.getByKey(namespaced)==null||level==null||args.length>8||args.length==8&&!Set.of("true","false").contains(args[7])){usage(sender);return;}directory.addEffect(dungeon,room.id(),target,key,level,particles).whenComplete((value,failure)->saved(sender,failure,"Added room effect "+(value==null?"":value.id())+"."));return;}if(args.length==5&&args[3].equals("remove")){UUID id=id(args[4]);if(id==null){usage(sender);return;}directory.removeEffect(dungeon,room.id(),id).whenComplete((value,failure)->saved(sender,failure,value!=null&&value.isPresent()?"Removed room effect "+id+".":"Room effect not found."));return;}usage(sender);}
|
||||
private void reward(CommandSender sender,Player player,UUID dungeon,String[] args){if(args.length!=6||!args[3].equals("set")){usage(sender);return;}var room=standing(sender,player);if(room==null)return;BigInteger xp=whole(args[4]),coins=whole(args[5]);if(xp==null||coins==null){usage(sender);return;}directory.setReward(dungeon,room.id(),xp,coins).whenComplete((value,failure)->saved(sender,failure,"Set room reward to "+xp+" XP and "+coins+" coins."));}
|
||||
private void boss(CommandSender sender,Player player,UUID dungeon,String[] args){var room=standing(sender,player);if(room==null)return;if(args.length==5&&args[3].equals("assign-sheet")){assignCharacterBoss(sender,player,dungeon,room,args[4]);return;}if(args.length>=5&&args[3].equals("mob")){var type=entity(args[4]);if(!ordinary(type)){sender.sendMessage("Unsupported living boss type.");return;}var location=player.getLocation();directory.setBoss(dungeon,room.id(),type,join(args,5),location.getX(),location.getY(),location.getZ(),location.getYaw(),location.getPitch()).whenComplete((value,failure)->saved(sender,failure,"Configured native boss "+type.name().toLowerCase(Locale.ROOT)+"."));return;}if(args.length==4&&args[3].equals("clear")){directory.clearBoss(dungeon,room.id()).whenComplete((value,failure)->saved(sender,failure,"Cleared the room boss."));return;}usage(sender);}
|
||||
private void assignCharacterBoss(CommandSender sender,Player player,UUID dungeon,DungeonState.Room room,String rawMultiplier){BigDecimal multiplier;try{if(!rawMultiplier.matches("(?:0|[1-9][0-9]*)(?:\\.[0-9]{1,3})?"))throw new NumberFormatException();multiplier=new BigDecimal(rawMultiplier);}catch(NumberFormatException invalid){usage(sender);return;}var held=player.getInventory().getItemInMainHand();var character=sheets.identify(held).orElse(null);if(character==null||held.getAmount()!=1){sender.sendMessage("Hold exactly one authentic living character sheet in your main hand.");return;}if(physicalCopies(character)!=1){sender.sendMessage("Character boss assignment requires one unambiguous physical sheet.");return;}if(activeCharacter.test(character)){sender.sendMessage("That character has an active equipment session.");return;}if(partyCharacter.test(character)){sender.sendMessage("That character must leave its party before boss assignment.");return;}characters.apply(character).whenComplete((loaded,failure)->{if(failure!=null||loaded==null||loaded.isEmpty()||loaded.orElseThrow().dead()||loaded.orElseThrow().health().signum()<=0){respond(sender,()->sender.sendMessage("Character boss assignment refused: the saved living character could not be verified."));return;}var expected=loaded.orElseThrow();directory.assignCharacterBoss(dungeon,room.id(),expected,multiplier).whenComplete((custody,saveFailure)->{if(saveFailure!=null){respond(sender,()->sender.sendMessage("Character boss assignment failed before sheet custody changed."));return;}characters.apply(character).whenComplete((fresh,readFailure)->respond(sender,()->{if(readFailure!=null||fresh==null||fresh.isEmpty()||!fresh.orElseThrow().equals(expected)||activeCharacter.test(character)||partyCharacter.test(character)){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the saved character changed.":"Character boss custody was saved but assignment rollback could not be confirmed; recover it before editing.")));return;}var current=player.getInventory().getItemInMainHand();if(!player.isOnline()||physicalCopies(character)!=1||current.getAmount()!=1||sheets.identify(current).filter(character::equals).isEmpty()){directory.cancelAssignment(dungeon,custody.operation()).whenComplete((ignored,rollbackFailure)->respond(sender,()->sender.sendMessage(rollbackFailure==null?"Character boss assignment cancelled because the held sheet changed.":"Character boss custody was saved but sheet consumption could not be confirmed; recover it before editing.")));return;}player.getInventory().setItemInMainHand(null);sender.sendMessage("Assigned character "+custody.character().name()+" as the dungeon boss at "+custody.multiplier().toPlainString()+"x stats; its sheet is now in durable custody.");}));});});}
|
||||
private void bossCommand(CommandSender sender,String[] args){if(args.length!=4||!args[2].equals("recover-sheet")||!(sender instanceof Player player)){usage(sender);return;}UUID dungeon=id(args[3]);if(dungeon==null){usage(sender);return;}recoverCharacterBoss(sender,player,dungeon);}
|
||||
private void recoverCharacterBoss(CommandSender sender,Player player,UUID dungeon){var definition=directory.find(dungeon).orElse(null);var custody=directory.custodyForDungeon(dungeon).orElse(null);if(definition==null||custody==null){sender.sendMessage("Dungeon character boss custody was not found.");return;}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeon);boolean accepted=adventures.active().stream().anyMatch(quest->quest.site().equals(dungeon));if(definition.enabled()||offered||accepted||controller.beingEdited(dungeon)||controller.editing(player.getUniqueId()).isPresent()||controller.occupied(dungeon)){sender.sendMessage("Recovery requires a disabled, closed, unedited and unoccupied dungeon with no accepted quests.");return;}var physical=plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents()).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(custody.character().id()::equals).isPresent()).map(item->Map.entry(holder,item))).toList();if(physical.size()>1||physical.size()==1&&physical.getFirst().getKey()!=player){sender.sendMessage("Recovery refused because existing physical-sheet custody is ambiguous.");return;}boolean delivered=!physical.isEmpty()||custody.recovery()!=null&&Arrays.stream(player.getInventory().getContents()).anyMatch(item->sheets.hasRecovery(item,custody.recovery().operation()));if(!delivered&&player.getInventory().firstEmpty()<0){sender.sendMessage("One empty inventory slot is required before recovery.");return;}directory.prepareRecovery(dungeon,player.getUniqueId()).whenComplete((prepared,failure)->respond(sender,()->{if(failure!=null){sender.sendMessage("Character boss recovery could not be prepared; custody is retained.");return;}var operation=prepared.recovery().operation();org.bukkit.inventory.ItemStack sheet=null;for(var item:player.getInventory().getContents())if(sheets.hasRecovery(item,operation)||sheets.identify(item).filter(prepared.character().id()::equals).isPresent()){sheet=item;break;}if(sheet==null){int slot=player.getInventory().firstEmpty();if(slot<0){sender.sendMessage("Character boss recovery is prepared, but one empty inventory slot is required.");return;}sheet=sheets.create(prepared.character());sheets.markRecovery(sheet,operation);player.getInventory().setItem(slot,sheet);}var item=sheet;directory.completeRecovery(dungeon,operation).whenComplete((completed,completeFailure)->respond(sender,()->{if(completeFailure!=null||!completed){sender.sendMessage("Recovery sheet delivery is retained, but durable custody completion must be retried.");return;}sheets.clearRecovery(item);sender.sendMessage("Recovered character sheet for "+prepared.character().name()+" from dungeon "+dungeon+"; configure another boss before enabling it.");}));}));}
|
||||
private void teleport(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}if(!(sender instanceof Player)){sender.sendMessage("Dungeon teleport requires a player.");return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}var dungeon=directory.find(id).orElse(null);if(dungeon==null){sender.sendMessage("Dungeon not found.");return;}teleports.teleport(sender,"Dungeon",target(dungeon),()->directory.find(id).map(DungeonCommands::target));}
|
||||
private static AdventureAdminTeleport.Target target(DungeonState.Dungeon dungeon){var entrance=dungeon.entrance();return new AdventureAdminTeleport.Target(dungeon.id(),dungeon.name(),"dungeon entrance",dungeon.world(),entrance.x(),entrance.y(),entrance.z(),entrance.yaw(),entrance.pitch(),false);}
|
||||
private void enable(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.enable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Enabled dungeon "+id+" for later adventure catalogs.":"Dungeon enable refused: validate rooms, boss, overlap and persistence.")));}
|
||||
private void disable(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.disable(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon disable failed; retained state is authoritative.":value?"Disabled and closed dungeon "+id+".":"Dungeon not found.")));}
|
||||
private void open(CommandSender sender,String[] args){if(args.length!=3){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.open(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure==null?"Opened dungeon "+id+" in generation "+value.revision()+".":"Dungeon open refused: it is invalid, disabled, defeated or the offer limit was reached.")));}
|
||||
private void close(CommandSender sender,String[] args){if(args.length!=4||!args[3].equals("confirm")){usage(sender);return;}UUID id=id(args[2]);if(id==null){usage(sender);return;}controller.closeOffer(id).whenComplete((value,failure)->respond(sender,()->sender.sendMessage(failure!=null?"Dungeon close failed; retained state is authoritative.":value?"Closed dungeon "+id+" for this generation.":"Dungeon was not open.")));}
|
||||
public List<String> complete(CommandSender sender,String[] args){if(!sender.hasPermission(PERMISSION))return List.of();if(args.length==1)return matching(List.of("dungeon"),args[0]);if(args.length==2&&args[0].equals("dungeon"))return matching(sender instanceof Player?List.of("boss","close","create","disable","edit","enable","entrance","list","open","remove","room","teleport"):List.of("close","disable","enable","list","open","remove"),args[1]);if(!args[0].equals("dungeon"))return List.of();if(args.length==3&&args[1].equals("boss"))return matching(List.of("recover-sheet"),args[2]);if(args.length==4&&args[1].equals("boss")&&args[2].equals("recover-sheet"))return matching(directory.snapshot().dungeons().stream().filter(value->recoverable(value.id())).map(value->value.id().toString()).toList(),args[3]);if(args.length==3&&Set.of("close","disable","enable","open","remove","teleport").contains(args[1]))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[2]);if(args.length==4&&Set.of("close","disable","remove").contains(args[1]))return matching(List.of("confirm"),args[3]);if(args.length==3&&args[1].equals("edit"))return matching(List.of("done","start"),args[2]);if(args.length==4&&args[1].equals("edit")&&args[2].equals("start"))return matching(directory.snapshot().dungeons().stream().map(v->v.id().toString()).toList(),args[3]);if(args.length==5&&args[1].equals("edit")&&args[2].equals("start"))return matching(List.of("confirm"),args[4]);if(args.length==3&&args[1].equals("entrance"))return matching(List.of("set"),args[2]);if(args.length==3&&args[1].equals("room"))return matching(List.of("boss","create","effect","list","mob","remove","reward"),args[2]);if(args.length==4&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("corner","radius"),args[3]);if(args.length>=5&&args.length<=7&&args[1].equals("room")&&args[2].equals("create"))return matching(List.of("1","3","8","128","256"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("remove")&&sender instanceof Player player){var id=controller.editing(player.getUniqueId()).orElse(null);return id==null?List.of():matching(directory.find(id).map(DungeonState.Dungeon::rooms).orElse(List.of()).stream().map(v->v.id().toString()).toList(),args[3]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("mob"))return matching(List.of("add","list","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("add"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("mob")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.mobs().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("effect"))return matching(List.of("add","remove"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("both","mobs","players"),args[4]);if(args.length==6&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(effectNames(),args[5]);if(args.length==7&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("1","2","64","256"),args[6]);if(args.length==8&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("add"))return matching(List.of("false","true"),args[7]);if(args.length==5&&args[1].equals("room")&&args[2].equals("effect")&&args[3].equals("remove")&&sender instanceof Player player){var room=controller.standingRoom(player).orElse(null);return room==null?List.of():matching(room.effects().stream().map(v->v.id().toString()).toList(),args[4]);}if(args.length==4&&args[1].equals("room")&&args[2].equals("reward"))return matching(List.of("set"),args[3]);if((args.length==5||args.length==6)&&args[1].equals("room")&&args[2].equals("reward")&&args[3].equals("set"))return matching(List.of("0","10","100","2147483647"),args[args.length-1]);if(args.length==4&&args[1].equals("room")&&args[2].equals("boss"))return matching(List.of("assign-sheet","clear","mob"),args[3]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("mob"))return matching(entityNames(),args[4]);if(args.length==5&&args[1].equals("room")&&args[2].equals("boss")&&args[3].equals("assign-sheet"))return matching(List.of("0.1","1","1.5","10","100"),args[4]);return List.of();}
|
||||
private long physicalCopies(UUID character){return plugin.getServer().getOnlinePlayers().stream().flatMap(holder->Arrays.stream(holder.getInventory().getContents())).filter(Objects::nonNull).filter(item->sheets.identify(item).filter(character::equals).isPresent()).mapToLong(org.bukkit.inventory.ItemStack::getAmount).sum();}
|
||||
private boolean recoverable(UUID dungeon){var definition=directory.find(dungeon).orElse(null);var catalog=adventures.snapshot().catalog();return definition!=null&&!definition.enabled()&&directory.custodyForDungeon(dungeon).isPresent()&&!controller.beingEdited(dungeon)&&!controller.occupied(dungeon)&&(catalog==null||!adventures.offerOpen(dungeon))&&adventures.active().stream().noneMatch(quest->quest.site().equals(dungeon));}
|
||||
private UUID editing(Player player){var result=controller.editing(player.getUniqueId()).orElse(null);if(result==null)player.sendMessage("Start editing one dungeon first.");return result;}
|
||||
private DungeonState.Room standing(CommandSender sender,Player player){var result=controller.standingRoom(player).orElse(null);if(result==null)sender.sendMessage("Stand inside exactly one room of the dungeon being edited.");return result;}
|
||||
private void saved(CommandSender sender,Throwable failure,String success){respond(sender,()->sender.sendMessage(failure==null?success:"Dungeon edit failed; desired state is unchanged."));}
|
||||
private boolean authorized(CommandSender sender){if(sender.hasPermission(PERMISSION))return true;sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");return false;}
|
||||
private void respond(CommandSender sender,Runnable response){try{nextTick.accept(()->{if(sender.hasPermission(PERMISSION)&&(!(sender instanceof Player player)||player.isOnline()))response.run();});}catch(RuntimeException ignored){}}
|
||||
private static EntityType entity(String value){return Arrays.stream(EntityType.values()).filter(type->type.name().equalsIgnoreCase(value)||type.getKey().toString().equals(value)).findFirst().orElse(null);}
|
||||
private static boolean ordinary(EntityType type){if(type==null||!type.isSpawnable()||type==EntityType.PLAYER||type==EntityType.ENDER_DRAGON||type==EntityType.WITHER)return false;var entity=type.getEntityClass();return entity!=null&&Mob.class.isAssignableFrom(entity);}
|
||||
private static List<String> entityNames(){return Arrays.stream(EntityType.values()).filter(DungeonCommands::ordinary).flatMap(v->java.util.stream.Stream.of(v.name().toLowerCase(Locale.ROOT),v.getKey().toString())).distinct().sorted().toList();}
|
||||
@SuppressWarnings("deprecation") private static List<String> effectNames(){return Arrays.stream(PotionEffectType.values()).map(value->value.getKey().toString()).sorted().toList();}
|
||||
private static UUID id(String value){try{var id=UUID.fromString(value);return id.toString().equals(value)?id:null;}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static Integer integer(String value){try{return Integer.valueOf(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static BigInteger whole(String value){try{return new BigInteger(value);}catch(NumberFormatException invalid){return null;}}
|
||||
private static String join(String[] args,int from){return from>=args.length?"":String.join(" ",Arrays.copyOfRange(args,from,args.length));}
|
||||
private static List<String> matching(Collection<String> values,String prefix){return values.stream().filter(value->value.startsWith(prefix)).distinct().sorted().toList();}
|
||||
private static void usage(CommandSender sender){sender.sendMessage("Usage: /fantasystick dungeon <create|list|remove|edit|entrance|room|boss|enable|disable|open|close|teleport> ...");}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestDirectory;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.ability.*;
|
||||
import games.dmg.fantasystick.passive.PassiveRegistry;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.ProgressionRules;
|
||||
import games.dmg.fantasystick.progression.RewardAllocator;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.*;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.potion.*;
|
||||
|
||||
/** Transient exclusive editing leases, definition/catalog publication and editor-only room outlines. */
|
||||
public final class DungeonController implements Listener,AutoCloseable,games.dmg.fantasystick.adventure.AdventureResetParticipant {
|
||||
public enum EditStatus { STARTED, CONFIRM_REQUIRED, BUSY, NOT_FOUND }
|
||||
private record Edit(UUID dungeon,Map<UUID,Color> colors){}
|
||||
private static final String ENEMY="dungeon_enemy",PROJECTILE="dungeon_projectile";
|
||||
private record AttemptKey(UUID dungeon,UUID room){}
|
||||
private static final class Attempt {final UUID dungeon,room,generation;final Map<UUID,UUID> entities=new HashMap<>();final Set<UUID> represented=new HashSet<>();final Map<UUID,Double> threat=new HashMap<>();final Map<String,Long> cooldowns=new HashMap<>();final Map<String,Long> effects=new HashMap<>();UUID boss,target;long lastCombat=System.nanoTime();Attempt(UUID dungeon,UUID room,UUID generation){this.dungeon=dungeon;this.room=room;this.generation=generation;}}
|
||||
private record Context(UUID character,UUID party,Set<UUID> members){}
|
||||
private record OwnedEffect(UUID dungeon,UUID player,PotionEffectType type,int amplifier,PotionEffect prior){}
|
||||
private final Plugin plugin;private final DungeonDirectory dungeons;private final AdventureQuestDirectory adventures;private final EquipmentSessions sessions;private final PartyRoster parties;private final CharacterStorage characters;private final ProgressionRules progression;private final Consumer<Runnable> nextTick;private final java.util.random.RandomGenerator random;private final AbilityRegistry abilities;private final PassiveRegistry passives;private final java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings;private final java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus;private final java.util.function.Supplier<BigInteger> healthStealHealing;
|
||||
private final NamespacedKey typeKey,dungeonKey,roomKey,slotKey,generationKey,bossKey;
|
||||
private final Map<UUID,Edit> playerEdits=new HashMap<>();private final Map<UUID,UUID> dungeonEditors=new HashMap<>();private final Map<AttemptKey,Attempt> attempts=new HashMap<>();private final Set<String> startingRuns=new HashSet<>();private final Set<AttemptKey> completing=new HashSet<>();private final Set<UUID> defeatingDungeons=new HashSet<>();private final Set<CompletableFuture<?>> inFlight=ConcurrentHashMap.newKeySet();private final Map<String,OwnedEffect> ownedEffects=new HashMap<>();private final Set<Entity> relocating=Collections.newSetFromMap(new IdentityHashMap<>());private org.bukkit.scheduler.BukkitTask pulse;private boolean closed;private long ticks=9;
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,new AbilityRegistry(),new PassiveRegistry(),games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives){this(plugin,dungeons,adventures,sessions,parties,characters,progression,nextTick,random,abilities,passives,games.dmg.fantasystick.session.OutOfCombatRecovery.Settings::defaults,ignored->BigInteger.ZERO,()->BigInteger.ONE);}
|
||||
public DungeonController(Plugin plugin,DungeonDirectory dungeons,AdventureQuestDirectory adventures,EquipmentSessions sessions,PartyRoster parties,CharacterStorage characters,ProgressionRules progression,Consumer<Runnable> nextTick,java.util.random.RandomGenerator random,AbilityRegistry abilities,PassiveRegistry passives,java.util.function.Supplier<games.dmg.fantasystick.session.OutOfCombatRecovery.Settings> recoverySettings,java.util.function.Function<games.dmg.fantasystick.persistence.CharacterRecord,BigInteger> recoveryBonus,java.util.function.Supplier<BigInteger> healthStealHealing){this.plugin=Objects.requireNonNull(plugin);this.dungeons=Objects.requireNonNull(dungeons);this.adventures=Objects.requireNonNull(adventures);this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.characters=Objects.requireNonNull(characters);this.progression=Objects.requireNonNull(progression);this.nextTick=Objects.requireNonNull(nextTick);this.random=Objects.requireNonNull(random);this.abilities=Objects.requireNonNull(abilities);this.passives=Objects.requireNonNull(passives);this.recoverySettings=Objects.requireNonNull(recoverySettings);this.recoveryBonus=Objects.requireNonNull(recoveryBonus);this.healthStealHealing=Objects.requireNonNull(healthStealHealing);typeKey=new NamespacedKey(plugin,"managed_npc_type");dungeonKey=new NamespacedKey(plugin,"dungeon_id");roomKey=new NamespacedKey(plugin,"dungeon_room_id");slotKey=new NamespacedKey(plugin,"dungeon_spawn_id");generationKey=new NamespacedKey(plugin,"dungeon_generation_id");bossKey=new NamespacedKey(plugin,"dungeon_boss");}
|
||||
public void install(){synchronizeEnabled();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();pulse=plugin.getServer().getScheduler().runTaskTimer(plugin,this::tick,1,1);}
|
||||
public boolean managed(Entity entity){var data=entity.getPersistentDataContainer();if(data==null)return false;var type=data.get(typeKey,PersistentDataType.STRING);return ENEMY.equals(type)||PROJECTILE.equals(type);}
|
||||
private void synchronizeEnabled(){for(var site:adventures.snapshot().sites())if(site.type()==AdventureQuestState.SiteType.DUNGEON)adventures.removeDungeonSite(site.id());}
|
||||
public CompletionStage<EditStatus> start(Player player,UUID dungeonId,boolean confirm){if(closed)return CompletableFuture.completedFuture(EditStatus.BUSY);UUID playerId=player.getUniqueId();var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)return CompletableFuture.completedFuture(EditStatus.NOT_FOUND);synchronized(this){if(playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return CompletableFuture.completedFuture(EditStatus.BUSY);}var catalog=adventures.snapshot().catalog();boolean offered=catalog!=null&&adventures.offerOpen(dungeonId);boolean work=offered||adventures.active().stream().anyMatch(q->q.site().equals(dungeonId))||dungeons.snapshot().runs().stream().anyMatch(run->run.dungeon().equals(dungeonId));if(work&&!confirm)return CompletableFuture.completedFuture(EditStatus.CONFIRM_REQUIRED);CompletionStage<?> transition=settleAwards().thenCompose(ignored->dungeons.disable(dungeonId)).thenCompose(ignored->adventures.closeOffer(dungeonId)).thenCompose(ignored->adventures.removeDungeonSite(dungeonId)).thenCompose(ignored->cleanupOnMain(dungeonId));return transition.thenApply(ignored->{synchronized(this){if(closed||playerEdits.containsKey(playerId)||dungeonEditors.containsKey(dungeonId))return EditStatus.BUSY;var colors=new HashMap<UUID,Color>();for(var room:dungeons.find(dungeonId).orElseThrow().rooms())colors.put(room.id(),randomColor());playerEdits.put(playerId,new Edit(dungeonId,colors));dungeonEditors.put(dungeonId,playerId);return EditStatus.STARTED;}});}
|
||||
public synchronized boolean done(UUID player){var edit=playerEdits.remove(player);if(edit==null)return false;dungeonEditors.remove(edit.dungeon(),player);return true;}
|
||||
public synchronized Optional<UUID> editing(UUID player){var edit=playerEdits.get(player);return edit==null?Optional.empty():Optional.of(edit.dungeon());}
|
||||
public synchronized boolean beingEdited(UUID dungeon){return dungeonEditors.containsKey(dungeon);}
|
||||
public boolean occupied(UUID dungeon){var definition=dungeons.find(dungeon).orElse(null);if(definition==null)return false;return plugin.getServer().getOnlinePlayers().stream().anyMatch(player->definition.rooms().stream().anyMatch(room->inside(player.getLocation(),definition,room)));}
|
||||
public Optional<DungeonState.Room> standingRoom(Player player){var dungeon=editing(player.getUniqueId()).orElse(null);if(dungeon==null)return Optional.empty();var location=player.getLocation();return dungeons.roomAt(dungeon,location.getBlockX(),location.getBlockY(),location.getBlockZ());}
|
||||
public CompletionStage<DungeonState.Dungeon> enable(UUID id){synchronized(this){if(dungeonEditors.containsKey(id))return CompletableFuture.failedFuture(new IllegalStateException("Dungeon is being edited"));}return dungeons.enable(id,List.of());}
|
||||
public CompletionStage<Boolean> disable(UUID id){return settleAwards().thenCompose(ignored->dungeons.disable(id)).thenCompose(value->adventures.closeOffer(id).thenCompose(ignored->adventures.removeDungeonSite(id)).thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->value.isPresent()));}
|
||||
public CompletionStage<AdventureQuestState.Catalog> open(UUID id){var dungeon=dungeons.find(id).filter(DungeonState.Dungeon::enabled).orElseThrow(()->new IllegalArgumentException("Dungeon is not enabled"));var catalog=adventures.snapshot().catalog();if(catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false))return CompletableFuture.failedFuture(new IllegalArgumentException("Dungeon is defeated"));return adventures.ensureToday().thenCompose(current->adventures.appendOffer(dungeon.id()));}
|
||||
public CompletionStage<Boolean> closeOffer(UUID id){var catalog=adventures.snapshot().catalog();boolean defeated=catalog!=null&&dungeons.runView(id,catalog.revision()).map(DungeonState.Run::defeated).orElse(false);return settleAwards().thenCompose(ignored->adventures.closeOffer(id)).thenCompose(changed->{if(catalog==null)return CompletableFuture.completedFuture(changed);CompletionStage<?> progress=defeated?CompletableFuture.completedFuture(false):dungeons.clearRun(id,catalog.revision());return progress.thenCompose(ignored->cleanupOnMain(id)).thenApply(ignored->changed);});}
|
||||
private CompletableFuture<Void> settleAwards(){return CompletableFuture.allOf(inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new));}
|
||||
private CompletionStage<Void> cleanupOnMain(UUID dungeon){var result=new CompletableFuture<Void>();if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return result;}try{nextTick.accept(()->{if(closed){result.completeExceptionally(new IllegalStateException("Dungeon controller closed"));return;}cleanupDungeon(dungeon);synchronized(this){var editor=dungeonEditors.remove(dungeon);if(editor!=null)playerEdits.remove(editor);}result.complete(null);});}catch(RuntimeException failure){result.completeExceptionally(failure);}return result;}
|
||||
public void definitionChanged(UUID dungeon){synchronized(this){var editor=dungeonEditors.get(dungeon);if(editor==null)return;var edit=playerEdits.get(editor);if(edit==null)return;for(var room:dungeons.find(dungeon).map(DungeonState.Dungeon::rooms).orElse(List.of()))edit.colors().computeIfAbsent(room.id(),ignored->randomColor());}}
|
||||
private void tick(){if(closed)return;if(++ticks%10==0)showBorders();runtime();}
|
||||
private void runtime(){var catalog=adventures.snapshot().catalog();if(catalog==null||adventures.snapshot().reset()!=null){cleanupRuntime();return;}var activeDungeonIds=catalog.sites().stream().filter(adventures::offerOpen).filter(id->adventures.findSite(id).map(AdventureQuestState.Site::type).orElse(null)==AdventureQuestState.SiteType.DUNGEON).collect(java.util.stream.Collectors.toSet());for(var key:new ArrayList<>(attempts.keySet()))if(!activeDungeonIds.contains(key.dungeon()))cleanupAttempt(key);var activeEffects=new HashSet<String>();for(UUID dungeonId:activeDungeonIds){if(defeatingDungeons.contains(dungeonId))continue;var dungeon=dungeons.find(dungeonId).orElse(null);if(dungeon==null)continue;var run=dungeons.runView(dungeonId,catalog.revision()).orElse(null);if(run==null){String key=dungeonId+":"+catalog.revision();if(startingRuns.add(key))dungeons.run(dungeonId,catalog.revision()).whenComplete((ignored,failure)->schedule(()->startingRuns.remove(key)));continue;}if(run.defeated()){cleanupDungeon(dungeonId);continue;}for(var room:dungeon.rooms()){var players=eligibleInside(dungeon,room).toList();var key=new AttemptKey(dungeonId,room.id());if(players.isEmpty()){cleanupAttempt(key);continue;}applyPlayerEffects(dungeon.id(),room,players,activeEffects);if(run.clearedRooms().contains(room.id())){cleanupAttempt(key);continue;}if(completing.contains(key))continue;var attempt=attempts.computeIfAbsent(key,ignored->new Attempt(dungeonId,room.id(),catalog.revision()));contain(dungeon,room,attempt);if(attempt.entities.isEmpty()&&attempt.boss==null)spawnRoom(dungeon,room,run,attempt);if(room.boss()!=null&&room.boss().character()!=null)driveCharacterBoss(dungeon,room,attempt,players);if(room.mobs().isEmpty()&&room.boss()==null){attempt.represented.addAll(players.stream().map(this::eligibleQuest).filter(Objects::nonNull).map(AdventureQuestState.Quest::id).toList());clearRoom(dungeon,room,attempt,false);}}}cleanupOwnedEffects(activeEffects);}
|
||||
private Context context(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return null;var record=sessions.currentRecord(active).orElse(null);if(record==null||record.dead())return null;var party=parties.partyOf(player.getUniqueId()).orElse(null);return party==null?new Context(record.id(),null,Set.of(record.id())):new Context(record.id(),party.id(),Set.copyOf(party.characters()));}
|
||||
private AdventureQuestState.Quest eligibleQuest(Player player){var context=context(player);if(context==null)return null;var quest=adventures.activeFor(context.character(),context.party()).orElse(null);return quest!=null&&quest.snapshot().contains(context.character())?quest:null;}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID dungeon){var quest=eligibleQuest(player);return quest!=null&&quest.site().equals(dungeon)?quest:null;}
|
||||
private AdventureQuestState.Quest eligible(Player player,UUID dungeon,UUID room){var quest=eligible(player,dungeon);var definition=dungeons.find(dungeon).orElse(null);var roomDefinition=dungeons.room(dungeon,room).orElse(null);return quest!=null&&definition!=null&&roomDefinition!=null&&inside(player.getLocation(),definition,roomDefinition)?quest:null;}
|
||||
private java.util.stream.Stream<Player> eligibleInside(DungeonState.Dungeon dungeon,DungeonState.Room room){return plugin.getServer().getOnlinePlayers().stream().map(Player.class::cast).filter(player->eligible(player,dungeon.id())!=null).filter(player->inside(player.getLocation(),dungeon,room));}
|
||||
private static boolean inside(Location location,DungeonState.Dungeon dungeon,DungeonState.Room room){return location.getWorld()!=null&&location.getWorld().getUID().equals(dungeon.world())&&room.bounds().contains(location.getBlockX(),location.getBlockY(),location.getBlockZ());}
|
||||
@SuppressWarnings({"unchecked","rawtypes"}) private void spawnRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,DungeonState.Run run,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var slot:room.mobs())if(!run.defeatedSlots().contains(slot.id())&&loaded(world,slot.position()))world.spawn(location(world,slot.position()),(Class)slot.type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,slot.id(),false,slot.name(),room.effects()));if(room.boss()!=null&&loaded(world,room.boss().position()))world.spawn(location(world,room.boss().position()),(Class)room.boss().type().getEntityClass(),entity->configure((Mob)entity,dungeon,room,attempt,null,true,room.boss().name(),room.effects()));}
|
||||
private void configure(Mob mob,DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,UUID slot,boolean boss,String name,List<DungeonState.Effect> effects){var data=mob.getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,ENEMY);data.set(dungeonKey,PersistentDataType.STRING,dungeon.id().toString());data.set(roomKey,PersistentDataType.STRING,room.id().toString());data.set(generationKey,PersistentDataType.STRING,attempt.generation.toString());data.set(bossKey,PersistentDataType.BOOLEAN,boss);if(slot!=null)data.set(slotKey,PersistentDataType.STRING,slot.toString());mob.setCanPickupItems(false);mob.setRemoveWhenFarAway(false);mob.setPersistent(true);mob.setFireTicks(0);if(mob instanceof Raider raider){raider.setPatrolLeader(false);raider.setCanJoinRaid(false);}if(mob instanceof Zombie zombie)zombie.setShouldBurnInDay(false);if(!name.isBlank()){mob.customName(net.kyori.adventure.text.Component.text(name));mob.setCustomNameVisible(true);}if(boss){attempt.boss=mob.getUniqueId();if(room.boss()!=null&&room.boss().character()!=null)configureCharacterBoss(mob,room.boss().character());}else attempt.entities.put(mob.getUniqueId(),slot);applyMobEffects(mob,effects);}
|
||||
private void configureCharacterBoss(Mob mob,DungeonState.CharacterBoss custody){var stats=custody.effectiveStats();var maximum=new java.math.BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN)).min(games.dmg.fantasystick.session.NativeHealthLimit.read()).max(java.math.BigDecimal.ONE).doubleValue();var health=mob.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(health!=null){health.setBaseValue(maximum);mob.setHealth(maximum);}var damage=mob.getAttribute(org.bukkit.attribute.Attribute.ATTACK_DAMAGE);if(damage!=null)damage.setBaseValue(Math.min(characterDamage(stats.get(Stat.STR)),nativeAttributeMaximum(org.bukkit.attribute.Attribute.ATTACK_DAMAGE)));var equipment=mob.getEquipment();if(equipment!=null){var gear=custody.character().gear();equipment.setChestplate(armor(org.bukkit.inventory.EquipmentSlot.CHEST,gear.chestplate()));equipment.setLeggings(armor(org.bukkit.inventory.EquipmentSlot.LEGS,gear.leggings()));equipment.setBoots(armor(org.bukkit.inventory.EquipmentSlot.FEET,gear.boots()));equipment.setChestplateDropChance(0);equipment.setLeggingsDropChance(0);equipment.setBootsDropChance(0);}for(var id:custody.character().passives())if(passives.find(id).isPresent()){if(id.equals("speed_i"))mob.addPotionEffect(new PotionEffect(PotionEffectType.SPEED,PotionEffect.INFINITE_DURATION,0,false,false));else if(id.equals("low_light_vision"))mob.addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION,PotionEffect.INFINITE_DURATION,0,false,false));}}
|
||||
private static double nativeAttributeMaximum(org.bukkit.attribute.Attribute attribute){try{var handle=attribute.getClass().getMethod("getHandle").invoke(attribute);double maximum=((Number)handle.getClass().getMethod("getMaxValue").invoke(handle)).doubleValue();if(!Double.isFinite(maximum)||maximum<=0)throw new IllegalStateException("Invalid native attribute range");return maximum;}catch(ReflectiveOperationException|ClassCastException failure){throw new IllegalStateException("Cannot read native attribute range",failure);}}
|
||||
private static BigInteger modifier(BigInteger stat){var delta=stat.subtract(BigInteger.TEN);var parts=delta.divideAndRemainder(BigInteger.TWO);return delta.signum()<0&&parts[1].signum()!=0?parts[0].subtract(BigInteger.ONE):parts[0];}
|
||||
private static double characterDamage(BigInteger stat){var value=modifier(stat).add(BigInteger.ONE).max(BigInteger.ONE);return value.bitLength()>1023?Double.MAX_VALUE:value.doubleValue();}
|
||||
private static double abilityAmount(AbilityDefinition definition,DungeonState.CharacterBoss custody,boolean healing){var stats=custody.effectiveStats();Stat stat=healing?Stat.WIS:switch(definition.id()){case"great_sword","shield_bash"->Stat.STR;case"longbow"->Stat.DEX;case"hex"->Stat.CHR;default->Stat.INT;};var base=definition.parameters().get(healing?"base-healing":"base-damage");var exact=(base==null?BigInteger.ZERO:base.toBigInteger()).add(modifier(stats.get(stat))).max(BigInteger.ZERO);return exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();}
|
||||
private static double range(AbilityDefinition definition){for(var key:List.of("range","initial-range","target-range","placement-range")){var value=definition.parameters().get(key);if(value!=null)return value.doubleValue();}return definition.id().equals("great_sword")?4d:16d;}
|
||||
private static org.bukkit.inventory.ItemStack armor(org.bukkit.inventory.EquipmentSlot slot,games.dmg.fantasystick.persistence.GearTier tier){if(tier==games.dmg.fantasystick.persistence.GearTier.NONE)return null;String material=(tier==games.dmg.fantasystick.persistence.GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHEST->"CHESTPLATE";case LEGS->"LEGGINGS";case FEET->"BOOTS";default->throw new IllegalArgumentException("Not armor");};return new org.bukkit.inventory.ItemStack(Material.valueOf(material));}
|
||||
private void driveCharacterBoss(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,List<Player> eligible){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;Mob boss=null;for(var entity:safe(world.getEntities()))if(entity instanceof Mob mob&&Objects.equals(attempt.boss,mob.getUniqueId())){boss=mob;break;}if(boss==null)return;var actor=boss;long now=System.nanoTime();if(attempt.effects.getOrDefault("absorption",Long.MAX_VALUE)<=now){boss.setAbsorptionAmount(0);attempt.effects.remove("absorption");}var eligibleIds=eligible.stream().map(Player::getUniqueId).collect(java.util.stream.Collectors.toSet());attempt.threat.keySet().removeIf(id->!eligibleIds.contains(id));Player target=eligible.stream().filter(player->player.getUniqueId().equals(attempt.target)&&!player.isDead()&&actor.hasLineOfSight(player)).findFirst().orElse(null);if(target==null&&!attempt.threat.isEmpty()){target=eligible.stream().filter(player->!player.isDead()&&actor.hasLineOfSight(player)&&attempt.threat.containsKey(player.getUniqueId())).max(Comparator.<Player>comparingDouble(player->attempt.threat.getOrDefault(player.getUniqueId(),0d)).thenComparing(player->player.getUniqueId().toString(),Comparator.reverseOrder())).orElse(null);attempt.target=target==null?null:target.getUniqueId();}if(target==null){regenerateCharacterBoss(boss,room.boss().character(),attempt);return;}if(boss.getTarget()!=target)boss.setTarget(target);castCharacterAbility(boss,target,eligible,room.boss().character(),attempt);}
|
||||
private void regenerateCharacterBoss(Mob boss,DungeonState.CharacterBoss custody,Attempt attempt){var settings=recoverySettings.get();long now=System.nanoTime();long ready=attempt.cooldowns.getOrDefault("recovery",attempt.lastCombat+settings.delay().toNanos());if(now<ready)return;var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);var amount=settings.amount().add(recoveryBonus.apply(custody.character()));if(maximum!=null&&amount.signum()>0&&boss.getHealth()<maximum.getValue())healBoss(boss,Math.min(Double.MAX_VALUE,amount.doubleValue()));attempt.cooldowns.put("recovery",now+settings.interval().toNanos());}
|
||||
private void castCharacterAbility(Mob boss,Player target,List<Player> eligible,DungeonState.CharacterBoss custody,Attempt attempt){
|
||||
long now=System.nanoTime();
|
||||
for(String id:custody.character().loadout()){
|
||||
if(id.isBlank()||!custody.character().learned().contains(id))continue;
|
||||
var definition=abilities.find(id).orElse(null);
|
||||
if(definition==null||now<attempt.cooldowns.getOrDefault(id,0L)
|
||||
||definition.targetPolicy()==AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT
|
||||
||definition.targetPolicy()==AbilityDefinition.TargetPolicy.EXCEPTIONAL_NONCOMBAT_SELECTION
|
||||
||id.equals("revive")||id.equals("guardian_bond"))continue;
|
||||
double range=range(definition);
|
||||
if(definition.category()==AbilityDefinition.Category.ATK&&(definition.targetPolicy()!=AbilityDefinition.TargetPolicy.AREA||id.equals("meteor"))
|
||||
&&(!boss.hasLineOfSight(target)||boss.getLocation().distanceSquared(target.getLocation())>range*range))continue;
|
||||
boolean cast;
|
||||
if(id.equals("purify")){
|
||||
var harmful=boss.getActivePotionEffects().stream().filter(effect->effect.getType().getEffectCategory().name().equals("HARMFUL")).toList();
|
||||
for(var effect:harmful)boss.removePotionEffect(effect.getType());
|
||||
cast=!harmful.isEmpty();
|
||||
}else if(id.equals("hex")){
|
||||
target.addPotionEffect(new PotionEffect(PotionEffectType.WEAKNESS,Math.max(20,(int)Math.min(1200,definition.cooldown().toMillis()/50)),0,false,true),true);cast=true;
|
||||
}else cast=switch(definition.category()){
|
||||
case HEAL->{yield healBoss(boss,abilityAmount(definition,custody,true));}
|
||||
case BUF->{if(id.equals("quickstep")){var direction=target.getLocation().toVector().subtract(boss.getLocation().toVector());double distance=definition.parameters().getOrDefault("distance",java.math.BigDecimal.ONE).doubleValue();if(direction.lengthSquared()==0||!Double.isFinite(distance)||distance<=0)yield false;boss.setVelocity(direction.normalize().multiply(distance));yield true;}if(id.equals("inspire")){double amount=definition.parameters().getOrDefault("base-temporary-health",java.math.BigDecimal.ZERO).add(new java.math.BigDecimal(modifier(custody.effectiveStats().get(Stat.CHR)))).max(java.math.BigDecimal.ZERO).doubleValue();if(amount<=boss.getAbsorptionAmount())yield false;boss.setAbsorptionAmount(amount);attempt.effects.put("absorption",now+java.time.Duration.ofMillis(durationTicks(definition)*50L).toNanos());yield true;}var type=id.equals("stone_skin")?PotionEffectType.RESISTANCE:id.equals("rallying_cry")?PotionEffectType.SPEED:PotionEffectType.STRENGTH;boss.addPotionEffect(new PotionEffect(type,durationTicks(definition),0,false,false),true);yield true;}
|
||||
case ATK->{var victims=abilityVictims(definition,boss,target,eligible,range);double amount=abilityAmount(definition,custody,false);if(amount<=0)yield false;for(int index=0;index<victims.size();index++){var victim=victims.get(index);double strike=id.equals("chain_lightning")?java.math.BigDecimal.valueOf(amount).multiply(definition.parameters().getOrDefault("jump-multiplier",java.math.BigDecimal.ONE).pow(index)).doubleValue():amount;victim.damage(strike,boss);if(id.equals("frost_shard")||id.equals("frost_nova"))victim.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS,40,0,false,true),true);}yield !victims.isEmpty();}
|
||||
};
|
||||
if(cast)attempt.cooldowns.put(id,now+definition.cooldown().toNanos());
|
||||
}
|
||||
}
|
||||
private static int durationTicks(AbilityDefinition definition){var seconds=definition.parameters().get("duration-seconds");long ticks=seconds==null?Math.max(20,definition.cooldown().toMillis()/50):seconds.movePointRight(1).longValue();return (int)Math.max(1,Math.min(Integer.MAX_VALUE,ticks));}
|
||||
private boolean healBoss(Mob boss,double requested){var maximum=boss.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH);if(maximum==null||!Double.isFinite(requested)||requested<=0)return false;double accepted=Math.min(requested,maximum.getValue()-boss.getHealth());if(accepted<=0)return false;var event=new EntityRegainHealthEvent(boss,accepted,EntityRegainHealthEvent.RegainReason.CUSTOM);plugin.getServer().getPluginManager().callEvent(event);if(event.isCancelled()||!Double.isFinite(event.getAmount())||event.getAmount()<=0)return false;double before=boss.getHealth();boss.setHealth(Math.min(maximum.getValue(),before+event.getAmount()));return boss.getHealth()>before;}
|
||||
private static List<Player> abilityVictims(AbilityDefinition definition,Mob boss,Player target,List<Player> eligible,double range){if(definition.id().equals("chain_lightning")){var result=new ArrayList<Player>();result.add(target);int maximum=1+definition.parameters().getOrDefault("additional-targets",java.math.BigDecimal.ZERO).intValue();double jump=definition.parameters().getOrDefault("jump-range",java.math.BigDecimal.valueOf(range)).doubleValue();Player current=target;while(result.size()<maximum){var origin=current;var next=eligible.stream().filter(player->!result.contains(player)&&origin.hasLineOfSight(player)&&origin.getLocation().distanceSquared(player.getLocation())<=jump*jump).min(Comparator.comparingDouble((Player player)->origin.getLocation().distanceSquared(player.getLocation())).thenComparing(player->player.getUniqueId().toString())).orElse(null);if(next==null)break;result.add(next);current=next;}return List.copyOf(result);}double radius=definition.parameters().getOrDefault("radius",java.math.BigDecimal.valueOf(range)).doubleValue();var center=definition.id().equals("meteor")?target:boss;return definition.targetPolicy()==AbilityDefinition.TargetPolicy.AREA?eligible.stream().filter(player->center.hasLineOfSight(player)&¢er.getLocation().distanceSquared(player.getLocation())<=radius*radius).toList():List.of(target);}
|
||||
private void applyMobEffects(LivingEntity mob,List<DungeonState.Effect> effects){for(var effect:effects)if(effect.target()!=DungeonState.EffectTarget.PLAYERS){var type=effectType(effect.effect());if(type!=null)mob.addPotionEffect(new PotionEffect(type,PotionEffect.INFINITE_DURATION,effect.level()-1,false,effect.particles()));}}
|
||||
private void applyPlayerEffects(UUID dungeon,DungeonState.Room room,List<Player> players,Set<String> active){for(var effect:room.effects())if(effect.target()!=DungeonState.EffectTarget.MOBS){var type=effectType(effect.effect());if(type==null)continue;for(var player:players){String key=player.getUniqueId()+":"+effect.id();active.add(key);var current=effect(player,type);int amplifier=effect.level()-1;if(current!=null&¤t.getAmplifier()>amplifier)continue;ownedEffects.computeIfAbsent(key,ignored->new OwnedEffect(dungeon,player.getUniqueId(),type,amplifier,current));player.addPotionEffect(new PotionEffect(type,30,amplifier,false,effect.particles()),true);}}}
|
||||
private void cleanupOwnedEffects(Set<String> active){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(!active.contains(entry.getKey()))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void cleanupOwnedDungeon(UUID dungeon){for(var entry:new ArrayList<>(ownedEffects.entrySet()))if(entry.getValue().dungeon().equals(dungeon))restoreOwned(entry.getKey(),entry.getValue());}
|
||||
private void restoreOwned(String key,OwnedEffect owned){ownedEffects.remove(key);var player=plugin.getServer().getPlayer(owned.player());if(player==null)return;var current=effect(player,owned.type());if(current!=null&¤t.getAmplifier()==owned.amplifier()){player.removePotionEffect(owned.type());if(owned.prior()!=null&&owned.prior().getDuration()>0)player.addPotionEffect(owned.prior(),true);}}
|
||||
private static PotionEffect effect(LivingEntity entity,PotionEffectType type){return entity.getActivePotionEffects().stream().filter(value->value.getType().equals(type)).findFirst().orElse(null);}
|
||||
private static PotionEffectType effectType(String key){var value=NamespacedKey.fromString(key);return value==null?null:PotionEffectType.getByKey(value);}
|
||||
private void contain(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt){var world=plugin.getServer().getWorld(dungeon.world());if(world==null)return;for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.id().equals(id(entity,dungeonKey))&&room.id().equals(id(entity,roomKey))&&!inside(entity.getLocation(),dungeon,room)){relocating.add(entity);try{entity.teleport(new Location(world,(room.bounds().minX()+room.bounds().maxX()+1)/2d,room.bounds().minY()+1,(room.bounds().minZ()+room.bounds().maxZ()+1)/2d));if(entity instanceof LivingEntity living)living.setVelocity(new org.bukkit.util.Vector());}finally{relocating.remove(entity);}}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(managedEnemy(event.getEntity())){if(!(event instanceof EntityDamageByEntityEvent by)||source(by)==null||eligible(source(by),id(event.getEntity(),dungeonKey),id(event.getEntity(),roomKey))==null)event.setCancelled(true);return;}if(event instanceof EntityDamageByEntityEvent by&&managed(by.getDamager())){UUID dungeon=id(by.getDamager(),dungeonKey),room=id(by.getDamager(),roomKey);if(!(event.getEntity() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void accepted(EntityDamageByEntityEvent event){if(!managedEnemy(event.getEntity())||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;var player=source(event);UUID dungeon=id(event.getEntity(),dungeonKey),room=id(event.getEntity(),roomKey);var quest=player==null||dungeon==null||room==null?null:eligible(player,dungeon,room);var attempt=room==null?null:attempts.get(new AttemptKey(dungeon,room));if(quest!=null&&attempt!=null){attempt.represented.add(quest.id());attempt.threat.merge(player.getUniqueId(),event.getFinalDamage(),Double::sum);attempt.lastCombat=System.nanoTime();attempt.cooldowns.remove("recovery");}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void bossAccepted(EntityDamageByEntityEvent event){if(!(event.getDamager() instanceof Mob boss)||!managedEnemy(boss)||!(event.getEntity() instanceof Player player)||event.getFinalDamage()<=0)return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null)return;if(custody.character().passives().contains("stick_knockback")&&passives.find("stick_knockback").isPresent()){double radians=Math.toRadians(boss.getLocation().getYaw()),x=Math.sin(radians),z=-Math.cos(radians);if(Double.isFinite(x)&&Double.isFinite(z))player.knockback(1,x,z);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void bossKill(org.bukkit.event.entity.PlayerDeathEvent event){if(!(event.getEntity().getLastDamageCause() instanceof EntityDamageByEntityEvent damage)||!(damage.getDamager() instanceof Mob boss)||!managedEnemy(boss))return;var room=dungeons.room(id(boss,dungeonKey),id(boss,roomKey)).orElse(null);var custody=room==null||room.boss()==null?null:room.boss().character();if(custody==null||!custody.character().passives().contains("health_steal")||passives.find("health_steal").isEmpty()||eligible(event.getPlayer(),id(boss,dungeonKey),id(boss,roomKey))==null)return;healBoss(boss,Math.min(Double.MAX_VALUE,healthStealHealing.get().doubleValue()));}
|
||||
public void accept(AbilityReport report){if(report.type()==AbilityReport.Type.DAMAGE)return;var active=sessions.activeCharacter(report.sourceCharacter()).filter(sessions::current).orElse(null);if(active==null)return;var player=plugin.getServer().getPlayer(active.player());if(player==null)return;double value=report.amount().doubleValue();if(!Double.isFinite(value)||value<=0)return;for(var entry:attempts.entrySet())if(eligible(player,entry.getKey().dungeon(),entry.getKey().room())!=null)entry.getValue().threat.merge(player.getUniqueId(),value*.5d,Double::sum);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void death(EntityDeathEvent event){if(!managedEnemy(event.getEntity()))return;UUID dungeonId=id(event.getEntity(),dungeonKey),roomId=id(event.getEntity(),roomKey),generation=id(event.getEntity(),generationKey);if(dungeonId==null||roomId==null||generation==null)return;var dungeon=dungeons.find(dungeonId).orElse(null);var room=dungeons.room(dungeonId,roomId).orElse(null);var attempt=attempts.get(new AttemptKey(dungeonId,roomId));if(dungeon==null||room==null||attempt==null)return;boolean boss=Boolean.TRUE.equals(event.getEntity().getPersistentDataContainer().get(bossKey,PersistentDataType.BOOLEAN));if(boss){attempt.boss=null;defeatingDungeons.add(dungeonId);cleanupDungeon(dungeonId);clearRoom(dungeon,room,attempt,true);return;}UUID slot=id(event.getEntity(),slotKey);attempt.entities.remove(event.getEntity().getUniqueId());if(slot==null)return;dungeons.defeatSlot(dungeonId,generation,slot).whenComplete((run,failure)->schedule(()->{if(failure!=null)return;if(room.boss()==null&&room.mobs().stream().allMatch(value->run.defeatedSlots().contains(value.id())))clearRoom(dungeon,room,attempt,false);}));}
|
||||
private void clearRoom(DungeonState.Dungeon dungeon,DungeonState.Room room,Attempt attempt,boolean boss){var key=new AttemptKey(dungeon.id(),room.id());if(!completing.add(key))return;var quests=attempt.represented.stream().map(adventures::findQuest).flatMap(Optional::stream).toList();var awards=quests.stream().map(quest->awardQuest(dungeon,room,attempt.generation,quest,boss)).toArray(CompletableFuture[]::new);var completion=CompletableFuture.allOf(awards).thenCompose(ignored->dungeons.markCleared(dungeon.id(),attempt.generation,room.id())).thenCompose(run->boss?dungeons.defeat(dungeon.id(),attempt.generation):CompletableFuture.completedFuture(run)).thenCompose(run->boss?adventures.expireOfferQuests(dungeon.id()).thenApply(ignored->run):CompletableFuture.completedFuture(run)).whenComplete((run,failure)->schedule(()->{completing.remove(key);if(boss)defeatingDungeons.remove(dungeon.id());if(failure!=null){plugin.getLogger().log(java.util.logging.Level.WARNING,"Dungeon room completion failed",failure);return;}cleanupAttempt(key);if(boss)cleanupDungeon(dungeon.id());}));inFlight.add(completion);completion.whenComplete((ignored,failure)->inFlight.remove(completion));}
|
||||
private CompletableFuture<Void> awardQuest(DungeonState.Dungeon dungeon,DungeonState.Room room,UUID generation,AdventureQuestState.Quest quest,boolean completeQuest){var run=dungeons.runView(dungeon.id(),generation).orElse(null);if(run==null||run.unitClaims().contains(new DungeonState.UnitClaim(room.id(),quest.id())))return CompletableFuture.completedFuture(null);var recipients=recipients(dungeon,room,quest,run);if(recipients.isEmpty())return CompletableFuture.completedFuture(null);var unit=new RewardAllocator.Unit<UUID>(quest.unit(),recipients);var xp=RewardAllocator.allocate(room.reward().xp(),List.of(unit),random);var coins=RewardAllocator.allocate(room.reward().coins(),List.of(unit),random);var rewards=new LinkedHashMap<UUID,AdventureQuestDirectory.Reward>();for(UUID character:recipients)rewards.put(character,new AdventureQuestDirectory.Reward(xp.get(character),coins.get(character)));UUID source=UUID.nameUUIDFromBytes((generation+":"+dungeon.id()+":"+room.id()+":"+quest.id()).getBytes(StandardCharsets.UTF_8));return adventures.bank(quest.id(),source,rewards).thenCompose(banked->{if(!banked)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon reward banking refused"));return completeQuest?adventures.complete(quest.id(),Set.copyOf(recipients)):CompletableFuture.completedFuture(true);}).thenCompose(completed->{if(!completed)return CompletableFuture.failedFuture(new IllegalStateException("Dungeon quest completion refused"));return dungeons.clearRoom(dungeon.id(),generation,room.id(),quest.id(),Set.copyOf(recipients));}).thenApply(ignored->null);}
|
||||
private List<UUID> recipients(DungeonState.Dungeon dungeon,DungeonState.Room room,AdventureQuestState.Quest quest,DungeonState.Run run){var result=new ArrayList<UUID>();for(UUID character:quest.snapshot()){if(run.characterClaims().contains(new DungeonState.CharacterClaim(room.id(),character)))continue;var active=sessions.activeCharacter(character).filter(sessions::current).orElse(null);if(active==null)continue;var player=plugin.getServer().getPlayer(active.player());if(player==null||!inside(player.getLocation(),dungeon,room))continue;if(quest.unitType()==AdventureQuestState.UnitType.PARTY){var party=parties.partyOf(player.getUniqueId()).orElse(null);if(party==null||!party.id().equals(quest.unit())||!party.characters().contains(character))continue;}result.add(character);}return result.stream().distinct().sorted().toList();}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void target(EntityTargetLivingEntityEvent event){if(managedEnemy(event.getEntity())&&event.getTarget()!=null){UUID dungeon=id(event.getEntity(),dungeonKey);UUID room=id(event.getEntity(),roomKey);var attempt=dungeon==null||room==null?null:attempts.get(new AttemptKey(dungeon,room));if(!(event.getTarget() instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null||attempt!=null&&attempt.target!=null&&!attempt.target.equals(player.getUniqueId()))event.setCancelled(true);}}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void projectile(ProjectileLaunchEvent event){if(event.getEntity().getShooter() instanceof Entity shooter&&managedEnemy(shooter)){var data=event.getEntity().getPersistentDataContainer();data.set(typeKey,PersistentDataType.STRING,PROJECTILE);copy(data,dungeonKey,shooter);copy(data,roomKey,shooter);copy(data,generationKey,shooter);}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void splash(PotionSplashEvent event){if(!managed(event.getPotion()))return;UUID dungeon=id(event.getPotion(),dungeonKey),room=id(event.getPotion(),roomKey);for(var affected:List.copyOf(event.getAffectedEntities()))if(!(affected instanceof Player player)||dungeon==null||room==null||eligible(player,dungeon,room)==null)event.setIntensity(affected,0);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(EntityCombustEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(EntityTransformEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void portal(EntityPortalEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void pickup(EntityPickupItemEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void block(EntityChangeBlockEvent event){if(managed(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void explode(EntityExplodeEvent event){if(managed(event.getEntity())){event.blockList().clear();event.setYield(0);}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(EntityTeleportEvent event){if(managed(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
private boolean managedEnemy(Entity entity){return entity!=null&&ENEMY.equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
private Player source(EntityDamageByEntityEvent event){if(event.getDamager() instanceof Player player)return player;if(event.getDamager() instanceof Projectile projectile&&projectile.getShooter() instanceof Player player)return player;return null;}
|
||||
private void cleanupAttempt(AttemptKey key){var attempt=attempts.remove(key);if(attempt==null)return;for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&key.dungeon().equals(id(entity,dungeonKey))&&key.room().equals(id(entity,roomKey)))entity.remove();attempt.entities.clear();attempt.boss=null;}
|
||||
private void cleanupDungeon(UUID dungeon){cleanupOwnedDungeon(dungeon);for(var key:new ArrayList<>(attempts.keySet()))if(key.dungeon().equals(dungeon))cleanupAttempt(key);for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity)&&dungeon.equals(id(entity,dungeonKey)))entity.remove();}
|
||||
private void cleanupRuntime(){for(var key:new ArrayList<>(attempts.keySet()))cleanupAttempt(key);cleanupOwnedEffects(Set.of());completing.clear();defeatingDungeons.clear();startingRuns.clear();}
|
||||
private void schedule(Runnable task){if(closed)return;try{nextTick.accept(()->{if(!closed)task.run();});}catch(RuntimeException ignored){}}
|
||||
private static boolean loaded(World world,DungeonState.Position value){return world.isChunkLoaded(((int)Math.floor(value.x()))>>4,((int)Math.floor(value.z()))>>4);}
|
||||
private static Location location(World world,DungeonState.Position value){return new Location(world,value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
private static UUID id(Entity entity,NamespacedKey key){try{var value=entity.getPersistentDataContainer().get(key,PersistentDataType.STRING);return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static void copy(org.bukkit.persistence.PersistentDataContainer target,NamespacedKey key,Entity source){var value=source.getPersistentDataContainer().get(key,PersistentDataType.STRING);if(value!=null)target.set(key,PersistentDataType.STRING,value);}
|
||||
private static <T>List<T> safe(Collection<T> values){return values==null?List.of():List.copyOf(values);}
|
||||
private void showBorders(){if(closed)return;for(var entry:List.copyOf(playerEdits.entrySet())){var player=plugin.getServer().getPlayer(entry.getKey());if(player==null||!player.isOnline()){done(entry.getKey());continue;}var dungeon=dungeons.find(entry.getValue().dungeon()).orElse(null);if(dungeon==null){done(entry.getKey());continue;}int count=0;for(var room:dungeon.rooms()){var bounds=room.bounds();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(dungeon.world()))continue;var dust=new Particle.DustOptions(entry.getValue().colors().computeIfAbsent(room.id(),ignored->randomColor()),1);for(var point:edges(bounds)){var sample=new Location(location.getWorld(),point[0],point[1],point[2]);if(location.distanceSquared(sample)>64d*64d)continue;if(count++>=1024)return;player.spawnParticle(Particle.DUST,sample,1,0,0,0,0,dust);}}}}
|
||||
static List<double[]> edges(DungeonState.Bounds b){var result=new ArrayList<double[]>();double x1=b.minX(),x2=b.maxX()+1d,y1=b.minY(),y2=b.maxY()+1d,z1=b.minZ(),z2=b.maxZ()+1d;line(result,x1,x2,y1,z1,0);line(result,x1,x2,y1,z2,0);line(result,x1,x2,y2,z1,0);line(result,x1,x2,y2,z2,0);line(result,z1,z2,y1,x1,1);line(result,z1,z2,y1,x2,1);line(result,z1,z2,y2,x1,1);line(result,z1,z2,y2,x2,1);line(result,y1,y2,x1,z1,2);line(result,y1,y2,x1,z2,2);line(result,y1,y2,x2,z1,2);line(result,y1,y2,x2,z2,2);return List.copyOf(result);}
|
||||
private static void line(List<double[]> out,double from,double to,double fixedA,double fixedB,int axis){for(double value=from;value<to;value+=2)out.add(axis==0?new double[]{value,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,value}:new double[]{fixedA,value,fixedB});out.add(axis==0?new double[]{to,fixedA,fixedB}:axis==1?new double[]{fixedB,fixedA,to}:new double[]{fixedA,to,fixedB});}
|
||||
private static Color randomColor(){var random=java.util.concurrent.ThreadLocalRandom.current();return Color.fromRGB(64+random.nextInt(192),64+random.nextInt(192),64+random.nextInt(192));}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkLoad(org.bukkit.event.world.ChunkLoadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity))entity.remove();}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunkUnload(org.bukkit.event.world.ChunkUnloadEvent event){for(var entity:event.getChunk().getEntities())if(managed(entity)){UUID dungeon=id(entity,dungeonKey),room=id(entity,roomKey);if(dungeon!=null&&room!=null)cleanupAttempt(new AttemptKey(dungeon,room));}}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){cleanupRuntime();var pending=inFlight.stream().map(future->future.handle((ignored,failure)->null)).toArray(CompletableFuture[]::new);return CompletableFuture.allOf(pending).thenCompose(ignored->dungeons.reset(reset));}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void quit(PlayerQuitEvent event){done(event.getPlayer().getUniqueId());}
|
||||
@Override public synchronized void close(){if(closed)return;if(pulse!=null)pulse.cancel();cleanupRuntime();for(var world:plugin.getServer().getWorlds())for(var entity:safe(world.getEntities()))if(managed(entity))entity.remove();closed=true;playerEdits.clear();dungeonEditors.clear();startingRuns.clear();completing.clear();defeatingDungeons.clear();}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import games.dmg.fantasystick.adventure.AdventureResetParticipant;
|
||||
import games.dmg.fantasystick.persistence.YamlDungeonRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Serialized save-before-publication authority for dungeon definitions and daily progress. */
|
||||
public final class DungeonDirectory implements AutoCloseable,AdventureResetParticipant {
|
||||
private final YamlDungeonRepository repository;private final java.util.function.IntSupplier roomLinkDistance;private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-dungeon-persistence"));
|
||||
private volatile DungeonState snapshot;private volatile UUID currentGeneration;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public DungeonDirectory(YamlDungeonRepository repository)throws IOException{this(repository,()->10);}
|
||||
public DungeonDirectory(YamlDungeonRepository repository,java.util.function.IntSupplier roomLinkDistance)throws IOException{this.repository=Objects.requireNonNull(repository);this.roomLinkDistance=Objects.requireNonNull(roomLinkDistance);snapshot=repository.load();}
|
||||
public DungeonState snapshot(){return snapshot;}
|
||||
public Optional<DungeonState.Dungeon> find(UUID id){return snapshot.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
public Optional<DungeonState.CharacterBoss> custody(UUID character){return snapshot.dungeons().stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).filter(value->value.character().id().equals(character)).findFirst();}
|
||||
public Optional<DungeonState.CharacterBoss> custodyForDungeon(UUID dungeon){return find(dungeon).stream().flatMap(value->value.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).findFirst();}
|
||||
public Optional<DungeonState.Room> room(UUID dungeon,UUID room){return find(dungeon).flatMap(value->value.rooms().stream().filter(v->v.id().equals(room)).findFirst());}
|
||||
public Optional<DungeonState.Room> roomAt(UUID dungeon,int x,int y,int z){return find(dungeon).flatMap(value->{var matches=value.rooms().stream().filter(room->room.bounds().contains(x,y,z)).toList();return matches.size()==1?Optional.of(matches.getFirst()):Optional.empty();});}
|
||||
public CompletableFuture<DungeonState.Dungeon> create(String name,UUID world,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Dungeon(UUID.randomUUID(),name,world,new DungeonState.Position(x,y,z,yaw,pitch),false,UUID.randomUUID(),List.of());return mutate(state->{var values=new ArrayList<>(state.dungeons());values.add(value);return result(new DungeonState(values,state.runs()),value);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> remove(UUID id){return mutate(state->{var found=find(state,id);if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).anyMatch(boss->boss.character()!=null))throw new IllegalArgumentException("Recover character boss custody first");return result(new DungeonState(state.dungeons().stream().filter(v->!v.id().equals(id)).toList(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),found);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> setEntrance(UUID id,double x,double y,double z,float yaw,float pitch){return change(id,dungeon->definition(dungeon,dungeon.rooms(),new DungeonState.Position(x,y,z,yaw,pitch),false));}
|
||||
public CompletableFuture<DungeonState.Room> addCornerRoom(UUID id,String name,int x,int y,int z,int xSize,int zSize,int height){if(xSize==0||Math.abs((long)xSize)>256||zSize==0||Math.abs((long)zSize)>256||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));int maxX=x+(xSize>0?xSize-1:0),minX=x+(xSize<0?xSize+1:0),maxZ=z+(zSize>0?zSize-1:0),minZ=z+(zSize<0?zSize+1:0);return addRoom(id,name,new DungeonState.Bounds(Math.min(x,minX),y,Math.min(z,minZ),Math.max(x,maxX),y+height-1,Math.max(z,maxZ)));}
|
||||
public CompletableFuture<DungeonState.Room> addRadiusRoom(UUID id,String name,int x,int y,int z,int radius,int height){if(radius<1||radius>128||height<1||height>128)return CompletableFuture.failedFuture(new IllegalArgumentException("Room dimensions out of range"));return addRoom(id,name,new DungeonState.Bounds(x-radius,y,z-radius,x+radius,y+height-1,z+radius));}
|
||||
private CompletableFuture<DungeonState.Room> addRoom(UUID id,String name,DungeonState.Bounds bounds){var room=new DungeonState.Room(UUID.randomUUID(),name,bounds,List.of(),List.of(),DungeonState.Reward.empty(),null);return mutate(state->{var dungeon=editable(state,id);if(dungeon.rooms().size()>=128)throw new IllegalArgumentException("Room limit reached");if(dungeon.rooms().stream().anyMatch(value->value.bounds().overlaps(bounds)))throw new IllegalArgumentException("Rooms overlap");int maximum=linkDistance();var rooms=new ArrayList<>(dungeon.rooms());rooms.add(room);if(!connected(rooms,maximum))throw new IllegalArgumentException("Room exceeds configured link distance of "+maximum+" blocks");return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),room);});}
|
||||
public CompletableFuture<Optional<DungeonState.Room>> removeRoom(UUID id,UUID roomId){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst();if(found.isEmpty())return result(state,Optional.empty());if(found.orElseThrow().boss()!=null&&found.orElseThrow().boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");var rooms=dungeon.rooms().stream().filter(v->!v.id().equals(roomId)).toList();if(!connected(rooms,linkDistance()))throw new IllegalArgumentException("Removal disconnects rooms");return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),found);});}
|
||||
public CompletableFuture<DungeonState.Spawn> addMob(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Spawn(UUID.randomUUID(),type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{var mobs=new ArrayList<>(room.mobs());if(mobs.size()>=256)throw new IllegalArgumentException("Mob limit reached");mobs.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),mobs,room.effects(),room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Spawn>> removeMob(UUID id,UUID roomId,UUID spawn){var found=room(id,roomId).flatMap(value->value.mobs().stream().filter(v->v.id().equals(spawn)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs().stream().filter(v->!v.id().equals(spawn)).toList(),room.effects(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Effect> addEffect(UUID id,UUID roomId,DungeonState.EffectTarget target,String effect,int level,boolean particles){var value=new DungeonState.Effect(UUID.randomUUID(),target,effect,level,particles);return updateRoom(id,roomId,room->{var effects=new ArrayList<>(room.effects());if(effects.size()>=32)throw new IllegalArgumentException("Effect limit reached");effects.add(value);return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),effects,room.reward(),room.boss());},value);}
|
||||
public CompletableFuture<Optional<DungeonState.Effect>> removeEffect(UUID id,UUID roomId,UUID effect){var found=room(id,roomId).flatMap(value->value.effects().stream().filter(v->v.id().equals(effect)).findFirst());if(found.isEmpty())return CompletableFuture.completedFuture(Optional.empty());return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects().stream().filter(v->!v.id().equals(effect)).toList(),room.reward(),room.boss()),found);}
|
||||
public CompletableFuture<DungeonState.Reward> setReward(UUID id,UUID roomId,BigInteger xp,BigInteger coins){var value=new DungeonState.Reward(xp,coins);return updateRoom(id,roomId,room->new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),value,room.boss()),value);}
|
||||
public CompletableFuture<DungeonState.Boss> setBoss(UUID id,UUID roomId,EntityType type,String name,double x,double y,double z,float yaw,float pitch){var value=new DungeonState.Boss(type,name,new DungeonState.Position(x,y,z,yaw,pitch));return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),value);},value);}
|
||||
public CompletableFuture<Boolean> clearBoss(UUID id,UUID roomId){return updateRoom(id,roomId,room->{if(room.boss()!=null&&room.boss().character()!=null)throw new IllegalArgumentException("Recover character boss custody first");return new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),null);},true);}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> assignCharacterBoss(UUID id,UUID roomId,CharacterRecord character,BigDecimal multiplier){Objects.requireNonNull(character);var template=new DungeonState.CharacterBoss(UUID.randomUUID(),character,multiplier,null);return mutate(state->{var dungeon=editable(state,id);if(state.dungeons().stream().flatMap(v->v.rooms().stream()).map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(custody->custody.character().id().equals(character.id())))throw new IllegalArgumentException("Character already assigned");var room=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));if(room.boss()==null||room.boss().character()!=null)throw new IllegalArgumentException("Configure one native boss room first");var value=new DungeonState.CharacterBoss(template.operation(),character,template.multiplier(),room.boss().type(),room.boss().name(),null);var boss=new DungeonState.Boss(EntityType.VINDICATOR,character.name(),room.boss().position(),value);var updated=new DungeonState.Room(room.id(),room.name(),room.bounds(),room.mobs(),room.effects(),room.reward(),boss);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),value);});}
|
||||
public CompletableFuture<Boolean> cancelAssignment(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var custody=target.boss().character();var prior=new DungeonState.Boss(custody.priorType(),custody.priorName(),target.boss().position());var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),prior);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
|
||||
public CompletableFuture<DungeonState.CharacterBoss> prepareRecovery(UUID id,UUID administrator){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null).findFirst().orElseThrow(()->new IllegalArgumentException("Character boss not found"));var custody=target.boss().character();if(custody.recovery()!=null){if(!custody.recovery().administrator().equals(administrator))throw new IllegalArgumentException("Recovery belongs to another administrator");return result(state,custody);}var prepared=new DungeonState.CharacterBoss(custody.operation(),custody.character(),custody.multiplier(),custody.priorType(),custody.priorName(),new DungeonState.Recovery(UUID.randomUUID(),administrator));var boss=new DungeonState.Boss(target.boss().type(),target.boss().name(),target.boss().position(),prepared);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),boss);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),prepared);});}
|
||||
public CompletableFuture<Boolean> completeRecovery(UUID id,UUID operation){return mutate(state->{var dungeon=editable(state,id);var target=dungeon.rooms().stream().filter(room->room.boss()!=null&&room.boss().character()!=null&&room.boss().character().recovery()!=null&&room.boss().character().recovery().operation().equals(operation)).findFirst().orElse(null);if(target==null)return result(state,false);var changed=new DungeonState.Room(target.id(),target.name(),target.bounds(),target.mobs(),target.effects(),target.reward(),null);var rooms=dungeon.rooms().stream().map(room->room.id().equals(target.id())?changed:room).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),true);});}
|
||||
public CompletableFuture<DungeonState.Dungeon> enable(UUID id,List<DungeonState.Dungeon> externalEnabled){return mutate(state->{var dungeon=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));validate(dungeon);var conflicts=new ArrayList<DungeonState.Dungeon>();conflicts.addAll(state.dungeons().stream().filter(v->v.enabled()&&!v.id().equals(id)).toList());conflicts.addAll(externalEnabled);for(var other:conflicts)if(other.world().equals(dungeon.world())&&dungeon.rooms().stream().anyMatch(a->other.rooms().stream().anyMatch(b->a.bounds().overlaps(b.bounds()))))throw new IllegalArgumentException("Dungeon overlaps an enabled dungeon");if(dungeon.enabled())return result(state,dungeon);var enabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),true);return result(replace(state,enabled),enabled);});}
|
||||
public CompletableFuture<Optional<DungeonState.Dungeon>> disable(UUID id){return mutate(state->{var dungeon=find(state,id).orElse(null);if(dungeon==null)return result(state,Optional.empty());var disabled=definition(dungeon,dungeon.rooms(),dungeon.entrance(),false);return result(replace(new DungeonState(state.dungeons(),state.runs().stream().filter(v->!v.dungeon().equals(id)).toList()),disabled),Optional.of(disabled));});}
|
||||
public CompletableFuture<Boolean> clearRun(UUID dungeon,UUID generation){return mutate(state->{var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).toList();if(runs.size()==state.runs().size())return result(state,false);return result(new DungeonState(state.dungeons(),runs),true);});}
|
||||
public CompletableFuture<DungeonState.Run> run(UUID dungeon,UUID generation){return mutate(state->{requireGeneration(generation);var found=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();if(found.isPresent())return result(state,found.orElseThrow());var value=new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false);var runs=new ArrayList<>(state.runs());runs.add(value);return result(new DungeonState(state.dungeons(),runs),value);});}
|
||||
public Optional<DungeonState.Run> runView(UUID dungeon,UUID generation){return snapshot.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst();}
|
||||
public CompletableFuture<DungeonState.Run> defeatSlot(UUID dungeon,UUID generation,UUID slot){return updateRun(dungeon,generation,run->{var defeated=new HashSet<>(run.defeatedSlots());defeated.add(slot);return new DungeonState.Run(run.dungeon(),run.generation(),defeated,run.clearedRooms(),run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> markCleared(UUID dungeon,UUID generation,UUID room){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,run.unitClaims(),run.characterClaims(),run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> clearRoom(UUID dungeon,UUID generation,UUID room,UUID quest,Set<UUID> characters){return updateRun(dungeon,generation,run->{var cleared=new HashSet<>(run.clearedRooms());cleared.add(room);var units=new HashSet<>(run.unitClaims());units.add(new DungeonState.UnitClaim(room,quest));var claims=new HashSet<>(run.characterClaims());characters.forEach(value->claims.add(new DungeonState.CharacterClaim(room,value)));return new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),cleared,units,claims,run.defeated());});}
|
||||
public CompletableFuture<DungeonState.Run> defeat(UUID dungeon,UUID generation){return updateRun(dungeon,generation,run->new DungeonState.Run(run.dungeon(),run.generation(),run.defeatedSlots(),run.clearedRooms(),run.unitClaims(),run.characterClaims(),true));}
|
||||
@Override public CompletionStage<Void> reset(AdventureQuestState.Reset reset){currentGeneration=reset.generation();return mutate(state->result(new DungeonState(state.dungeons(),List.of()),null));}
|
||||
private <T>CompletableFuture<T> updateRoom(UUID id,UUID roomId,java.util.function.UnaryOperator<DungeonState.Room> operation,T result){return mutate(state->{var dungeon=editable(state,id);var found=dungeon.rooms().stream().filter(v->v.id().equals(roomId)).findFirst().orElseThrow(()->new IllegalArgumentException("Room not found"));var updated=operation.apply(found);var rooms=dungeon.rooms().stream().map(v->v.id().equals(roomId)?updated:v).toList();return result(replace(state,definition(dungeon,rooms,dungeon.entrance(),false)),result);});}
|
||||
private CompletableFuture<DungeonState.Run> updateRun(UUID dungeon,UUID generation,java.util.function.UnaryOperator<DungeonState.Run> operation){return mutate(state->{requireGeneration(generation);var existing=state.runs().stream().filter(v->v.dungeon().equals(dungeon)&&v.generation().equals(generation)).findFirst().orElse(new DungeonState.Run(dungeon,generation,Set.of(),Set.of(),Set.of(),Set.of(),false));var updated=operation.apply(existing);var runs=state.runs().stream().filter(v->!(v.dungeon().equals(dungeon)&&v.generation().equals(generation))).collect(java.util.stream.Collectors.toCollection(ArrayList::new));runs.add(updated);return result(new DungeonState(state.dungeons(),runs),updated);});}
|
||||
private CompletableFuture<DungeonState.Dungeon> change(UUID id,java.util.function.UnaryOperator<DungeonState.Dungeon> operation){return mutate(state->{var updated=operation.apply(editable(state,id));return result(replace(state,updated),updated);});}
|
||||
private static DungeonState.Dungeon editable(DungeonState state,UUID id){var value=find(state,id).orElseThrow(()->new IllegalArgumentException("Dungeon not found"));if(value.enabled())throw new IllegalArgumentException("Dungeon must be disabled");return value;}
|
||||
private static Optional<DungeonState.Dungeon> find(DungeonState state,UUID id){return state.dungeons().stream().filter(v->v.id().equals(id)).findFirst();}
|
||||
private static DungeonState replace(DungeonState state,DungeonState.Dungeon dungeon){var values=state.dungeons().stream().map(v->v.id().equals(dungeon.id())?dungeon:v).toList();return new DungeonState(values,state.runs());}
|
||||
private static DungeonState.Dungeon definition(DungeonState.Dungeon original,List<DungeonState.Room> rooms,DungeonState.Position entrance,boolean enabled){return new DungeonState.Dungeon(original.id(),original.name(),original.world(),entrance,enabled,UUID.randomUUID(),rooms);}
|
||||
public boolean eligibleForFutureOffer(UUID id){var dungeon=find(id).orElse(null);if(dungeon==null||!dungeon.enabled())return false;try{validate(dungeon);return true;}catch(IllegalArgumentException invalid){return false;}}
|
||||
private void validate(DungeonState.Dungeon dungeon){if(overlapping(dungeon.rooms()))throw new IllegalArgumentException("Dungeon rooms overlap");if(dungeon.rooms().stream().map(DungeonState.Room::boss).filter(Objects::nonNull).map(DungeonState.Boss::character).filter(Objects::nonNull).anyMatch(value->value.recovery()!=null))throw new IllegalArgumentException("Character recovery is pending");if(dungeon.rooms().isEmpty()||!connected(dungeon.rooms(),linkDistance()))throw new IllegalArgumentException("Dungeon rooms are incomplete");if(dungeon.rooms().stream().filter(v->v.boss()!=null).count()!=1)throw new IllegalArgumentException("Dungeon needs exactly one boss");}
|
||||
private int linkDistance(){int value=roomLinkDistance.getAsInt();if(value<1||value>256)throw new IllegalArgumentException("Invalid dungeon room link distance");return value;}
|
||||
private static boolean overlapping(List<DungeonState.Room> rooms){for(int left=0;left<rooms.size();left++)for(int right=left+1;right<rooms.size();right++)if(rooms.get(left).bounds().overlaps(rooms.get(right).bounds()))return true;return false;}
|
||||
static boolean connected(List<DungeonState.Room> rooms,int maximum){if(rooms.size()<2)return true;var reached=new HashSet<UUID>();var queue=new ArrayDeque<DungeonState.Room>();queue.add(rooms.getFirst());reached.add(rooms.getFirst().id());while(!queue.isEmpty()){var current=queue.remove();for(var room:rooms)if(!reached.contains(room.id())&¤t.bounds().withinDistance(room.bounds(),maximum)){reached.add(room.id());queue.add(room);}}return reached.size()==rooms.size();}
|
||||
private void requireGeneration(UUID generation){var current=currentGeneration;if(current!=null&&!current.equals(generation))throw new IllegalStateException("Stale dungeon generation");}
|
||||
private record Result<T>(DungeonState state,T value){}
|
||||
private static <T>Result<T> result(DungeonState state,T value){return new Result<>(state,value);}
|
||||
private <T>CompletableFuture<T> mutate(java.util.function.Function<DungeonState,Result<T>> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Dungeon directory closed"));return future;}pending++;}worker.execute(()->{try{var changed=operation.apply(snapshot);if(changed.state()!=snapshot){repository.save(changed.state());snapshot=changed.state();}future.complete(changed.value());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
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,62 @@
|
||||
package games.dmg.fantasystick.dungeon;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.*;
|
||||
import java.util.*;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
/** Immutable durable dungeon definitions and adventure-generation progress. */
|
||||
public record DungeonState(List<Dungeon> dungeons,List<Run> runs) {
|
||||
public enum EffectTarget { PLAYERS, MOBS, BOTH }
|
||||
public record Position(double x,double y,double z,float yaw,float pitch) {
|
||||
public Position {if(!Double.isFinite(x)||!Double.isFinite(y)||!Double.isFinite(z)||!Float.isFinite(yaw)||!Float.isFinite(pitch))throw new IllegalArgumentException("Invalid position");}
|
||||
}
|
||||
public record Bounds(int minX,int minY,int minZ,int maxX,int maxY,int maxZ) {
|
||||
public Bounds {if(minX>maxX||minY>maxY||minZ>maxZ)throw new IllegalArgumentException("Invalid bounds");}
|
||||
public boolean contains(int x,int y,int z){return x>=minX&&x<=maxX&&y>=minY&&y<=maxY&&z>=minZ&&z<=maxZ;}
|
||||
public boolean overlaps(Bounds other){return minX<=other.maxX&&maxX>=other.minX&&minY<=other.maxY&&maxY>=other.minY&&minZ<=other.maxZ&&maxZ>=other.minZ;}
|
||||
public boolean touchesFace(Bounds other){boolean vertical=minY<=other.maxY&&maxY>=other.minY;boolean x=(maxX+1==other.minX||other.maxX+1==minX)&&minZ<=other.maxZ&&maxZ>=other.minZ;boolean z=(maxZ+1==other.minZ||other.maxZ+1==minZ)&&minX<=other.maxX&&maxX>=other.minX;return vertical&&(x||z);}
|
||||
public double distanceSquared(Bounds other){double x=axisDistance(minX,maxX,other.minX,other.maxX),y=axisDistance(minY,maxY,other.minY,other.maxY),z=axisDistance(minZ,maxZ,other.minZ,other.maxZ);return x*x+y*y+z*z;}
|
||||
public boolean withinDistance(Bounds other,int maximum){return maximum>=1&&distanceSquared(other)<=(double)maximum*maximum;}
|
||||
private static double axisDistance(int minimum,int maximum,int otherMinimum,int otherMaximum){if(maximum<otherMinimum)return (long)otherMinimum-maximum;if(otherMaximum<minimum)return (long)minimum-otherMaximum;return 0;}
|
||||
}
|
||||
public record Spawn(UUID id,EntityType type,String name,Position position) {
|
||||
public Spawn {Objects.requireNonNull(id);Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(position);}
|
||||
}
|
||||
public record Effect(UUID id,EffectTarget target,String effect,int level,boolean particles) {
|
||||
public Effect {Objects.requireNonNull(id);Objects.requireNonNull(target);Objects.requireNonNull(effect);if(effect.isBlank()||effect.length()>128||level<1||level>256)throw new IllegalArgumentException("Invalid effect");}
|
||||
}
|
||||
public record Reward(BigInteger xp,BigInteger coins) {
|
||||
public Reward {Objects.requireNonNull(xp);Objects.requireNonNull(coins);var maximum=BigInteger.valueOf(Integer.MAX_VALUE);if(xp.signum()<0||coins.signum()<0||xp.compareTo(maximum)>0||coins.compareTo(maximum)>0)throw new IllegalArgumentException("Invalid reward");}
|
||||
public static Reward empty(){return new Reward(BigInteger.ZERO,BigInteger.ZERO);}
|
||||
}
|
||||
public record Recovery(UUID operation,UUID administrator) {public Recovery{Objects.requireNonNull(operation);Objects.requireNonNull(administrator);}}
|
||||
public record CharacterBoss(UUID operation,CharacterRecord character,BigDecimal multiplier,EntityType priorType,String priorName,Recovery recovery) {
|
||||
public CharacterBoss {Objects.requireNonNull(operation);Objects.requireNonNull(character);Objects.requireNonNull(multiplier);Objects.requireNonNull(priorType);priorName=validName(priorName);if(character.dead()||character.health().signum()<=0||multiplier.scale()>3||multiplier.compareTo(new BigDecimal("0.1"))<0||multiplier.compareTo(new BigDecimal("100"))>0)throw new IllegalArgumentException("Invalid character boss");multiplier=multiplier.stripTrailingZeros();}
|
||||
public CharacterBoss(UUID operation,CharacterRecord character,BigDecimal multiplier,Recovery recovery){this(operation,character,multiplier,EntityType.HUSK,"",recovery);}
|
||||
public Map<Stat,BigInteger> effectiveStats(){var result=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())result.put(stat,new BigDecimal(character.stats().get(stat)).multiply(multiplier).setScale(0,RoundingMode.FLOOR).toBigIntegerExact());return Map.copyOf(result);}
|
||||
}
|
||||
public record Boss(EntityType type,String name,Position position,CharacterBoss character) {
|
||||
public Boss {Objects.requireNonNull(type);name=validName(name);Objects.requireNonNull(position);if(character!=null&&(type!=EntityType.VINDICATOR||!name.equals(character.character().name())))throw new IllegalArgumentException("Invalid character boss shell");}
|
||||
public Boss(EntityType type,String name,Position position){this(type,name,position,null);}
|
||||
}
|
||||
public record Room(UUID id,String name,Bounds bounds,List<Spawn> mobs,List<Effect> effects,Reward reward,Boss boss) {
|
||||
public Room {Objects.requireNonNull(id);name=validName(name);Objects.requireNonNull(bounds);mobs=List.copyOf(mobs);effects=List.copyOf(effects);Objects.requireNonNull(reward);unique(mobs.stream().map(Spawn::id).toList());unique(effects.stream().map(Effect::id).toList());if(mobs.size()>256||effects.size()>32)throw new IllegalArgumentException("Room limit exceeded");}
|
||||
public String displayName(){return name.isBlank()?"Room":name;}
|
||||
}
|
||||
public record Dungeon(UUID id,String name,UUID world,Position entrance,boolean enabled,UUID revision,List<Room> rooms) {
|
||||
public Dungeon {Objects.requireNonNull(id);name=validName(name);if(name.isBlank())throw new IllegalArgumentException("Dungeon name required");Objects.requireNonNull(world);Objects.requireNonNull(entrance);Objects.requireNonNull(revision);rooms=List.copyOf(rooms);unique(rooms.stream().map(Room::id).toList());if(rooms.size()>128)throw new IllegalArgumentException("Dungeon room limit exceeded");}
|
||||
public BigInteger maximumXp(){return rooms.stream().map(v->v.reward().xp()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
public BigInteger maximumCoins(){return rooms.stream().map(v->v.reward().coins()).reduce(BigInteger.ZERO,BigInteger::add);}
|
||||
}
|
||||
public record UnitClaim(UUID room,UUID quest) {public UnitClaim {Objects.requireNonNull(room);Objects.requireNonNull(quest);}}
|
||||
public record CharacterClaim(UUID room,UUID character) {public CharacterClaim {Objects.requireNonNull(room);Objects.requireNonNull(character);}}
|
||||
public record Run(UUID dungeon,UUID generation,Set<UUID> defeatedSlots,Set<UUID> clearedRooms,Set<UnitClaim> unitClaims,Set<CharacterClaim> characterClaims,boolean defeated) {
|
||||
public Run {Objects.requireNonNull(dungeon);Objects.requireNonNull(generation);defeatedSlots=Set.copyOf(defeatedSlots);clearedRooms=Set.copyOf(clearedRooms);unitClaims=Set.copyOf(unitClaims);characterClaims=Set.copyOf(characterClaims);}
|
||||
}
|
||||
public DungeonState {dungeons=List.copyOf(dungeons);runs=List.copyOf(runs);unique(dungeons.stream().map(Dungeon::id).toList());var keys=runs.stream().map(v->v.dungeon()+":"+v.generation()).toList();if(new HashSet<>(keys).size()!=keys.size())throw new IllegalArgumentException("Duplicate dungeon run");}
|
||||
public static DungeonState empty(){return new DungeonState(List.of(),List.of());}
|
||||
private static String validName(String name){Objects.requireNonNull(name);name=name.strip();if(name.codePointCount(0,name.length())>64||name.codePoints().anyMatch(c->{int t=Character.getType(c);return t==Character.CONTROL||t==Character.FORMAT||t==Character.SURROGATE;}))throw new IllegalArgumentException("Invalid name");return name;}
|
||||
private static void unique(List<UUID> values){if(new HashSet<>(values).size()!=values.size())throw new IllegalArgumentException("Duplicate identity");}
|
||||
}
|
||||
@@ -14,8 +14,10 @@ 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 NamespacedKey RECOVERY = new NamespacedKey("fantasystick", "boss_recovery_operation");
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
|
||||
|
||||
@@ -26,15 +28,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 +51,20 @@ public final class CharacterSheets {
|
||||
return item;
|
||||
}
|
||||
|
||||
public void markRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())throw new IllegalArgumentException("Not an authentic character sheet");var meta=item.getItemMeta();meta.getPersistentDataContainer().set(RECOVERY,PersistentDataType.STRING,operation.toString());item.setItemMeta(meta);}
|
||||
public boolean hasRecovery(ItemStack item,UUID operation){if(identify(item).isEmpty())return false;return operation.toString().equals(item.getItemMeta().getPersistentDataContainer().get(RECOVERY,PersistentDataType.STRING));}
|
||||
public void clearRecovery(ItemStack item){if(item==null||!item.hasItemMeta())return;var meta=item.getItemMeta();meta.getPersistentDataContainer().remove(RECOVERY);item.setItemMeta(meta);}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -131,7 +131,7 @@ public final class EquipmentStorage {
|
||||
for (int slot : EQUIPMENT_SLOTS) {
|
||||
if (slot == 39) continue;
|
||||
var current = inventory.getItem(slot);
|
||||
if (!empty(current) && !(slot < 9 && ownedControl.test(current.clone()))) return RestoreResult.OCCUPIED_SLOT;
|
||||
if (!empty(current) && !ownedControl.test(current.clone())) return RestoreResult.OCCUPIED_SLOT;
|
||||
}
|
||||
var currentHead = inventory.getItem(39);
|
||||
var displacedHead = empty(currentHead) ? null : currentHead.clone();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.*;import org.bukkit.inventory.*;import org.bukkit.persistence.PersistentDataType;import org.bukkit.plugin.Plugin;
|
||||
/** Native unbreakable session-bound projection of permanent character armor. */
|
||||
public final class GearItems{
|
||||
private final NamespacedKey typeKey,ownerKey,sessionKey,slotKey,tierKey;
|
||||
public GearItems(Plugin plugin){typeKey=new NamespacedKey(plugin,"character_gear");ownerKey=new NamespacedKey(plugin,"character_gear_owner");sessionKey=new NamespacedKey(plugin,"character_gear_session");slotKey=new NamespacedKey(plugin,"character_gear_slot");tierKey=new NamespacedKey(plugin,"character_gear_tier");}
|
||||
public ItemStack create(GearSlot slot,GearTier tier,UUID owner,UUID session){if(tier==GearTier.NONE)return null;var item=new ItemStack(material(slot,tier));var meta=item.getItemMeta();meta.setUnbreakable(true);meta.setMaxStackSize(1);meta.displayName(Component.text("Fantasy "+title(tier)+" "+title(slot)));var data=meta.getPersistentDataContainer();data.set(typeKey,PersistentDataType.INTEGER,1);data.set(ownerKey,PersistentDataType.STRING,owner.toString());data.set(sessionKey,PersistentDataType.STRING,session.toString());data.set(slotKey,PersistentDataType.STRING,slot.name());data.set(tierKey,PersistentDataType.STRING,tier.name());item.setItemMeta(meta);return item;}
|
||||
public boolean gear(ItemStack item){return item!=null&&!item.isEmpty()&&Integer.valueOf(1).equals(item.getItemMeta().getPersistentDataContainer().get(typeKey,PersistentDataType.INTEGER));}
|
||||
public boolean owned(ItemStack item,UUID owner,UUID session){if(!gear(item))return false;var data=item.getItemMeta().getPersistentDataContainer();return owner.toString().equals(data.get(ownerKey,PersistentDataType.STRING))&&session.toString().equals(data.get(sessionKey,PersistentDataType.STRING));}
|
||||
public void install(PlayerInventory inventory,CharacterRecord record,UUID owner,UUID session){inventory.setChestplate(create(GearSlot.CHESTPLATE,record.gear().chestplate(),owner,session));inventory.setLeggings(create(GearSlot.LEGGINGS,record.gear().leggings(),owner,session));inventory.setBoots(create(GearSlot.BOOTS,record.gear().boots(),owner,session));}
|
||||
private static String title(Enum<?> value){String text=value.name().toLowerCase(Locale.ROOT).replace('_',' ');return Character.toUpperCase(text.charAt(0))+text.substring(1);}
|
||||
private static Material material(GearSlot slot,GearTier tier){return Material.valueOf((tier==GearTier.GOLD?"GOLDEN":tier.name())+"_"+switch(slot){case CHESTPLATE->"CHESTPLATE";case LEGGINGS->"LEGGINGS";case BOOTS->"BOOTS";});}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -17,7 +18,9 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
* The caller must enforce session custody and suppress vanilla item uses; this class registers no events.
|
||||
*/
|
||||
public final class SessionControls {
|
||||
public static final NamespacedKey COOLDOWN_MODEL = new NamespacedKey("fantasystick", "cooldown_control");
|
||||
private final games.dmg.fantasystick.ability.AbilityRegistry registry;
|
||||
private final Clock clock;
|
||||
private static final NamespacedKey VERSION = key("control_version");
|
||||
private static final NamespacedKey OWNER = key("control_owner_id");
|
||||
private static final NamespacedKey SESSION = key("control_session_id");
|
||||
@@ -52,9 +55,13 @@ public final class SessionControls {
|
||||
|
||||
public record Identity(UUID owner, UUID session, int slot) { }
|
||||
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry()); }
|
||||
public SessionControls() { this(new games.dmg.fantasystick.ability.AbilityRegistry(), Clock.systemUTC()); }
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
this(registry, Clock.systemUTC());
|
||||
}
|
||||
public SessionControls(games.dmg.fantasystick.ability.AbilityRegistry registry, Clock clock) {
|
||||
this.registry = Objects.requireNonNull(registry);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
private Icon icon(String ability) {
|
||||
@@ -71,24 +78,32 @@ public final class SessionControls {
|
||||
Objects.requireNonNull(owner);
|
||||
Objects.requireNonNull(session);
|
||||
var items = new ItemStack[9];
|
||||
var now = clock.instant();
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
String ability = slot < 2 ? "" : character.loadout().get(slot - 2);
|
||||
Icon abilityIcon = ability.isEmpty() ? null : icon(ability);
|
||||
var deadline = ability.isEmpty() ? null : character.cooldowns().get(ability);
|
||||
boolean cooling = abilityIcon != null && deadline != null && now.isBefore(deadline);
|
||||
Icon icon = slot == 0 ? new Icon(Material.BOOK, "Character book")
|
||||
: slot == 1 ? new Icon(Material.STICK, "Basic attack")
|
||||
: ability.isEmpty() ? new Icon(Material.GRAY_DYE, "No Ability")
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability) : abilityIcon;
|
||||
: abilityIcon == null ? new Icon(Material.PAPER, "Unavailable ability: " + ability)
|
||||
: cooling ? new Icon(Material.GRAY_STAINED_GLASS_PANE, abilityIcon.name() + " — Cooling Down") : abilityIcon;
|
||||
var item = new ItemStack(icon.material());
|
||||
var meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(icon.name()));
|
||||
meta.setMaxStackSize(1);
|
||||
if (slot == 1 && character.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID)) {
|
||||
meta.addEnchant(org.bukkit.enchantments.Enchantment.KNOCKBACK, 1, true);
|
||||
}
|
||||
if (cooling) meta.setItemModel(COOLDOWN_MODEL);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(OWNER, PersistentDataType.STRING, owner.toString());
|
||||
data.set(SESSION, PersistentDataType.STRING, session.toString());
|
||||
data.set(SLOT, PersistentDataType.INTEGER, slot);
|
||||
data.set(KIND, PersistentDataType.STRING, slot == 0 ? "book" : slot == 1 ? "basic_attack"
|
||||
: ability.isEmpty() ? "empty" : abilityIcon != null ? "ability" : "unavailable");
|
||||
: ability.isEmpty() ? "empty" : abilityIcon == null ? "unavailable" : cooling ? "cooldown" : "ability");
|
||||
if (!ability.isEmpty()) data.set(ABILITY, PersistentDataType.STRING, ability);
|
||||
item.setItemMeta(meta);
|
||||
items[slot] = item;
|
||||
@@ -123,13 +138,14 @@ public final class SessionControls {
|
||||
public Optional<String> ability(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return Optional.empty();
|
||||
var data = item.getPersistentDataContainer();
|
||||
return "ability".equals(data.get(KIND, PersistentDataType.STRING))
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
return kind.equals("ability") || kind.equals("cooldown")
|
||||
? Optional.of(data.get(ABILITY, PersistentDataType.STRING)) : Optional.empty();
|
||||
}
|
||||
|
||||
private boolean validKind(Material material, int slot, PersistentDataContainer data) {
|
||||
String kind = data.get(KIND, PersistentDataType.STRING);
|
||||
if (slot == 0) return kind.equals("book") && material == Material.BOOK && !data.has(ABILITY);
|
||||
if (slot == 0) return kind.equals("book") && (material == Material.BOOK || material == Material.COMPASS) && !data.has(ABILITY);
|
||||
if (slot == 1) return kind.equals("basic_attack") && material == Material.STICK && !data.has(ABILITY);
|
||||
if (kind.equals("empty")) return material == Material.GRAY_DYE && !data.has(ABILITY);
|
||||
if (!data.has(ABILITY, PersistentDataType.STRING)) return false;
|
||||
@@ -137,6 +153,7 @@ public final class SessionControls {
|
||||
if (ability.isBlank()) return false;
|
||||
Icon icon = icon(ability);
|
||||
return kind.equals("ability") ? icon != null && icon.material() == material
|
||||
: kind.equals("cooldown") ? icon != null && material == Material.GRAY_STAINED_GLASS_PANE
|
||||
: kind.equals("unavailable") && icon == null && material == Material.PAPER;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,11 @@ public final class TailBooks {
|
||||
meta.displayName(Component.text(fullTitle));
|
||||
List<Component> pages = new ArrayList<>();
|
||||
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
|
||||
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
|
||||
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints())
|
||||
.append("\nCoins: ").append(character.coins());
|
||||
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
|
||||
pages.addAll(BookPagination.pages(summary.toString()));
|
||||
pages.addAll(BookPagination.pages("Gear\nHead: Cosmetic\nChestplate: "+tier(character.gear().chestplate())+"\nLeggings: "+tier(character.gear().leggings())+"\nBoots: "+tier(character.gear().boots())));
|
||||
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
|
||||
character.learned().stream().sorted().forEach(ability -> {
|
||||
var slots = new ArrayList<String>();
|
||||
@@ -52,6 +54,14 @@ public final class TailBooks {
|
||||
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
|
||||
pages.addAll(BookPagination.pages(section.toString()));
|
||||
});
|
||||
if (character.passives().stream().allMatch(String::isEmpty)) {
|
||||
pages.addAll(BookPagination.pages("Passives: None"));
|
||||
} else {
|
||||
for (int slot = 0; slot < character.passives().size(); slot++) {
|
||||
String passive = character.passives().get(slot);
|
||||
if (!passive.isEmpty()) pages.addAll(BookPagination.pages("Passive: " + passive + "\nSlot: " + (slot + 1)));
|
||||
}
|
||||
}
|
||||
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
|
||||
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
|
||||
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
|
||||
@@ -65,6 +75,11 @@ public final class TailBooks {
|
||||
return item;
|
||||
}
|
||||
|
||||
private static String tier(games.dmg.fantasystick.persistence.GearTier tier) {
|
||||
String value = tier.name().toLowerCase(java.util.Locale.ROOT);
|
||||
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
|
||||
}
|
||||
|
||||
public Optional<Identity> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable party identity; every person reference is a transferable character UUID. */
|
||||
public record CharacterParty(UUID id, BigInteger revision, String displayName, String canonicalName,
|
||||
UUID leader, Set<UUID> members, List<Invitation> invitations,UUID lastPaidOperation) {
|
||||
public CharacterParty(UUID id,BigInteger revision,String displayName,String canonicalName,UUID leader,Set<UUID> members,List<Invitation> invitations){this(id,revision,displayName,canonicalName,leader,members,invitations,null);}
|
||||
public record Invitation(UUID id, UUID character) {
|
||||
public Invitation { Objects.requireNonNull(id); Objects.requireNonNull(character); }
|
||||
}
|
||||
|
||||
public CharacterParty {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(revision); Objects.requireNonNull(displayName);
|
||||
Objects.requireNonNull(canonicalName); Objects.requireNonNull(leader);
|
||||
members = Set.copyOf(members); invitations = List.copyOf(invitations);
|
||||
var memberIds = members;
|
||||
var parsed = PartyNames.parse(displayName);
|
||||
var invitationIds = new HashSet<UUID>();
|
||||
var targets = new HashSet<UUID>();
|
||||
if (revision.signum() < 0 || parsed.isEmpty() || !parsed.orElseThrow().canonical().equals(canonicalName)
|
||||
|| !members.contains(leader) || members.isEmpty()
|
||||
|| invitations.stream().anyMatch(invitation -> !invitationIds.add(invitation.id())
|
||||
|| !targets.add(invitation.character()) || memberIds.contains(invitation.character()))) {
|
||||
throw new IllegalArgumentException("Invalid character party");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.PartyOperationStore;
|
||||
import games.dmg.fantasystick.persistence.YamlPartyRepository;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
/** Serialized durable character-party mutations with an immutable acknowledged read snapshot. */
|
||||
public final class CharacterPartyDirectory implements AutoCloseable {
|
||||
public enum Status { APPLIED, DUPLICATE, NAME_TAKEN, FULL, ALREADY_MEMBER, INVALID }
|
||||
public enum Defeat { UNCHANGED, MEMBER_REMOVED, PARTY_DISBANDED }
|
||||
public record Offer(UUID id, UUID party, UUID character) { }
|
||||
public record PaidResult(Result result,CharacterStorage.CoinSpend spend){}
|
||||
public record Result(Status status, Optional<CharacterParty> party, Optional<Offer> invitation) {
|
||||
static Result of(Status status) { return new Result(status, Optional.empty(), Optional.empty()); }
|
||||
static Result party(Status status, CharacterParty party) {
|
||||
return new Result(status, Optional.of(party), Optional.empty());
|
||||
}
|
||||
static Result invite(Status status, CharacterParty party, CharacterParty.Invitation invitation) {
|
||||
return new Result(status, Optional.of(party), Optional.of(new Offer(invitation.id(), party.id(), invitation.character())));
|
||||
}
|
||||
}
|
||||
|
||||
private final YamlPartyRepository repository;
|
||||
private final IntSupplier maximumSize;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-party-persistence"));
|
||||
private volatile List<CharacterParty> snapshot;
|
||||
private volatile java.util.function.Consumer<CharacterParty> ended=ignored->{};
|
||||
private volatile java.util.function.Predicate<UUID> unavailable=ignored->false;
|
||||
private volatile boolean closed;
|
||||
private volatile boolean ready=true;
|
||||
private volatile CompletableFuture<Void> readiness=CompletableFuture.completedFuture(null);
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public CharacterPartyDirectory(YamlPartyRepository repository, IntSupplier maximumSize) throws IOException {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.maximumSize = Objects.requireNonNull(maximumSize);
|
||||
snapshot = repository.load();
|
||||
maximumSize();
|
||||
}
|
||||
|
||||
public List<CharacterParty> all(){return snapshot;}
|
||||
public synchronized void suspend(){if(!ready)return;ready=false;readiness=new CompletableFuture<>();}
|
||||
public synchronized void resume(){ready=true;readiness.complete(null);}public boolean ready(){return ready;}public java.util.concurrent.CompletionStage<Void> readiness(){return readiness.minimalCompletionStage();}
|
||||
public void attachEnded(java.util.function.Consumer<CharacterParty> notification){ended=Objects.requireNonNull(notification);}
|
||||
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){unavailable=Objects.requireNonNull(predicate);}
|
||||
public Optional<CharacterParty> partyOf(UUID character) {
|
||||
return snapshot.stream().filter(party -> party.members().contains(character)).findFirst();
|
||||
}
|
||||
public Optional<CharacterParty> partyById(UUID id) {
|
||||
return snapshot.stream().filter(party -> party.id().equals(id)).findFirst();
|
||||
}
|
||||
public Optional<CharacterParty> partyByCanonicalName(String canonical) {
|
||||
return snapshot.stream().filter(party -> party.canonicalName().equals(canonical)).findFirst();
|
||||
}
|
||||
public List<Offer> invitationsFor(UUID character) {
|
||||
return snapshot.stream().flatMap(party -> party.invitations().stream()
|
||||
.filter(invitation -> invitation.character().equals(character))
|
||||
.map(invitation -> new Offer(invitation.id(), party.id(), character)))
|
||||
.sorted(Comparator.comparing(Offer::id)).toList();
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> create(UUID leader, String input) {
|
||||
return create(UUID.randomUUID(),leader,input);
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> create(UUID partyId, UUID leader, String input) {
|
||||
return submit(() -> createNow(partyId,leader,input));
|
||||
}
|
||||
private Result createNow(UUID partyId,UUID leader,String input)throws IOException{
|
||||
if(unavailable.test(leader))return Result.of(Status.INVALID);
|
||||
var completed=partyById(partyId);if(completed.isPresent())return Result.party(Status.DUPLICATE,completed.orElseThrow());
|
||||
var name=PartyNames.parse(input);if(name.isEmpty())return Result.of(Status.INVALID);
|
||||
if(partyOf(leader).isPresent())return Result.of(Status.ALREADY_MEMBER);
|
||||
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return Result.of(Status.NAME_TAKEN);
|
||||
var party=new CharacterParty(partyId,BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),leader,Set.of(leader),List.of());var next=new ArrayList<>(snapshot);next.add(party);save(next);return Result.party(Status.APPLIED,party);
|
||||
}
|
||||
public CompletableFuture<PaidResult> paidCreate(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
|
||||
if(plan.kind()!=PartyOperationStore.Kind.CREATE)throw new IllegalArgumentException("Expected create operation");
|
||||
if(unavailable.test(plan.actor()))return new PaidResult(Result.of(Status.INVALID),null);
|
||||
var existing=partyById(plan.id());if(existing.isPresent()){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){remove(existing.orElseThrow());return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));}return new PaidResult(Result.party(Status.DUPLICATE,existing.orElseThrow()),spent);}
|
||||
var name=PartyNames.parse(plan.name());if(name.isEmpty())return new PaidResult(Result.of(Status.INVALID),null);
|
||||
if(partyOf(plan.actor()).isPresent())return new PaidResult(Result.of(Status.ALREADY_MEMBER),null);
|
||||
if(partyByCanonicalName(name.orElseThrow().canonical()).isPresent())return new PaidResult(Result.of(Status.NAME_TAKEN),null);
|
||||
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
|
||||
var party=new CharacterParty(plan.id(),BigInteger.ZERO,name.orElseThrow().display(),name.orElseThrow().canonical(),plan.actor(),Set.of(plan.actor()),List.of(),plan.id());var next=new ArrayList<>(snapshot);next.add(party);try{save(next);return new PaidResult(Result.party(Status.APPLIED,party),spent);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}
|
||||
});}
|
||||
|
||||
public CompletableFuture<Result> invite(UUID leader, UUID partyId, UUID target) {
|
||||
return submit(() -> {
|
||||
var party = managed(leader, partyId);
|
||||
if (unavailable.test(target) || party.isEmpty() || partyOf(target).isPresent() || leader.equals(target)) return Result.of(Status.INVALID);
|
||||
var existing = party.orElseThrow().invitations().stream()
|
||||
.filter(invitation -> invitation.character().equals(target)).findFirst();
|
||||
if (existing.isPresent()) return Result.invite(Status.DUPLICATE, party.orElseThrow(), existing.orElseThrow());
|
||||
var invitation = new CharacterParty.Invitation(UUID.randomUUID(), target);
|
||||
var invitations = new ArrayList<>(party.orElseThrow().invitations()); invitations.add(invitation);
|
||||
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), party.orElseThrow().members(), invitations);
|
||||
replace(party.orElseThrow(), updated);
|
||||
return Result.invite(Status.APPLIED, updated, invitation);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> accept(UUID character, UUID invitationId) {
|
||||
return submit(() -> {
|
||||
if(unavailable.test(character))return Result.of(Status.INVALID);
|
||||
CharacterParty party = null; CharacterParty.Invitation invitation = null;
|
||||
for (var candidate : snapshot) for (var offer : candidate.invitations()) {
|
||||
if (offer.id().equals(invitationId)) { party = candidate; invitation = offer; }
|
||||
}
|
||||
if (party == null || !invitation.character().equals(character)) return Result.of(Status.INVALID);
|
||||
if (partyOf(character).isPresent()) return Result.of(Status.ALREADY_MEMBER);
|
||||
if (party.members().size() >= maximumSize()) return Result.of(Status.FULL);
|
||||
var members = new java.util.HashSet<>(party.members()); members.add(character);
|
||||
var updated = copy(party, party.leader(), members,
|
||||
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
|
||||
var next = new ArrayList<CharacterParty>();
|
||||
for (var candidate : snapshot) {
|
||||
var base = candidate.id().equals(party.id()) ? updated : candidate;
|
||||
var offers = base.invitations().stream().filter(value -> !value.character().equals(character)).toList();
|
||||
next.add(offers.size() == base.invitations().size() ? base
|
||||
: copy(base, base.leader(), base.members(), offers));
|
||||
}
|
||||
save(next);
|
||||
return Result.party(Status.APPLIED, partyById(party.id()).orElseThrow());
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> decline(UUID character, UUID invitationId) {
|
||||
return submit(() -> {
|
||||
for (var party : snapshot) {
|
||||
var invitation = party.invitations().stream().filter(value -> value.id().equals(invitationId)).findFirst();
|
||||
if (invitation.isEmpty() || !invitation.orElseThrow().character().equals(character)) continue;
|
||||
var updated = copy(party, party.leader(), party.members(),
|
||||
party.invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
|
||||
replace(party, updated); return Result.party(Status.APPLIED, updated);
|
||||
}
|
||||
return Result.of(Status.INVALID);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> cancel(UUID leader, UUID partyId, UUID invitationId) {
|
||||
return submit(() -> {
|
||||
var party = managed(leader, partyId);
|
||||
if (party.isEmpty() || party.orElseThrow().invitations().stream().noneMatch(value -> value.id().equals(invitationId))) {
|
||||
return Result.of(Status.INVALID);
|
||||
}
|
||||
var updated = copy(party.orElseThrow(), leader, party.orElseThrow().members(),
|
||||
party.orElseThrow().invitations().stream().filter(value -> !value.id().equals(invitationId)).toList());
|
||||
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> kick(UUID leader, UUID partyId, UUID member) {
|
||||
return submit(() -> {
|
||||
var party = managed(leader, partyId);
|
||||
if (party.isEmpty() || member.equals(leader) || !party.orElseThrow().members().contains(member)) {
|
||||
return Result.of(Status.INVALID);
|
||||
}
|
||||
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(member);
|
||||
var updated = copy(party.orElseThrow(), leader, members, party.orElseThrow().invitations());
|
||||
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> leave(UUID character, UUID partyId) {
|
||||
return submit(() -> {
|
||||
var party = partyOf(character).filter(value -> value.id().equals(partyId));
|
||||
if (party.isEmpty()) return Result.of(Status.INVALID);
|
||||
if (party.orElseThrow().leader().equals(character)) {
|
||||
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
|
||||
}
|
||||
var members = new java.util.HashSet<>(party.orElseThrow().members()); members.remove(character);
|
||||
var updated = copy(party.orElseThrow(), party.orElseThrow().leader(), members,
|
||||
party.orElseThrow().invitations());
|
||||
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> disband(UUID leader, UUID partyId) {
|
||||
return submit(() -> {
|
||||
var party = managed(leader, partyId);
|
||||
if (party.isEmpty()) return Result.of(Status.INVALID);
|
||||
remove(party.orElseThrow()); return Result.party(Status.APPLIED, party.orElseThrow());
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Result> transfer(UUID leader, UUID partyId, UUID target) {
|
||||
return submit(() -> {
|
||||
var existing=partyById(partyId);
|
||||
if(existing.isPresent()&&existing.orElseThrow().leader().equals(target)
|
||||
&&existing.orElseThrow().members().contains(leader))return Result.party(Status.DUPLICATE,existing.orElseThrow());
|
||||
var party = managed(leader, partyId);
|
||||
if (party.isEmpty() || target.equals(leader) || !party.orElseThrow().members().contains(target)) {
|
||||
return Result.of(Status.INVALID);
|
||||
}
|
||||
var updated = copy(party.orElseThrow(), target, party.orElseThrow().members(), party.orElseThrow().invitations());
|
||||
replace(party.orElseThrow(), updated); return Result.party(Status.APPLIED, updated);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<PaidResult> paidTransfer(PartyOperationStore.Plan plan,CharacterStorage characters){return submit(()->{
|
||||
if(plan.kind()!=PartyOperationStore.Kind.TRANSFER)throw new IllegalArgumentException("Expected transfer operation");
|
||||
var existing=partyById(plan.party());
|
||||
if(existing.isPresent()&&plan.id().equals(existing.orElseThrow().lastPaidOperation())){var spent=debit(plan,characters,plan.expectedRevision());if(refunded(plan,spent)){var reverted=new CharacterParty(existing.orElseThrow().id(),existing.orElseThrow().revision().add(BigInteger.ONE),existing.orElseThrow().displayName(),existing.orElseThrow().canonicalName(),plan.actor(),existing.orElseThrow().members(),existing.orElseThrow().invitations(),null);replace(existing.orElseThrow(),reverted);return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));}return new PaidResult(Result.party(Status.DUPLICATE,existing.orElseThrow()),spent);}
|
||||
if(existing.isEmpty()||!existing.orElseThrow().revision().equals(plan.expectedPartyRevision()))return new PaidResult(Result.of(Status.INVALID),null);
|
||||
var party=managed(plan.actor(),plan.party());if(party.isEmpty()||plan.target().equals(plan.actor())||!party.orElseThrow().members().contains(plan.target()))return new PaidResult(Result.of(Status.INVALID),null);
|
||||
var spent=debit(plan,characters,plan.expectedRevision());if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),spent);if(refunded(plan,spent))return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,spent.record()));
|
||||
var updated=new CharacterParty(party.orElseThrow().id(),party.orElseThrow().revision().add(BigInteger.ONE),party.orElseThrow().displayName(),party.orElseThrow().canonicalName(),plan.target(),party.orElseThrow().members(),party.orElseThrow().invitations(),plan.id());try{replace(party.orElseThrow(),updated);}catch(IOException persistenceFailure){return compensate(plan,characters,persistenceFailure);}return new PaidResult(Result.party(Status.APPLIED,updated),spent);
|
||||
});}
|
||||
private CharacterStorage.CoinSpend debit(PartyOperationStore.Plan plan,CharacterStorage characters,BigInteger expected){return characters.spendCoins(plan.actor(),expected,plan.id(),plan.amount()).join();}
|
||||
private static boolean refunded(PartyOperationStore.Plan plan,CharacterStorage.CoinSpend spent){return spent.record().map(value->value.rewardReceipts().contains(PartyOperationStore.refundId(plan.id()))).orElse(false);}
|
||||
private PaidResult compensate(PartyOperationStore.Plan plan,CharacterStorage characters,IOException failure)throws IOException{var refund=characters.refundCoins(plan.actor(),plan.id(),PartyOperationStore.refundId(plan.id()),plan.amount()).join();if(refund.status()==CharacterStorage.CoinRefundStatus.APPLIED||refund.status()==CharacterStorage.CoinRefundStatus.DUPLICATE)return new PaidResult(Result.of(Status.INVALID),new CharacterStorage.CoinSpend(CharacterStorage.CoinSpendStatus.STALE,refund.record()));throw failure;}
|
||||
|
||||
public CompletableFuture<Defeat> defeat(UUID character) {
|
||||
return submit(() -> {
|
||||
var membership=partyOf(character);var endedParty=membership.filter(value->value.leader().equals(character)).orElse(null);var next=new ArrayList<CharacterParty>();boolean changed=false;
|
||||
for(var party:snapshot){if(endedParty!=null&&party.id().equals(endedParty.id())){changed=true;continue;}var members=party.members();if(membership.isPresent()&&party.id().equals(membership.orElseThrow().id())){var reduced=new java.util.HashSet<>(members);reduced.remove(character);members=Set.copyOf(reduced);changed=true;}var invitations=party.invitations().stream().filter(value->!value.character().equals(character)).toList();if(invitations.size()!=party.invitations().size())changed=true;next.add(members.equals(party.members())&&invitations.equals(party.invitations())?party:copy(party,party.leader(),members,invitations));}
|
||||
if(changed)save(next);if(endedParty!=null){try{ended.accept(endedParty);}catch(RuntimeException ignored){}return Defeat.PARTY_DISBANDED;}return membership.isPresent()?Defeat.MEMBER_REMOVED:Defeat.UNCHANGED;
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<CharacterParty> managed(UUID leader, UUID id) {
|
||||
return partyById(id).filter(party -> party.leader().equals(leader));
|
||||
}
|
||||
private CharacterParty copy(CharacterParty old, UUID leader, Set<UUID> members,
|
||||
List<CharacterParty.Invitation> invitations) {
|
||||
return new CharacterParty(old.id(), old.revision().add(BigInteger.ONE), old.displayName(), old.canonicalName(),
|
||||
leader, members, invitations,old.lastPaidOperation());
|
||||
}
|
||||
private void replace(CharacterParty old, CharacterParty updated) throws IOException {
|
||||
var next = snapshot.stream().map(party -> party.id().equals(old.id()) ? updated : party).toList(); save(next);
|
||||
}
|
||||
private void remove(CharacterParty party) throws IOException {
|
||||
save(snapshot.stream().filter(candidate -> !candidate.id().equals(party.id())).toList());
|
||||
try{ended.accept(party);}catch(RuntimeException ignored){}
|
||||
}
|
||||
private void save(List<CharacterParty> next) throws IOException {
|
||||
repository.save(next); snapshot = List.copyOf(next);
|
||||
}
|
||||
private int maximumSize() {
|
||||
int value = maximumSize.getAsInt(); if (value < 1) throw new IllegalStateException("Party maximum must be positive");
|
||||
return value;
|
||||
}
|
||||
private <T> CompletableFuture<T> submit(Callable<T> operation) {
|
||||
var future = new CompletableFuture<T>();
|
||||
synchronized(this){if (closed) { future.completeExceptionally(new IllegalStateException("Party directory is closed")); return future; }pending++;}
|
||||
worker.execute(() -> { try { future.complete(operation.call()); } catch (Throwable failure) { future.completeExceptionally(failure); } finally{finished();} });
|
||||
return future;
|
||||
}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
|
||||
public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown();if(pending==0)drained.complete(null); }
|
||||
}
|
||||
@@ -39,7 +39,8 @@ import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
/** Accepted native contribution capture and durable character-bound XP delivery. */
|
||||
public final class CombatExperience implements Listener, AutoCloseable {
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level) { }
|
||||
private record Defeat(EquipmentSessions.Active active, int level, BigInteger coins) { }
|
||||
private record Eligible(UUID character, UUID player) { }
|
||||
private static final Set<EntityType> PASSIVE = Set.of(
|
||||
EntityType.ALLAY, EntityType.ARMADILLO, EntityType.AXOLOTL, EntityType.BAT, EntityType.CAMEL,
|
||||
EntityType.CAT, EntityType.CHICKEN, EntityType.COD, EntityType.COPPER_GOLEM, EntityType.COW,
|
||||
@@ -49,10 +50,14 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
EntityType.SKELETON_HORSE, EntityType.SNIFFER, EntityType.SNOW_GOLEM, EntityType.SQUID,
|
||||
EntityType.STRIDER, EntityType.TADPOLE, EntityType.TROPICAL_FISH, EntityType.TURTLE,
|
||||
EntityType.VILLAGER, EntityType.WANDERING_TRADER, EntityType.ZOMBIE_HORSE);
|
||||
private static final Set<EntityType> NEUTRAL = Set.of(
|
||||
EntityType.BEE, EntityType.CAVE_SPIDER, EntityType.DOLPHIN, EntityType.ENDERMAN, EntityType.GOAT,
|
||||
EntityType.IRON_GOLEM, EntityType.LLAMA, EntityType.PANDA, EntityType.PIGLIN, EntityType.POLAR_BEAR,
|
||||
EntityType.SPIDER, EntityType.TRADER_LLAMA, EntityType.WOLF, EntityType.ZOMBIFIED_PIGLIN);
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final ProgressionRules progression;
|
||||
private final CombatRewardRules rewards;
|
||||
private volatile CombatRewardRules rewards;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
@@ -60,34 +65,61 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatSources sources;
|
||||
private final CombatContributions contributions;
|
||||
private final PartyRoster parties;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator random;
|
||||
private final Map<PlayerDeathEvent, Defeat> defeats = new IdentityHashMap<>();
|
||||
private final Set<UUID> pvpDefeats = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
private final Map<UUID, PendingAward> pending = new ConcurrentHashMap<>();
|
||||
private final java.util.concurrent.ConcurrentLinkedQueue<UUID> retries = new java.util.concurrent.ConcurrentLinkedQueue<>();
|
||||
private org.bukkit.scheduler.BukkitTask retryTask;
|
||||
private java.util.function.Predicate<org.bukkit.entity.Entity> excluded = ignored -> false;
|
||||
private boolean closed;
|
||||
|
||||
private final class PendingAward {
|
||||
final UUID id = UUID.randomUUID();
|
||||
final UUID id;
|
||||
final UUID character;
|
||||
final BigInteger reward;
|
||||
final BigInteger experience;
|
||||
final BigInteger coins;
|
||||
final CombatRewardRules.KillPosition restricted;
|
||||
final CombatRewardRules rewardRules;
|
||||
BigInteger expectedRevision;
|
||||
boolean running;
|
||||
PendingAward(UUID character, BigInteger reward, CombatRewardRules.KillPosition restricted) {
|
||||
this.character = character; this.reward = reward; this.restricted = restricted;
|
||||
PendingAward(UUID id, UUID character, BigInteger experience, BigInteger coins,
|
||||
CombatRewardRules.KillPosition restricted, CombatRewardRules rewardRules) {
|
||||
this.id = id; this.character = character; this.experience = experience;
|
||||
this.coins = coins; this.restricted = restricted; this.rewardRules = rewardRules;
|
||||
}
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this(characters, progression, rewards, sessions, new PartyRoster(clock, id -> {
|
||||
var player = players.apply(id); return player != null && player.isOnline();
|
||||
}), games.dmg.fantasystick.progression.CoinRules::defaults, java.util.random.RandomGenerator.getDefault(),
|
||||
clock, nextTick, players, failures);
|
||||
}
|
||||
|
||||
public CombatExperience(CharacterStorage characters, ProgressionRules progression, CombatRewardRules rewards,
|
||||
EquipmentSessions sessions, PartyRoster parties,
|
||||
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
|
||||
java.util.random.RandomGenerator random, Clock clock, Consumer<Runnable> nextTick,
|
||||
Function<UUID, Player> players, Consumer<Failure> failures) {
|
||||
this.characters = Objects.requireNonNull(characters); this.progression = Objects.requireNonNull(progression);
|
||||
this.rewards = Objects.requireNonNull(rewards); this.sessions = Objects.requireNonNull(sessions);
|
||||
this.clock = Objects.requireNonNull(clock); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.players = Objects.requireNonNull(players); this.failures = Objects.requireNonNull(failures);
|
||||
this.parties = Objects.requireNonNull(parties); this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.random = Objects.requireNonNull(random); this.clock = Objects.requireNonNull(clock);
|
||||
this.nextTick = Objects.requireNonNull(nextTick); this.players = Objects.requireNonNull(players);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
sources = new CombatSources(sessions); contributions = new CombatContributions(rewards);
|
||||
}
|
||||
|
||||
public void attachExcluded(java.util.function.Predicate<org.bukkit.entity.Entity> predicate) {
|
||||
if (closed) throw new IllegalStateException("Combat experience is closed");
|
||||
excluded = Objects.requireNonNull(predicate);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
if (closed || retryTask != null) throw new IllegalStateException("Combat experience is already installed or closed");
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::retryPending, 20, 20);
|
||||
@@ -100,45 +132,55 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
contributions.support(target, aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
/** Records effective healing; any directly contributed target can qualify it within ten seconds. */
|
||||
public void support(UUID supportingCharacter, EquipmentSessions.Active aided, BigDecimal amount) {
|
||||
Objects.requireNonNull(supportingCharacter); Objects.requireNonNull(aided); Objects.requireNonNull(amount);
|
||||
if (closed || amount.signum() <= 0 || !sessions.current(aided) || supportingCharacter.equals(aided.character())) return;
|
||||
contributions.support(aided.character(), supportingCharacter, clock.instant());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || event.getFinalDamage() <= 0) return;
|
||||
var origin = source(event);
|
||||
if (closed || excluded.test(event.getEntity()) || games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()
|
||||
|| event.getFinalDamage() <= 0) return;
|
||||
var origin = sources.source(event);
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| event.getEntity().getUniqueId().equals(origin.player())) return;
|
||||
contributions.accept(event.getEntity().getUniqueId(), origin.character(), clock.instant());
|
||||
}
|
||||
|
||||
private CombatSources.Origin source(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = sources.read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent byEntity) origin = sources.read(byEntity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (closed) return;
|
||||
var active = sessions.active(event.getEntity().getUniqueId()).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
if (record != null) defeats.put(event, new Defeat(active, record.level()));
|
||||
if (record != null) {
|
||||
defeats.put(event, new Defeat(active, record.level(), record.coins()));
|
||||
if (contributions.hasDirect(event.getEntity().getUniqueId(), record.id(), clock.instant())) {
|
||||
pvpDefeats.add(event.getEntity().getUniqueId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
var defeat = defeats.remove(event);
|
||||
if (closed || defeat == null || !event.isCancelled() || sessions.current(defeat.active())) return;
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER, defeat.level(), defeat.active().character());
|
||||
if (closed || defeat == null) return;
|
||||
if (!event.isCancelled() || sessions.current(defeat.active())) {
|
||||
pvpDefeats.remove(event.getEntity().getUniqueId());
|
||||
return;
|
||||
}
|
||||
award(event.getEntity(), CombatRewardRules.Target.FANTASY_CHARACTER,
|
||||
defeat.level(), defeat.active().character(), defeat.coins());
|
||||
}
|
||||
|
||||
/** Consumed synchronously by the fantasy defeat transition before it persists the Tail state. */
|
||||
public boolean consumePvpDefeat(UUID player) { return pvpDefeats.remove(player); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (closed || event instanceof PlayerDeathEvent) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null);
|
||||
if (closed || event instanceof PlayerDeathEvent || excluded.test(event.getEntity())) return;
|
||||
award(event.getEntity(), classify(event.getEntity()), 0, null, BigInteger.ZERO);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -148,21 +190,62 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter) {
|
||||
private void award(LivingEntity target, CombatRewardRules.Target kind, int level, UUID defeatedCharacter,
|
||||
BigInteger defeatedCoins) {
|
||||
Instant now = clock.instant();
|
||||
var contributors = new ArrayList<>(contributions.consume(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) contributors.remove(defeatedCharacter);
|
||||
if (contributors.isEmpty()) return;
|
||||
BigInteger share = rewards.share(rewards.pool(kind, level), contributors.size());
|
||||
var direct = new ArrayList<>(contributions.consumeDirect(target.getUniqueId(), now));
|
||||
if (defeatedCharacter != null) direct.remove(defeatedCharacter);
|
||||
if (direct.isEmpty()) return;
|
||||
var units = rewardUnits(direct);
|
||||
BigInteger coinPool = defeatedCharacter == null
|
||||
? coinRules.get().mob(coinTarget(target), random) : defeatedCoins;
|
||||
if (units.isEmpty()) return; // The defeat transition has already persisted any PvP balance loss.
|
||||
var capturedRewards = rewards;
|
||||
var xp = games.dmg.fantasystick.progression.RewardAllocator.allocate(capturedRewards.pool(kind, level), units, random);
|
||||
var coins = games.dmg.fantasystick.progression.RewardAllocator.allocate(coinPool, units, random);
|
||||
CombatRewardRules.KillPosition restricted = kind == CombatRewardRules.Target.PASSIVE
|
||||
|| kind == CombatRewardRules.Target.FANTASY_CHARACTER ? null : position(target.getLocation());
|
||||
for (UUID character : contributors) {
|
||||
var operation = new PendingAward(character, share, restricted);
|
||||
UUID targetReward = defeatedCharacter == null ? target.getUniqueId() : defeatedCharacter;
|
||||
for (UUID character : xp.keySet()) {
|
||||
UUID receipt = UUID.nameUUIDFromBytes((targetReward + ":" + character + ":" + now).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var operation = new PendingAward(receipt, character, xp.get(character), coins.get(character), restricted,
|
||||
capturedRewards);
|
||||
pending.put(operation.id, operation);
|
||||
attempt(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>> rewardUnits(
|
||||
java.util.List<UUID> direct) {
|
||||
var units = new java.util.LinkedHashMap<Object, games.dmg.fantasystick.progression.RewardAllocator.Unit<UUID>>();
|
||||
direct.stream().sorted().forEach(character -> eligible(character).ifPresent(attacker -> {
|
||||
var membership = parties.rewardMembership(attacker.player());
|
||||
if (membership.isEmpty()) {
|
||||
units.putIfAbsent(character, new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(character,
|
||||
java.util.List.of(character)));
|
||||
return;
|
||||
}
|
||||
var group = membership.orElseThrow();
|
||||
var recipients = group.activePlayers().stream().map(player -> sessions.active(player).orElse(null))
|
||||
.filter(Objects::nonNull).filter(sessions::current)
|
||||
.filter(active -> living(active.player())).map(EquipmentSessions.Active::character).distinct().sorted().toList();
|
||||
if (!recipients.isEmpty()) units.putIfAbsent(group.key(),
|
||||
new games.dmg.fantasystick.progression.RewardAllocator.Unit<>(group.key(), recipients));
|
||||
}));
|
||||
return java.util.List.copyOf(units.values());
|
||||
}
|
||||
|
||||
private java.util.Optional<Eligible> eligible(UUID character) {
|
||||
var active = sessions.activeCharacter(character).orElse(null);
|
||||
if (active == null || !sessions.current(active) || !living(active.player())) return java.util.Optional.empty();
|
||||
return java.util.Optional.of(new Eligible(character, active.player()));
|
||||
}
|
||||
|
||||
private boolean living(UUID playerId) {
|
||||
var player = players.apply(playerId);
|
||||
return player != null; // The current active live-record session is the online/living authority.
|
||||
}
|
||||
|
||||
private static CombatRewardRules.KillPosition position(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld(), "Kill location has no world").getUID();
|
||||
return new CombatRewardRules.KillPosition(Objects.requireNonNull(world, "Kill world has no UUID"),
|
||||
@@ -184,8 +267,8 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
});
|
||||
return;
|
||||
}
|
||||
characters.awardCombatExperience(operation.character, operation.expectedRevision, operation.reward,
|
||||
progression, rewards, operation.restricted).whenComplete((award, failure) -> {
|
||||
characters.awardCombatReward(operation.character, operation.expectedRevision, operation.id,
|
||||
operation.experience, operation.coins, progression, operation.rewardRules, operation.restricted).whenComplete((award, failure) -> {
|
||||
synchronized (operation) { operation.running = false; }
|
||||
if (failure != null) { fail(operation, failure); return; }
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.STALE && award.record().isPresent()) {
|
||||
@@ -205,6 +288,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
|
||||
public void retryPending() {
|
||||
if (closed) return;
|
||||
contributions.sweep(clock.instant());
|
||||
for (int attempted = 0; attempted < 64; attempted++) {
|
||||
UUID id = retries.poll();
|
||||
if (id == null) break;
|
||||
@@ -225,7 +309,7 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
Player player = active == null ? null : players.apply(active.player());
|
||||
if (player == null || !player.isOnline()) return;
|
||||
if (award.status() == CharacterStorage.CombatAwardStatus.APPLIED) {
|
||||
player.sendMessage("Earned " + operation.reward + " fantasy XP.");
|
||||
if (operation.experience.signum() > 0) player.sendMessage("Earned " + operation.experience + " fantasy XP.");
|
||||
for (int gained = 1; gained <= award.levels(); gained++) {
|
||||
int level = saved.level() - award.levels() + gained;
|
||||
player.sendMessage("Level " + level + "! Gained 1 character point.");
|
||||
@@ -239,14 +323,35 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
} else if (award.status() == CharacterStorage.CombatAwardStatus.UNCHANGED) {
|
||||
player.sendMessage("No fantasy XP: this target's configured reward is zero.");
|
||||
}
|
||||
if (operation.coins.signum() > 0 && award.status() != CharacterStorage.CombatAwardStatus.DUPLICATE) {
|
||||
player.sendMessage("Earned " + operation.coins + " character coins.");
|
||||
}
|
||||
}
|
||||
|
||||
static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
public void configure(CombatRewardRules updated) {
|
||||
if (closed) throw new IllegalStateException("Combat rewards are closed");
|
||||
rewards = Objects.requireNonNull(updated);
|
||||
}
|
||||
|
||||
public CombatRewardRules rules() { return rewards; }
|
||||
|
||||
public static CombatRewardRules.Target classify(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON) return CombatRewardRules.Target.BOSS;
|
||||
if (type == EntityType.WITCH) return CombatRewardRules.Target.HARD;
|
||||
return PASSIVE.contains(type) ? CombatRewardRules.Target.PASSIVE : CombatRewardRules.Target.ORDINARY;
|
||||
}
|
||||
|
||||
public static games.dmg.fantasystick.progression.CoinRules.Target coinTarget(LivingEntity entity) {
|
||||
EntityType type = entity.getType();
|
||||
if (type == EntityType.WARDEN || type == EntityType.ENDER_DRAGON || type == EntityType.WITHER) {
|
||||
return games.dmg.fantasystick.progression.CoinRules.Target.BOSS;
|
||||
}
|
||||
if (PASSIVE.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.PASSIVE;
|
||||
if (NEUTRAL.contains(type)) return games.dmg.fantasystick.progression.CoinRules.Target.NEUTRAL;
|
||||
return games.dmg.fantasystick.progression.CoinRules.Target.HOSTILE;
|
||||
}
|
||||
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics never replay or acknowledge a reward. */ }
|
||||
@@ -255,6 +360,6 @@ public final class CombatExperience implements Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
if (retryTask != null) retryTask.cancel();
|
||||
defeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
defeats.clear(); pvpDefeats.clear(); pending.clear(); retries.clear(); contributions.close();
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Source identity travels with delayed native entities, never with a replacement sheet or shooter. */
|
||||
final class CombatSources {
|
||||
public final class CombatSources {
|
||||
static final NamespacedKey ORIGIN = new NamespacedKey("fantasystick", "combat_origin_v1");
|
||||
record Origin(UUID player, UUID session, UUID character) {
|
||||
public record Origin(UUID player, UUID session, UUID character) {
|
||||
String encode() { return player + "/" + (session == null ? "-/-" : session + "/" + character); }
|
||||
static Origin decode(String value) {
|
||||
if (value == null || value.length() > 110) return null;
|
||||
@@ -26,15 +26,27 @@ final class CombatSources {
|
||||
}
|
||||
}
|
||||
private final EquipmentSessions sessions;
|
||||
CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
public CombatSources(EquipmentSessions sessions) { this.sessions = Objects.requireNonNull(sessions); }
|
||||
Origin player(UUID player) {
|
||||
return sessions.active(player).map(active -> new Origin(player, active.session(), active.character()))
|
||||
.orElseGet(() -> new Origin(player, null, null));
|
||||
}
|
||||
boolean current(Origin origin) {
|
||||
public boolean current(Origin origin) {
|
||||
return origin.equals(player(origin.player())) && (origin.session() == null
|
||||
|| sessions.current(new EquipmentSessions.Active(origin.player(), origin.session(), origin.character())));
|
||||
}
|
||||
public Origin source(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null) return ongoing.source();
|
||||
}
|
||||
var origin = read(event.getDamageSource().getDirectEntity());
|
||||
if (origin == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity) {
|
||||
origin = read(byEntity.getDamager());
|
||||
}
|
||||
if (origin == null) origin = read(event.getDamageSource().getCausingEntity());
|
||||
return origin;
|
||||
}
|
||||
Origin read(Entity entity) { return read(entity, 0); }
|
||||
private Origin read(Entity entity, int depth) {
|
||||
if (entity == null || depth >= 8) return null;
|
||||
|
||||
@@ -66,6 +66,7 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.*;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Registrar-authorized mutations with durable operation plans spanning coin and party files. */
|
||||
public final class PartyManagement implements AutoCloseable {
|
||||
public enum Status{APPLIED,INVALID_SESSION,TOO_FAR,INVALID_NAME,ALREADY_MEMBER,NAME_TAKEN,INSUFFICIENT,FAILED}
|
||||
public record Outcome(Status status,Optional<CharacterParty> party){}
|
||||
private record Context(EquipmentSessions.Active active,CharacterRecord record,PartyRegistrar registrar){}
|
||||
private final CharacterPartyDirectory parties;private final PartyRegistrarDirectory registrars;private final CharacterStorage characters;private final EquipmentSessions sessions;private final PartyEconomySettings prices;private final PartyOperationStore operations;private final Consumer<Runnable> nextTick;private final Consumer<Throwable> failures;private volatile boolean closed;private volatile boolean recovered;private final java.util.concurrent.CompletableFuture<Void> recovery=new java.util.concurrent.CompletableFuture<>();
|
||||
public PartyManagement(CharacterPartyDirectory parties,PartyRegistrarDirectory registrars,CharacterStorage characters,EquipmentSessions sessions,PartyEconomySettings prices,PartyOperationStore operations,Consumer<Runnable> nextTick,Consumer<Throwable> failures){this.parties=Objects.requireNonNull(parties);this.registrars=Objects.requireNonNull(registrars);this.characters=Objects.requireNonNull(characters);this.sessions=Objects.requireNonNull(sessions);this.prices=Objects.requireNonNull(prices);this.operations=Objects.requireNonNull(operations);this.nextTick=Objects.requireNonNull(nextTick);this.failures=Objects.requireNonNull(failures);parties.readiness().thenCompose(ignored->operations.load()).thenCompose(plans->java.util.concurrent.CompletableFuture.allOf(plans.stream().map(plan->execute(plan,null,null).toCompletableFuture()).toArray(java.util.concurrent.CompletableFuture[]::new))).whenComplete((ignored,failure)->{if(failure==null){recovered=true;recovery.complete(null);}else{report(failure);recovery.completeExceptionally(failure);}});}
|
||||
public void register(Player player,UUID registrarId,String name,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var parsed=PartyNames.parse(name);if(parsed.isEmpty()){result.accept(new Outcome(Status.INVALID_NAME,Optional.empty()));return;}var captured=context.orElseThrow();if(parties.partyOf(captured.record().id()).isPresent()){result.accept(new Outcome(Status.ALREADY_MEMBER,Optional.empty()));return;}if(parties.partyByCanonicalName(parsed.orElseThrow().canonical()).isPresent()){result.accept(new Outcome(Status.NAME_TAKEN,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.CREATE,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.registrationPrice()),parsed.orElseThrow().display(),null,null,null);prepare(plan,captured,result);}
|
||||
public void transfer(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var captured=context.orElseThrow();var party=parties.partyOf(captured.record().id()).orElse(null);if(party==null||!party.leader().equals(captured.record().id())||!party.members().contains(target)||target.equals(captured.record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var plan=new PartyOperationStore.Plan(UUID.randomUUID(),PartyOperationStore.Kind.TRANSFER,captured.record().id(),captured.record().revision(),BigInteger.valueOf(prices.leaderChangePrice()),null,party.id(),target,party.revision());prepare(plan,captured,result);}
|
||||
private void prepare(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){operations.prepare(plan).whenComplete((saved,failure)->{if(failure!=null){fail(failure,result);return;}execute(saved,context,result);});}
|
||||
private java.util.concurrent.CompletionStage<Void> execute(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result){var done=new java.util.concurrent.CompletableFuture<Void>();parties.readiness().thenCompose(ignored->plan.kind()==PartyOperationStore.Kind.CREATE?parties.paidCreate(plan,characters):parties.paidTransfer(plan,characters)).whenComplete((paid,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}var spent=paid.spend();var changed=paid.result();if(spent!=null&&(spent.status()==CharacterStorage.CoinSpendStatus.INSUFFICIENT||spent.status()==CharacterStorage.CoinSpendStatus.UNAVAILABLE)){finish(plan,context,result,spent,new Outcome(Status.INSUFFICIENT,Optional.empty()),done);return;}if(spent==null){finish(plan,context,result,null,new Outcome(map(changed.status()),changed.party()),done);return;}if(spent.status()!=CharacterStorage.CoinSpendStatus.APPLIED&&spent.status()!=CharacterStorage.CoinSpendStatus.DUPLICATE){finish(plan,context,result,spent,new Outcome(Status.FAILED,Optional.empty()),done);return;}if(changed.status()!=CharacterPartyDirectory.Status.APPLIED&&changed.status()!=CharacterPartyDirectory.Status.DUPLICATE){finish(plan,context,result,spent,new Outcome(map(changed.status()),changed.party()),done);return;}finish(plan,context,result,spent,new Outcome(Status.APPLIED,changed.party()),done);});return done.minimalCompletionStage();}
|
||||
private void finish(PartyOperationStore.Plan plan,Context context,Consumer<Outcome> result,CharacterStorage.CoinSpend spent,Outcome outcome,java.util.concurrent.CompletableFuture<Void> done){operations.complete(plan.id()).whenComplete((ignored,failure)->{if(failure!=null){if(result!=null)fail(failure,result);else report(failure);done.completeExceptionally(failure);return;}if(result!=null)publish(()->{if(context!=null&&spent!=null&&spent.record().isPresent())sessions.acknowledgeProgress(context.active(),context.record().revision(),spent.record().orElseThrow());result.accept(outcome);});done.complete(null);});}
|
||||
private static Status map(CharacterPartyDirectory.Status status){return switch(status){case NAME_TAKEN->Status.NAME_TAKEN;case ALREADY_MEMBER->Status.ALREADY_MEMBER;default->Status.FAILED;};}
|
||||
public void invite(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){var context=context(player,registrarId);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var active=sessions.activeCharacter(target).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(record==null||parties.partyOf(target).isPresent()){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null||!party.leader().equals(context.orElseThrow().record().id())){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}parties.invite(context.orElseThrow().record().id(),party.id(),target).whenComplete((value,failure)->complete(value,failure,result));}
|
||||
public void accept(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.accept(c.record().id(),invitation),result);}public void decline(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutate(player,registrarId,c->parties.decline(c.record().id(),invitation),result);}public void cancel(Player player,UUID registrarId,UUID invitation,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.cancel(c.record().id(),p.id(),invitation),result);}public void leave(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.leave(c.record().id(),p.id()),result);}public void kick(Player player,UUID registrarId,UUID target,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.kick(c.record().id(),p.id(),target),result);}public void disband(Player player,UUID registrarId,Consumer<Outcome> result){mutateParty(player,registrarId,(c,p)->parties.disband(c.record().id(),p.id()),result);}
|
||||
public Optional<CharacterParty> party(Player player,UUID registrar){return context(player,registrar).flatMap(value->parties.partyOf(value.record().id()));}public List<CharacterPartyDirectory.Offer> invitations(Player player,UUID registrar){return context(player,registrar).map(value->parties.invitationsFor(value.record().id())).orElse(List.of());}public List<CharacterPartyDirectory.Offer> outgoingInvitations(Player player,UUID registrar){var context=context(player,registrar);if(context.isEmpty())return List.of();var party=parties.partyOf(context.orElseThrow().record().id()).filter(value->value.leader().equals(context.orElseThrow().record().id())).orElse(null);return party==null?List.of():party.invitations().stream().map(value->new CharacterPartyDirectory.Offer(value.id(),party.id(),value.character())).toList();}
|
||||
private void mutate(Player player,UUID id,java.util.function.Function<Context,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}try{action.apply(context.orElseThrow()).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void mutateParty(Player player,UUID id,java.util.function.BiFunction<Context,CharacterParty,java.util.concurrent.CompletableFuture<CharacterPartyDirectory.Result>> action,Consumer<Outcome> result){var context=context(player,id);if(context.isEmpty()){result.accept(new Outcome(Status.INVALID_SESSION,Optional.empty()));return;}var party=parties.partyOf(context.orElseThrow().record().id()).orElse(null);if(party==null){result.accept(new Outcome(Status.FAILED,Optional.empty()));return;}try{action.apply(context.orElseThrow(),party).whenComplete((value,failure)->complete(value,failure,result));}catch(RuntimeException failure){fail(failure,result);}}
|
||||
private void complete(CharacterPartyDirectory.Result value,Throwable failure,Consumer<Outcome> result){if(failure!=null){fail(failure,result);return;}publish(()->result.accept(new Outcome(value.status()==CharacterPartyDirectory.Status.APPLIED?Status.APPLIED:map(value.status()),value.party())));}
|
||||
public int registrationPrice(){return prices.registrationPrice();}public int leaderChangePrice(){return prices.leaderChangePrice();}
|
||||
public Optional<CharacterRecord> active(Player player,UUID registrar){return context(player,registrar).map(Context::record);}public Optional<UUID> activeSession(Player player,UUID registrar){return context(player,registrar).map(value->value.active().session());}public boolean eligibleInvitationTarget(UUID character){return parties.partyOf(character).isEmpty()&&sessions.activeCharacter(character).filter(sessions::current).flatMap(sessions::currentRecord).filter(value->!value.dead()).isPresent();}public Optional<CharacterRecord> activeCharacter(Player player){var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);return active==null?Optional.empty():sessions.currentRecord(active).filter(value->!value.dead());}
|
||||
private Optional<Context> context(Player player,UUID registrarId){if(!parties.ready()||!recovered)return Optional.empty();var registrar=registrars.find(registrarId).orElse(null);var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);var record=active==null?null:sessions.currentRecord(active).filter(value->!value.dead()).orElse(null);if(registrar==null||record==null)return Optional.empty();var location=player.getLocation();if(location.getWorld()==null||!location.getWorld().getUID().equals(registrar.world()))return Optional.empty();double dx=location.getX()-registrar.x(),dy=location.getY()-registrar.y(),dz=location.getZ()-registrar.z();if(dx*dx+dy*dy+dz*dz>36)return Optional.empty();return Optional.of(new Context(active,record,registrar));}
|
||||
private void fail(Throwable failure,Consumer<Outcome> result){report(failure);publish(()->result.accept(new Outcome(Status.FAILED,Optional.empty())));}private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}private void publish(Runnable action){if(closed)return;try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
public java.util.concurrent.CompletionStage<Void> ready(){return recovery.minimalCompletionStage();}
|
||||
public java.util.concurrent.CompletionStage<Void> drained(){return operations.drained();}
|
||||
@Override public void close(){closed=true;operations.close();}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Validated display and globally comparable identities for persistent parties. */
|
||||
public final class PartyNames {
|
||||
public record Name(String display, String canonical) { }
|
||||
|
||||
private PartyNames() { }
|
||||
|
||||
public static Optional<Name> parse(String input) {
|
||||
if (input == null) return Optional.empty();
|
||||
String display = Normalizer.normalize(input.strip(), Normalizer.Form.NFC);
|
||||
int length = display.codePointCount(0, display.length());
|
||||
if (length < 1 || length > 32 || display.codePoints().anyMatch(PartyNames::forbidden)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String canonical = Normalizer.normalize(display.toLowerCase(Locale.ROOT), Normalizer.Form.NFC);
|
||||
return Optional.of(new Name(display, canonical));
|
||||
}
|
||||
|
||||
private static boolean forbidden(int codePoint) {
|
||||
int type = Character.getType(codePoint);
|
||||
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Durable desired location for one administrator-managed Party Registrar. */
|
||||
public record PartyRegistrar(UUID id, String name, UUID world, double x, double y, double z, float yaw, float pitch) {
|
||||
public PartyRegistrar {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(world);
|
||||
name = name.strip();
|
||||
if (name.codePointCount(0, name.length()) > 64 || name.codePoints().anyMatch(codePoint -> {
|
||||
int type = Character.getType(codePoint);
|
||||
return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE;
|
||||
}) || !Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("Invalid Party Registrar");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Loaded-chunk reconciliation and native protection for distinct Party Registrar villagers. */
|
||||
public final class PartyRegistrarController implements Listener, AutoCloseable {
|
||||
private final Plugin plugin; private final PartyRegistrarDirectory directory;
|
||||
private final BiConsumer<Player,UUID> interactions; private final NamespacedKey idKey,typeKey;
|
||||
private final java.util.Set<Entity> relocating=java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
|
||||
private boolean closed;
|
||||
public PartyRegistrarController(Plugin plugin,PartyRegistrarDirectory directory,BiConsumer<Player,UUID> interactions){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.interactions=Objects.requireNonNull(interactions);idKey=new NamespacedKey(plugin,"party_registrar_id");typeKey=new NamespacedKey(plugin,"managed_npc_type");}
|
||||
public void install(){for(var world:plugin.getServer().getWorlds()){var chunks=world.getLoadedChunks();if(chunks!=null)for(var chunk:chunks)reconcile(chunk);}}
|
||||
public boolean tagged(Entity entity){return "party_registrar".equals(entity.getPersistentDataContainer().get(typeKey,PersistentDataType.STRING));}
|
||||
public void reconcile(Chunk chunk){if(closed)return;var desired=new HashMap<UUID,PartyRegistrar>();for(var value:directory.all())if(value.world().equals(chunk.getWorld().getUID())&&chunk(value.x())==chunk.getX()&&chunk(value.z())==chunk.getZ())desired.put(value.id(),value);var retained=new HashSet<UUID>();for(var entity:chunk.getEntities()){if(!tagged(entity))continue;UUID id=id(entity);var target=id==null?null:desired.get(id);if(!(entity instanceof Villager villager)||target==null||!retained.add(id)){entity.remove();continue;}configure(villager,target);var current=villager.getLocation();var expected=location(target);if(current.distanceSquared(expected)>0.000001||Math.abs(current.getYaw()-expected.getYaw())>0.001||Math.abs(current.getPitch()-expected.getPitch())>0.001){relocating.add(villager);boolean moved;try{moved=villager.teleport(expected);}finally{relocating.remove(villager);}if(!moved){villager.remove();spawn(target);}}}for(var value:desired.values())if(!retained.contains(value.id()))spawn(value);}
|
||||
public void removeLoaded(UUID id){for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity)&&id.equals(id(entity)))entity.remove();}}
|
||||
private void spawn(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());if(world==null)return;int x=chunk(value.x()),z=chunk(value.z());if(!world.isChunkLoaded(x,z))return;world.spawn(location(value),Villager.class,villager->configure(villager,value));}
|
||||
private void configure(Villager villager,PartyRegistrar value){villager.getPersistentDataContainer().set(typeKey,PersistentDataType.STRING,"party_registrar");villager.getPersistentDataContainer().set(idKey,PersistentDataType.STRING,value.id().toString());villager.setAI(false);villager.setGravity(false);villager.setSilent(true);villager.setInvulnerable(true);villager.setCollidable(false);villager.setCanPickupItems(false);villager.setLeashHolder(null);villager.setRemoveWhenFarAway(false);villager.setAdult();villager.setAgeLock(true);villager.customName(net.kyori.adventure.text.Component.text(value.name().isBlank()?"Party Registrar":value.name()));villager.setCustomNameVisible(true);}
|
||||
private Location location(PartyRegistrar value){var world=plugin.getServer().getWorld(value.world());return new Location(world,value.x(),value.y(),value.z(),value.yaw(),value.pitch());}
|
||||
private UUID id(Entity entity){String value=entity.getPersistentDataContainer().get(idKey,PersistentDataType.STRING);try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
private static int chunk(double coordinate){return ((int)Math.floor(coordinate))>>4;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void interact(org.bukkit.event.player.PlayerInteractEntityEvent event){if(!tagged(event.getRightClicked()))return;event.setCancelled(true);if(event.getHand()!=org.bukkit.inventory.EquipmentSlot.HAND)return;UUID id=id(event.getRightClicked());if(id!=null&&directory.find(id).isPresent())interactions.accept(event.getPlayer(),id);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void damage(org.bukkit.event.entity.EntityDamageEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void combust(org.bukkit.event.entity.EntityCombustEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void transform(org.bukkit.event.entity.EntityTransformEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void breed(org.bukkit.event.entity.EntityBreedEvent event){if(tagged(event.getMother())||tagged(event.getFather()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void vehicle(org.bukkit.event.vehicle.VehicleEnterEvent event){if(tagged(event.getEntered()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void leash(org.bukkit.event.entity.PlayerLeashEntityEvent event){if(tagged(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void teleport(org.bukkit.event.entity.EntityTeleportEvent event){if(tagged(event.getEntity())&&!relocating.contains(event.getEntity()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR)public void chunk(org.bukkit.event.world.ChunkLoadEvent event){reconcile(event.getChunk());}
|
||||
@Override public void close(){closed=true;for(var world:plugin.getServer().getWorlds()){var entities=world.getEntities();if(entities!=null)for(var entity:entities)if(tagged(entity))entity.remove();}}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.persistence.YamlPartyRegistrarRepository;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Serialized durable desired state for Party Registrar entities. */
|
||||
public final class PartyRegistrarDirectory implements AutoCloseable {
|
||||
private final YamlPartyRegistrarRepository repository;
|
||||
private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-party-registrar-persistence"));
|
||||
private volatile List<PartyRegistrar> snapshot; private volatile boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public PartyRegistrarDirectory(YamlPartyRegistrarRepository repository)throws IOException{this.repository=Objects.requireNonNull(repository);snapshot=repository.load();}
|
||||
public List<PartyRegistrar> all(){return snapshot;}
|
||||
public Optional<PartyRegistrar> find(UUID id){return snapshot.stream().filter(value->value.id().equals(id)).findFirst();}
|
||||
public CompletableFuture<PartyRegistrar> place(String name,UUID world,double x,double y,double z,float yaw,float pitch){return submit(()->{var value=new PartyRegistrar(UUID.randomUUID(),name,world,x,y,z,yaw,pitch);var next=new ArrayList<>(snapshot);next.add(value);repository.save(next);snapshot=List.copyOf(next);return value;});}
|
||||
public CompletableFuture<Optional<PartyRegistrar>> remove(UUID id){return submit(()->{var found=find(id);if(found.isEmpty())return Optional.empty();var next=snapshot.stream().filter(value->!value.id().equals(id)).toList();repository.save(next);snapshot=next;return found;});}
|
||||
public CompletableFuture<PartyRegistrar> restore(PartyRegistrar registrar){return submit(()->{var found=find(registrar.id());if(found.isPresent()){if(found.orElseThrow().equals(registrar))return registrar;throw new IOException("Registrar identity collision");}var next=new ArrayList<>(snapshot);next.add(registrar);next.sort(java.util.Comparator.comparing(value->value.id().toString()));repository.save(next);snapshot=List.copyOf(next);return registrar;});}
|
||||
private <T> CompletableFuture<T> submit(Callable<T> operation){var future=new CompletableFuture<T>();synchronized(this){if(closed){future.completeExceptionally(new IOException("Registrar directory closed"));return future;}pending++;}worker.execute(()->{try{future.complete(operation.call());}catch(Throwable failure){future.completeExceptionally(failure);}finally{finished();}});return future;}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public java.util.concurrent.CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import java.util.*;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Plugin-owned bounded paginated Registrar menus; no chat or remote book mutation path. */
|
||||
public final class PartyRegistrarMenus implements Listener,AutoCloseable {
|
||||
private enum Screen{ROOT,NAME,INVITATIONS,OUTGOING,INVITE,MEMBERS}
|
||||
private record Holder(UUID registrar,UUID character,UUID session,Screen screen,int page) implements InventoryHolder{public Inventory getInventory(){return null;}}
|
||||
private final Server server;private final PartyManagement management;private final NamespacedKey actionKey,targetKey;private boolean closed;
|
||||
public PartyRegistrarMenus(Plugin plugin,PartyManagement management){server=plugin.getServer();this.management=Objects.requireNonNull(management);actionKey=new NamespacedKey(plugin,"party_registrar_action");targetKey=new NamespacedKey(plugin,"party_registrar_target");}
|
||||
public void open(Player player,UUID registrar){open(player,registrar,Screen.ROOT,0);}
|
||||
private void open(Player player,UUID registrar,Screen screen,int page){if(closed)return;var record=management.active(player,registrar);if(record.isEmpty()){player.sendMessage("You must be actively playing a living fantasy character beside this Party Registrar.");return;}if(screen==Screen.NAME){openName(player,registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow());return;}var inventory=server.createInventory(new Holder(registrar,record.orElseThrow().id(),management.activeSession(player,registrar).orElseThrow(),screen,page),screen==Screen.ROOT?27:54,net.kyori.adventure.text.Component.text("Party Registrar"));var party=management.party(player,registrar);if(screen==Screen.ROOT){if(party.isEmpty()){inventory.setItem(11,item(Material.WRITABLE_BOOK,"Register a party — "+management.registrationPrice()+" coins","register",null));inventory.setItem(15,item(Material.MAP,"Invitations","invitations",null));}else{var group=party.orElseThrow();inventory.setItem(4,item(Material.PAPER,"Party: "+group.displayName(),"info",null));if(group.leader().equals(record.orElseThrow().id())){inventory.setItem(10,item(Material.EMERALD,"Invite active characters","invite",null));inventory.setItem(13,item(Material.PLAYER_HEAD,"Manage members","members",null));inventory.setItem(16,item(Material.BARRIER,"Disband party","disband",null));inventory.setItem(22,item(Material.MAP,"Pending invitations","outgoing",null));}else inventory.setItem(13,item(Material.BARRIER,"Leave party","leave",null));}}else if(screen==Screen.INVITATIONS){var values=management.invitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Invitation (left accept, right decline)","invitation",value.id()));}else if(screen==Screen.OUTGOING){var values=management.outgoingInvitations(player,registrar);fill(inventory,page,values,value->item(Material.MAP,"Cancel invitation for "+value.character(),"cancel-invitation",value.id()));}else if(screen==Screen.INVITE){var values=server.getOnlinePlayers().stream().filter(Player::isOnline).map(target->management.activeCharacter(target).orElse(null)).filter(Objects::nonNull).filter(target->management.eligibleInvitationTarget(target.id())).map(value->value.id()).distinct().sorted().toList();fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Invite "+value,"invite-target",value));}else{var values=party.map(value->value.members().stream().filter(member->!member.equals(value.leader())).sorted().toList()).orElse(List.of());fill(inventory,page,values,value->item(Material.PLAYER_HEAD,"Member "+value,"member",value,List.of(net.kyori.adventure.text.Component.text("Left click: Kick (free)"),net.kyori.adventure.text.Component.text("Right click: Transfer leadership — "+management.leaderChangePrice()+" coins"))));}player.openInventory(inventory);}
|
||||
private <T> void fill(Inventory inventory,int page,List<T> values,java.util.function.Function<T,ItemStack> item){int start=Math.max(0,page)*45;for(int index=start;index<Math.min(values.size(),start+45);index++)inventory.setItem(index-start,item.apply(values.get(index)));if(page>0)inventory.setItem(45,item(Material.ARROW,"Previous","previous",null));if(start+45<values.size())inventory.setItem(53,item(Material.ARROW,"Next","next",null));}
|
||||
private void openName(Player player,UUID registrar,UUID character,UUID session){var inventory=server.createInventory(new Holder(registrar,character,session,Screen.NAME,0),org.bukkit.event.inventory.InventoryType.ANVIL,net.kyori.adventure.text.Component.text("Choose party name"));inventory.setItem(0,item(Material.PAPER,"Party name","name",null));player.openInventory(inventory);}
|
||||
private ItemStack item(Material material,String name,String action,UUID target){return item(material,name,action,target,List.of());}
|
||||
private ItemStack item(Material material,String name,String action,UUID target,List<net.kyori.adventure.text.Component> lore){var item=new ItemStack(material);var meta=item.getItemMeta();meta.customName(net.kyori.adventure.text.Component.text(name));if(!lore.isEmpty())meta.lore(lore);meta.getPersistentDataContainer().set(actionKey,PersistentDataType.STRING,action);if(target!=null)meta.getPersistentDataContainer().set(targetKey,PersistentDataType.STRING,target.toString());item.setItemMeta(meta);return item;}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void click(InventoryClickEvent event){if(!(event.getInventory().getHolder() instanceof Holder holder)||!(event.getWhoClicked() instanceof Player player))return;event.setCancelled(true);if(closed)return;if(!management.active(player,holder.registrar()).map(value->value.id().equals(holder.character())).orElse(false)||!management.activeSession(player,holder.registrar()).map(holder.session()::equals).orElse(false)){player.sendMessage("Party action refused: the character or session changed.");return;}if(holder.screen()==Screen.NAME&&event.getRawSlot()==2){String name=event.getView() instanceof org.bukkit.inventory.view.AnvilView anvil?anvil.getRenameText():null;if(name!=null)management.register(player,holder.registrar(),name,outcome->respond(player,holder.registrar(),outcome,"Registered party."));return;}var clicked=event.getCurrentItem();if(clicked==null)return;var data=clicked.getItemMeta().getPersistentDataContainer();String action=data.get(actionKey,PersistentDataType.STRING);UUID target=uuid(data.get(targetKey,PersistentDataType.STRING));if("register".equals(action)){open(player,holder.registrar(),Screen.NAME,0);return;}if("invitations".equals(action)){open(player,holder.registrar(),Screen.INVITATIONS,0);return;}if("invite".equals(action)){open(player,holder.registrar(),Screen.INVITE,0);return;}if("outgoing".equals(action)){open(player,holder.registrar(),Screen.OUTGOING,0);return;}if("members".equals(action)){open(player,holder.registrar(),Screen.MEMBERS,0);return;}if("previous".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()-1);return;}if("next".equals(action)){open(player,holder.registrar(),holder.screen(),holder.page()+1);return;}if("leave".equals(action)){management.leave(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Left party."));return;}if("disband".equals(action)){management.disband(player,holder.registrar(),outcome->respond(player,holder.registrar(),outcome,"Party ended."));return;}if(target==null)return;if("invite-target".equals(action)){management.invite(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation saved."));return;}if("cancel-invitation".equals(action)){management.cancel(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation cancelled."));return;}if("invitation".equals(action)){if(event.isRightClick())management.decline(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Invitation declined."));else management.accept(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Joined party."));return;}if("member".equals(action)){if(event.isRightClick())management.transfer(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Leadership transferred."));else management.kick(player,holder.registrar(),target,outcome->respond(player,holder.registrar(),outcome,"Member removed."));}}
|
||||
@EventHandler(priority=EventPriority.HIGHEST,ignoreCancelled=true)public void drag(org.bukkit.event.inventory.InventoryDragEvent event){if(event.getInventory().getHolder() instanceof Holder)event.setCancelled(true);}
|
||||
private void respond(Player player,UUID registrar,PartyManagement.Outcome outcome,String success){if(!player.isOnline())return;if(outcome.status()==PartyManagement.Status.APPLIED){player.sendMessage(success);open(player,registrar);}else player.sendMessage("Party action refused: "+outcome.status().name().toLowerCase(Locale.ROOT).replace('_',' ')+".");}
|
||||
private static UUID uuid(String value){try{return value==null?null:UUID.fromString(value);}catch(IllegalArgumentException invalid){return null;}}
|
||||
@Override public void close(){closed=true;}
|
||||
}
|
||||
@@ -1,122 +1,40 @@
|
||||
package games.dmg.fantasystick.party;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Server-thread-confined, player-owned parties. No state survives a plugin/server restart. */
|
||||
/** Party facade: durable character identity in production, retained transient constructor for isolated legacy tests. */
|
||||
public final class PartyRoster implements AutoCloseable {
|
||||
public record Party(UUID id, UUID leader, List<UUID> members) {
|
||||
public Party { members = List.copyOf(members); }
|
||||
}
|
||||
public record Invitation(UUID id, UUID party, UUID leader, UUID invitee, Instant expires) { }
|
||||
private final Map<UUID, Party> membership = new HashMap<>();
|
||||
private final Map<UUID, Invitation> invitations = new HashMap<>();
|
||||
private final Set<UUID> departed = new HashSet<>();
|
||||
private boolean closed;
|
||||
private final Clock clock;
|
||||
private final Predicate<UUID> online;
|
||||
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.online = Objects.requireNonNull(online);
|
||||
}
|
||||
public Optional<Party> create(UUID player) {
|
||||
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
|
||||
var party = new Party(UUID.randomUUID(), player, List.of(player));
|
||||
membership.put(player, party);
|
||||
return Optional.of(party);
|
||||
}
|
||||
public Optional<Party> partyOf(UUID player) { return Optional.ofNullable(membership.get(player)); }
|
||||
public List<Invitation> invitationsFor(UUID player) {
|
||||
expire();
|
||||
return invitations.values().stream().filter(i -> i.invitee().equals(player))
|
||||
.sorted(java.util.Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();
|
||||
}
|
||||
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
|
||||
expire();
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
|
||||
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
|
||||
if (existing.isPresent()) return existing;
|
||||
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
|
||||
invitations.put(invitation.id(), invitation);
|
||||
return Optional.of(invitation);
|
||||
}
|
||||
public boolean accept(UUID player, UUID invitation) {
|
||||
expire();
|
||||
var offer = invitations.get(invitation);
|
||||
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
|
||||
var group = membership.get(offer.leader());
|
||||
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
|
||||
var members = new ArrayList<>(group.members());
|
||||
members.add(player);
|
||||
var updated = new Party(group.id(), group.leader(), members);
|
||||
for (var member : members) membership.put(member, updated);
|
||||
invitations.values().removeIf(i -> i.invitee().equals(player));
|
||||
return true;
|
||||
}
|
||||
public boolean kick(UUID leader, UUID party, UUID member) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || !group.members().contains(member) || member.equals(leader)) return false;
|
||||
removeMember(group, member);
|
||||
return true;
|
||||
}
|
||||
public boolean disband(UUID leader, UUID party) {
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null) return false;
|
||||
remove(group);
|
||||
return true;
|
||||
}
|
||||
private Party managedBy(UUID leader, UUID party) {
|
||||
var group = membership.get(leader);
|
||||
return group != null && group.id().equals(party) && group.leader().equals(leader) && connected(leader) ? group : null;
|
||||
}
|
||||
public boolean leave(UUID player, UUID party) {
|
||||
var group = membership.get(player);
|
||||
if (group == null || !group.id().equals(party) || !connected(player)) return false;
|
||||
if (group.leader().equals(player)) remove(group);
|
||||
else removeMember(group, player);
|
||||
return true;
|
||||
}
|
||||
private void removeMember(Party group, UUID player) {
|
||||
var remaining = group.members().stream().filter(member -> !member.equals(player)).toList();
|
||||
if (remaining.stream().noneMatch(this::connected)) { remove(group); return; }
|
||||
membership.remove(player);
|
||||
departed.remove(player);
|
||||
var updated = new Party(group.id(), group.leader(), remaining);
|
||||
for (var member : remaining) membership.put(member, updated);
|
||||
}
|
||||
public void arrive(UUID player) { departed.remove(player); }
|
||||
public void disconnect(UUID player) {
|
||||
var group = membership.get(player);
|
||||
if (group == null) return;
|
||||
departed.add(player);
|
||||
if (group.members().stream().noneMatch(this::connected)) remove(group);
|
||||
}
|
||||
private boolean connected(UUID player) { return !departed.contains(player) && online.test(player); }
|
||||
private void expire() {
|
||||
var now = clock.instant();
|
||||
invitations.values().removeIf(i -> !now.isBefore(i.expires()));
|
||||
}
|
||||
private void remove(Party group) {
|
||||
for (var member : group.members()) membership.remove(member);
|
||||
departed.removeAll(group.members());
|
||||
invitations.values().removeIf(i -> i.party().equals(group.id()));
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
membership.clear();
|
||||
invitations.clear();
|
||||
departed.clear();
|
||||
}
|
||||
public record Party(UUID id,UUID leader,List<UUID> members,UUID leaderCharacter,List<UUID> characters){public Party{members=List.copyOf(members);characters=List.copyOf(characters);}public Party(UUID id,UUID leader,List<UUID> members){this(id,leader,members,leader,members);}}
|
||||
public record Invitation(UUID id,UUID party,UUID leader,UUID invitee,Instant expires){}
|
||||
public record RewardMembership(Object key,List<UUID> activePlayers){public RewardMembership{Objects.requireNonNull(key);activePlayers=List.copyOf(activePlayers);}}
|
||||
private final Map<UUID,Party> membership=new HashMap<>();private final Map<UUID,Invitation> invitations=new HashMap<>();private final Set<UUID> departed=new HashSet<>();
|
||||
private final Clock clock;private final Predicate<UUID> online;private final java.util.function.IntSupplier maximumSize;
|
||||
private final CharacterPartyDirectory durable;private final EquipmentSessions sessions;private boolean closed;
|
||||
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);durable=null;sessions=null;maximumSize();}
|
||||
public PartyRoster(CharacterPartyDirectory durable,EquipmentSessions sessions,java.util.function.IntSupplier maximumSize){this.durable=Objects.requireNonNull(durable);this.sessions=Objects.requireNonNull(sessions);this.maximumSize=Objects.requireNonNull(maximumSize);clock=Clock.systemUTC();online=id->true;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)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();var result=durable.create(character,"Party "+character.toString().substring(0,8)).join();return result.party().flatMap(this::project);}if(closed||!connected(player)||membership.containsKey(player))return Optional.empty();var party=new Party(UUID.randomUUID(),player,List.of(player));membership.put(player,party);return Optional.of(party);}
|
||||
public Optional<Party> partyOf(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);return character==null?Optional.empty():durable.partyOf(character).flatMap(this::project);}return Optional.ofNullable(membership.get(player));}
|
||||
public Optional<RewardMembership> rewardMembership(UUID player){if(durable!=null){if(!durable.ready())return Optional.empty();var character=character(player);if(character==null)return Optional.empty();return durable.partyOf(character).map(party->new RewardMembership(party.id(),activePlayers(party)));}var party=membership.get(player);return party==null?Optional.empty():Optional.of(new RewardMembership(party.id(),party.members().stream().filter(this::connected).toList()));}
|
||||
public List<Invitation> invitationsFor(UUID player){if(durable!=null){if(!durable.ready())return List.of();var character=character(player);if(character==null)return List.of();return durable.invitationsFor(character).stream().map(value->new Invitation(value.id(),value.party(),new UUID(0,0),player,Instant.MAX)).toList();}expire();return invitations.values().stream().filter(i->i.invitee().equals(player)).sorted(Comparator.comparing(Invitation::expires).thenComparing(Invitation::id)).toList();}
|
||||
public Optional<Invitation> invite(UUID leader,UUID party,UUID invitee){if(closed)return Optional.empty();if(durable!=null){if(!durable.ready())return Optional.empty();var source=character(leader);var target=character(invitee);if(source==null||target==null)return Optional.empty();var result=durable.invite(source,party,target).join();return result.invitation().map(value->new Invitation(value.id(),value.party(),leader,invitee,Instant.MAX));}expire();var group=managedBy(leader,party);if(group==null||group.members().size()>=maximumSize()||!connected(invitee)||membership.containsKey(invitee))return Optional.empty();var existing=invitations.values().stream().filter(i->i.party().equals(party)&&i.invitee().equals(invitee)).findFirst();if(existing.isPresent())return existing;var invitation=new Invitation(UUID.randomUUID(),party,leader,invitee,clock.instant().plusSeconds(60));invitations.put(invitation.id(),invitation);return Optional.of(invitation);}
|
||||
public boolean accept(UUID player,UUID invitation){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.accept(character,invitation).join().status()==CharacterPartyDirectory.Status.APPLIED;}expire();var offer=invitations.get(invitation);if(offer==null||!offer.invitee().equals(player)||!connected(player)||membership.containsKey(player))return false;var group=membership.get(offer.leader());if(group==null||group.members().size()>=maximumSize()||!group.id().equals(offer.party()))return false;var members=new ArrayList<>(group.members());members.add(player);var updated=new Party(group.id(),group.leader(),members);for(var member:members)membership.put(member,updated);invitations.values().removeIf(i->i.invitee().equals(player));return true;}
|
||||
public boolean kick(UUID leader,UUID party,UUID member){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var a=character(leader);var b=character(member);return a!=null&&b!=null&&durable.kick(a,party,b).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null||!group.members().contains(member)||member.equals(leader))return false;removeMember(group,member);return true;}
|
||||
public boolean disband(UUID leader,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(leader);return character!=null&&durable.disband(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=managedBy(leader,party);if(group==null)return false;remove(group);return true;}
|
||||
public boolean leave(UUID player,UUID party){if(closed)return false;if(durable!=null){if(!durable.ready())return false;var character=character(player);return character!=null&&durable.leave(character,party).join().status()==CharacterPartyDirectory.Status.APPLIED;}var group=membership.get(player);if(group==null||!group.id().equals(party)||!connected(player))return false;if(group.leader().equals(player))remove(group);else removeMember(group,player);return true;}
|
||||
private UUID character(UUID player){return sessions.active(player).filter(sessions::current).map(EquipmentSessions.Active::character).orElse(null);}
|
||||
private List<UUID> activePlayers(CharacterParty party){return party.members().stream().map(sessions::activeCharacter).flatMap(Optional::stream).filter(sessions::current).map(EquipmentSessions.Active::player).sorted().toList();}
|
||||
public Optional<UUID> activePlayer(UUID character){if(durable==null)return Optional.of(character);return sessions.activeCharacter(character).filter(sessions::current).map(EquipmentSessions.Active::player);}
|
||||
public boolean exists(UUID party){if(durable!=null)return durable.ready()&&durable.all().stream().anyMatch(value->value.id().equals(party));return membership.values().stream().anyMatch(value->value.id().equals(party));}
|
||||
private Optional<Party> project(CharacterParty party){var members=activePlayers(party);UUID leader=sessions.activeCharacter(party.leader()).filter(sessions::current).map(EquipmentSessions.Active::player).orElse(party.leader());return Optional.of(new Party(party.id(),leader,members,party.leader(),party.members().stream().sorted().toList()));}
|
||||
private Party managedBy(UUID leader,UUID party){var group=membership.get(leader);return group!=null&&group.id().equals(party)&&group.leader().equals(leader)&&connected(leader)?group:null;}
|
||||
private void removeMember(Party group,UUID player){var remaining=group.members().stream().filter(member->!member.equals(player)).toList();if(remaining.stream().noneMatch(this::connected)){remove(group);return;}membership.remove(player);departed.remove(player);var updated=new Party(group.id(),group.leader(),remaining);for(var member:remaining)membership.put(member,updated);}
|
||||
public void arrive(UUID player){if(durable==null)departed.remove(player);}public void disconnect(UUID player){if(durable!=null)return;var group=membership.get(player);if(group==null)return;departed.add(player);if(group.members().stream().noneMatch(this::connected))remove(group);}
|
||||
private boolean connected(UUID player){return!departed.contains(player)&&online.test(player);}private void expire(){var now=clock.instant();invitations.values().removeIf(i->!now.isBefore(i.expires()));}private void remove(Party group){for(var member:group.members())membership.remove(member);departed.removeAll(group.members());invitations.values().removeIf(i->i.party().equals(group.id()));}
|
||||
@Override public void close(){closed=true;membership.clear();invitations.clear();departed.clear();}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.ability.AbilityReport;
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.presentation.CombatFeedback;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.entity.EntityRegainHealthEvent;
|
||||
import org.bukkit.event.entity.EntityRemoveEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
/** Consume-once final-blow attribution and persisted native healing for Health Steal. */
|
||||
public final class HealthSteal implements Listener, AutoCloseable {
|
||||
public static final String ID = "health_steal";
|
||||
private static final int MAX_PENDING_PER_CHARACTER = 64;
|
||||
private static final int MAX_CONSUMED_EVENTS = 1_024;
|
||||
public record Settings(BigInteger healing) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(healing);
|
||||
if (healing.signum() < 0 || healing.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Health Steal healing");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
public record Failure(UUID character, Throwable cause) { }
|
||||
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
|
||||
private record Heal(EquipmentSessions.Active active, double amount) { }
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<EntityRegainHealthEvent> healingEvents;
|
||||
private final Supplier<BigDecimal> nativeLimit;
|
||||
private final Consumer<Failure> failures;
|
||||
private final CombatFeedback feedback;
|
||||
private final CombatSources sources;
|
||||
private final Map<UUID, Candidate> lethal = new java.util.HashMap<>();
|
||||
private final Set<PlayerDeathEvent> fantasyDefeats = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final Set<EntityDeathEvent> consumed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
private final ArrayDeque<EntityDeathEvent> consumedOrder = new ArrayDeque<>();
|
||||
private final Map<UUID, ArrayDeque<Heal>> pending = new java.util.HashMap<>();
|
||||
private volatile Settings settings;
|
||||
private boolean closed;
|
||||
|
||||
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures) {
|
||||
this(settings, characters, sessions, nextTick, healingEvents, nativeLimit, failures, null);
|
||||
}
|
||||
|
||||
public HealthSteal(Settings settings, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<EntityRegainHealthEvent> healingEvents,
|
||||
Supplier<BigDecimal> nativeLimit, Consumer<Failure> failures, CombatFeedback feedback) {
|
||||
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);
|
||||
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger healing() { return settings.healing(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Health Steal",
|
||||
Map.of(Stat.STR, BigInteger.valueOf(14), Stat.CON, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Immediately restore " + healing() + " health point" + (healing().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " when this active character's accepted damage lands a qualifying final blow.",
|
||||
"Passive mobs qualify; assists, ordinary players and repeated defeat callbacks do not."));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (closed || !(event.getEntity() instanceof LivingEntity target)) return;
|
||||
UUID targetId = target.getUniqueId();
|
||||
if (event.isCancelled() || event.getFinalDamage() <= 0) { lethal.remove(targetId); return; }
|
||||
var origin = sources.source(event);
|
||||
double remaining = target.getHealth() + target.getAbsorptionAmount();
|
||||
if (origin == null || origin.session() == null || !sources.current(origin)
|
||||
|| targetId.equals(origin.player()) || !Double.isFinite(remaining)
|
||||
|| event.getFinalDamage() < remaining) {
|
||||
lethal.remove(targetId); return;
|
||||
}
|
||||
lethal.put(targetId, new Candidate(target, origin));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||
public void beforePlayerDeath(PlayerDeathEvent event) {
|
||||
if (!closed && sessions.active(event.getEntity().getUniqueId()).isPresent()) fantasyDefeats.add(event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void playerDeath(PlayerDeathEvent event) {
|
||||
boolean fantasy = fantasyDefeats.remove(event);
|
||||
if (!closed && fantasy && event.isCancelled()) defeat(event, event.getEntity());
|
||||
else lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void mobDeath(EntityDeathEvent event) {
|
||||
if (!closed && !(event instanceof PlayerDeathEvent)) defeat(event, event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void removed(EntityRemoveEvent event) {
|
||||
if (event.getCause() != EntityRemoveEvent.Cause.DEATH) lethal.remove(event.getEntity().getUniqueId());
|
||||
}
|
||||
|
||||
private void defeat(EntityDeathEvent event, LivingEntity target) {
|
||||
if (!consumed.add(event)) return;
|
||||
consumedOrder.addLast(event);
|
||||
while (consumedOrder.size() > MAX_CONSUMED_EVENTS) consumed.remove(consumedOrder.removeFirst());
|
||||
var candidate = lethal.remove(target.getUniqueId());
|
||||
if (candidate == null || candidate.target() != target || !sources.current(candidate.origin())) return;
|
||||
var origin = candidate.origin();
|
||||
var active = sessions.active(origin.player()).orElse(null);
|
||||
if (active == null || !active.session().equals(origin.session()) || !active.character().equals(origin.character())
|
||||
|| !sessions.current(active)) return;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) return;
|
||||
BigInteger configured = settings.healing();
|
||||
if (configured.signum() == 0) return;
|
||||
var maximumAttribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (maximumAttribute == null) return;
|
||||
double current = player.getHealth();
|
||||
double maximum = Math.min(maximumAttribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double reserved = pending.getOrDefault(active.character(), new ArrayDeque<>()).stream()
|
||||
.mapToDouble(Heal::amount).sum();
|
||||
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
|
||||
double proposed = Math.min(maximum - current - reserved, amount);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|
||||
|| !Double.isFinite(proposed) || proposed <= 0) return;
|
||||
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(healing);
|
||||
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
|
||||
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
|
||||
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
|
||||
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
|
||||
queue.addLast(new Heal(active, accepted));
|
||||
if (queue.size() == 1) attempt(active.character());
|
||||
}
|
||||
|
||||
private void attempt(UUID character) {
|
||||
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
|
||||
if (closed || operation == null) return;
|
||||
if (!sessions.current(operation.active())) { finish(character); return; }
|
||||
var record = sessions.currentRecord(operation.active()).orElse(null);
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
|
||||
double nativeBefore = player.getHealth();
|
||||
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
|
||||
BigDecimal limit;
|
||||
try { limit = nativeLimit.get(); }
|
||||
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
|
||||
BigInteger expected = record.revision();
|
||||
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
|
||||
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
|
||||
catch (RuntimeException rejected) { report(character, rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
|
||||
java.util.Optional<CharacterRecord> saved, Throwable failure) {
|
||||
try {
|
||||
if (closed || failure != null || saved == null || saved.isEmpty()) {
|
||||
if (failure != null) report(character, failure); return;
|
||||
}
|
||||
var record = saved.orElseThrow();
|
||||
if (!sessions.current(operation.active())
|
||||
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|
||||
var player = sessions.currentPlayer(operation.active()).orElse(null);
|
||||
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) return;
|
||||
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
|
||||
double publish = Math.min(maximum, record.health().doubleValue());
|
||||
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
|
||||
player.setHealth(publish);
|
||||
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
|
||||
}
|
||||
} finally { finish(character); }
|
||||
}
|
||||
|
||||
private void finish(UUID character) {
|
||||
var queue = pending.get(character);
|
||||
if (queue == null) return;
|
||||
queue.pollFirst();
|
||||
if (queue.isEmpty()) pending.remove(character); else attempt(character);
|
||||
}
|
||||
private void report(UUID character, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, failure)); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() {
|
||||
closed = true; lethal.clear(); fantasyDefeats.clear(); consumed.clear(); consumedOrder.clear(); pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Night Vision I layer for the learned character passive. */
|
||||
public final class LowLightVision implements AutoCloseable {
|
||||
public static final String ID = "low_light_vision";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public LowLightVision(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.NIGHT_VISION, 0, false, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Low-Light Vision", Map.of(Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides native Night Vision I while this character is active.",
|
||||
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable internal metadata for one character-owned automatic passive. */
|
||||
public record PassiveDefinition(String id, String name, Map<Stat, BigInteger> requirements,
|
||||
Set<String> prerequisites, BigInteger pointCost, List<String> description) {
|
||||
public PassiveDefinition {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(requirements);
|
||||
Objects.requireNonNull(prerequisites); Objects.requireNonNull(pointCost); Objects.requireNonNull(description);
|
||||
requirements = Map.copyOf(requirements); prerequisites = Set.copyOf(prerequisites); description = List.copyOf(description);
|
||||
if (!id.matches("[a-z][a-z0-9_]{0,63}") || name.isBlank() || !pointCost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(value -> value.isBlank() || value.equals(id))
|
||||
|| description.isEmpty() || description.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive definition");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.session.ControlInput;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Save-before-publication passive learning/refund coordinator for native book actions. */
|
||||
public final class PassiveEngine implements AutoCloseable {
|
||||
public record Failure(UUID character, String passive, Throwable cause) { }
|
||||
public record Mutation(CharacterStorage.PassiveMutationStatus status, CharacterRecord record) { }
|
||||
private final PassiveRegistry registry;
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private boolean closed;
|
||||
|
||||
public PassiveEngine(PassiveRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
this.registry = Objects.requireNonNull(registry); this.characters = Objects.requireNonNull(characters);
|
||||
this.sessions = Objects.requireNonNull(sessions); this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> purchase(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
var definition = registry.find(passive).orElse(null);
|
||||
if (closed || definition == null || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlockPassive(input.session().character(),
|
||||
expectedRevision, passive, definition.requirements(), definition.prerequisites(), definition.pointCost()));
|
||||
}
|
||||
|
||||
public CompletableFuture<Mutation> unlearn(ControlInput input, String passive, BigInteger expectedRevision) {
|
||||
if (closed || registry.find(passive).isEmpty() || input.kind() != ControlInput.Kind.BOOK || !input.stillCurrent()) {
|
||||
return CompletableFuture.completedFuture(new Mutation(CharacterStorage.PassiveMutationStatus.UNAVAILABLE, null));
|
||||
}
|
||||
return mutate(input, passive, expectedRevision, () -> characters.unlearnPassive(input.session().character(),
|
||||
expectedRevision, passive, registry.dependents(passive)));
|
||||
}
|
||||
|
||||
private CompletableFuture<Mutation> mutate(ControlInput input, String passive, BigInteger expectedRevision,
|
||||
java.util.function.Supplier<CompletableFuture<CharacterStorage.PassiveMutation>> operation) {
|
||||
var result = new CompletableFuture<Mutation>();
|
||||
CompletableFuture<CharacterStorage.PassiveMutation> work;
|
||||
try { work = operation.get(); }
|
||||
catch (RuntimeException failure) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return result; }
|
||||
work.whenComplete((mutation, failure) -> {
|
||||
try { nextTick.accept(() -> {
|
||||
if (failure != null) { report(input.session().character(), passive, failure); result.completeExceptionally(failure); return; }
|
||||
var record = mutation.record().orElse(null);
|
||||
if (mutation.status() == CharacterStorage.PassiveMutationStatus.APPLIED
|
||||
&& (record == null || !input.stillCurrent()
|
||||
|| !sessions.acknowledgeProgress(input.session(), expectedRevision, record))) {
|
||||
result.complete(new Mutation(CharacterStorage.PassiveMutationStatus.STALE, record)); return;
|
||||
}
|
||||
result.complete(new Mutation(mutation.status(), record));
|
||||
}); } catch (RuntimeException rejected) {
|
||||
report(input.session().character(), passive, rejected); result.completeExceptionally(rejected);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private void report(UUID character, String passive, Throwable failure) {
|
||||
try { failures.accept(new Failure(character, passive, failure)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Internal stable-ID passive catalogue consumed by persistence, books and focused runtime services. */
|
||||
public final class PassiveRegistry {
|
||||
private final Map<String, PassiveDefinition> definitions = new LinkedHashMap<>();
|
||||
|
||||
public synchronized void register(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (definitions.containsKey(definition.id())) {
|
||||
throw new IllegalArgumentException("Duplicate passive ID: " + definition.id());
|
||||
}
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
public synchronized void replace(PassiveDefinition definition) {
|
||||
Objects.requireNonNull(definition);
|
||||
if (!definitions.containsKey(definition.id())) throw new IllegalArgumentException("Passive is not registered");
|
||||
validatePrerequisites(definition);
|
||||
definitions.put(definition.id(), definition);
|
||||
}
|
||||
|
||||
private void validatePrerequisites(PassiveDefinition definition) {
|
||||
if (!definitions.keySet().containsAll(definition.prerequisites())) {
|
||||
throw new IllegalArgumentException("Passive prerequisite is not registered");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized Optional<PassiveDefinition> find(String id) {
|
||||
return Optional.ofNullable(definitions.get(id));
|
||||
}
|
||||
public synchronized Collection<PassiveDefinition> definitions() { return java.util.List.copyOf(definitions.values()); }
|
||||
public synchronized java.util.Set<String> dependents(String id) {
|
||||
return definitions.values().stream().filter(definition -> definition.prerequisites().contains(id))
|
||||
.map(PassiveDefinition::id).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Bounded current-character ownership for one persistent native potion layer. */
|
||||
final class PersistentPotionEffect implements AutoCloseable {
|
||||
private static final int MAX_ACTIVE_CHECKS = 256;
|
||||
|
||||
private final class State {
|
||||
final EquipmentSessions.Active active;
|
||||
final Player player;
|
||||
PotionEffect owned;
|
||||
PotionEffect displaced;
|
||||
long displacedUntil;
|
||||
long refreshedAt;
|
||||
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
|
||||
|
||||
void refresh() {
|
||||
refreshedAt = sweepGeneration;
|
||||
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
|
||||
if (!eligible()) { remove(); return; }
|
||||
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
|
||||
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
|
||||
try {
|
||||
if (player.addPotionEffect(effect, true)) {
|
||||
owned = effect; displaced = current;
|
||||
displacedUntil = current == null ? 0 : current.isInfinite()
|
||||
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
|
||||
}
|
||||
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
|
||||
}
|
||||
|
||||
boolean eligible() {
|
||||
if (closed || !sessions.current(active)) return false;
|
||||
var record = sessions.currentRecord(active).orElse(null);
|
||||
return record != null && !record.dead() && record.passives().contains(passive);
|
||||
}
|
||||
|
||||
void remove() {
|
||||
if (owned != null) {
|
||||
PotionEffect current;
|
||||
try { current = player.getPotionEffect(type); }
|
||||
catch (RuntimeException unavailable) { return; }
|
||||
if (same(current, owned)) {
|
||||
try {
|
||||
player.removePotionEffect(type);
|
||||
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
|
||||
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
|
||||
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
|
||||
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
|
||||
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
|
||||
}
|
||||
} catch (RuntimeException unavailable) { return; }
|
||||
}
|
||||
owned = null; displaced = null;
|
||||
}
|
||||
states.remove(active.player(), this);
|
||||
}
|
||||
}
|
||||
|
||||
private final String passive;
|
||||
private final PotionEffectType type;
|
||||
private final int amplifier;
|
||||
private final boolean replaceWeaker;
|
||||
private final EquipmentSessions sessions;
|
||||
private final Map<UUID, State> states = new java.util.HashMap<>();
|
||||
private int activeCursor;
|
||||
private int stateCursor;
|
||||
private long sweepGeneration;
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
|
||||
EquipmentSessions sessions) {
|
||||
this.passive = Objects.requireNonNull(passive); this.type = Objects.requireNonNull(type);
|
||||
if (passive.isBlank() || amplifier < 0) throw new IllegalArgumentException("Invalid persistent potion effect");
|
||||
this.amplifier = amplifier; this.replaceWeaker = replaceWeaker; this.sessions = Objects.requireNonNull(sessions);
|
||||
}
|
||||
|
||||
void install(org.bukkit.plugin.Plugin plugin) {
|
||||
Objects.requireNonNull(plugin);
|
||||
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
|
||||
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
|
||||
}
|
||||
|
||||
void sweep() {
|
||||
if (closed) return;
|
||||
sweepGeneration++;
|
||||
var active = sessions.activeCharacters();
|
||||
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
|
||||
for (int index = 0; index < activeChecks; index++) {
|
||||
var snapshot = active.get((activeCursor + index) % active.size());
|
||||
reconcile(snapshot);
|
||||
}
|
||||
activeCursor = active.isEmpty() ? 0 : (activeCursor + activeChecks) % active.size();
|
||||
|
||||
var known = List.copyOf(states.values());
|
||||
int stateChecks = Math.min(MAX_ACTIVE_CHECKS, known.size());
|
||||
for (int index = 0; index < stateChecks; index++) {
|
||||
var state = known.get((stateCursor + index) % known.size());
|
||||
if (state.refreshedAt != sweepGeneration) state.refresh();
|
||||
}
|
||||
stateCursor = known.isEmpty() ? 0 : (stateCursor + stateChecks) % known.size();
|
||||
}
|
||||
|
||||
private void reconcile(EquipmentSessions.ActiveCharacter snapshot) {
|
||||
var active = snapshot.session();
|
||||
var state = states.get(active.player());
|
||||
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
|
||||
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
|
||||
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
|
||||
if (state == null) {
|
||||
var player = sessions.currentPlayer(active).orElse(null);
|
||||
if (player == null) return;
|
||||
state = new State(active, player); states.put(active.player(), state);
|
||||
}
|
||||
state.refresh();
|
||||
}
|
||||
|
||||
private static boolean same(PotionEffect first, PotionEffect second) {
|
||||
return first != null && second != null && first.getType().equals(second.getType())
|
||||
&& first.getAmplifier() == second.getAmplifier() && first.isInfinite() && second.isInfinite()
|
||||
&& first.isAmbient() == second.isAmbient() && first.hasParticles() == second.hasParticles()
|
||||
&& first.hasIcon() == second.hasIcon();
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(states.values())) state.remove();
|
||||
states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration I metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationI {
|
||||
public static final String ID = "quicker_regeneration_i";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration I bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.ONE); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationI(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration I",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(12), Stat.WIS, BigInteger.valueOf(12)), Set.of(), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " to each eligible out-of-combat recovery pulse.",
|
||||
"Uses the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Quicker Regeneration II metadata and live configured pulse bonus. */
|
||||
public final class QuickerRegenerationII {
|
||||
public static final String ID = "quicker_regeneration_ii";
|
||||
public record Settings(BigInteger bonus) {
|
||||
public Settings {
|
||||
Objects.requireNonNull(bonus);
|
||||
if (bonus.signum() < 0 || bonus.compareTo(BigInteger.valueOf(1_024)) > 0) {
|
||||
throw new IllegalArgumentException("Invalid Quicker Regeneration II bonus");
|
||||
}
|
||||
}
|
||||
public static Settings defaults() { return new Settings(BigInteger.TWO); }
|
||||
}
|
||||
private volatile Settings settings;
|
||||
public QuickerRegenerationII(Settings settings) { this.settings = Objects.requireNonNull(settings); }
|
||||
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
|
||||
public BigInteger bonus() { return settings.bonus(); }
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Quicker Regeneration II",
|
||||
Map.of(Stat.CON, BigInteger.valueOf(16), Stat.WIS, BigInteger.valueOf(14)),
|
||||
Set.of(QuickerRegenerationI.ID), BigInteger.ONE,
|
||||
List.of("Add " + bonus() + " health point" + (bonus().equals(BigInteger.ONE) ? "" : "s")
|
||||
+ " beyond Quicker Regeneration I to each eligible out-of-combat recovery pulse.",
|
||||
"Both bonuses use the same capped persisted custom-healing transaction as base recovery."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Current-session native Speed I layer for the learned character passive. */
|
||||
public final class SpeedI implements AutoCloseable {
|
||||
public static final String ID = "speed_i";
|
||||
private final PersistentPotionEffect effect;
|
||||
|
||||
public SpeedI(EquipmentSessions sessions) {
|
||||
effect = new PersistentPotionEffect(ID, PotionEffectType.SPEED, 0, true, sessions);
|
||||
}
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Speed I", Map.of(Stat.DEX, BigInteger.valueOf(14)), Set.of(), BigInteger.ONE,
|
||||
List.of("Continuously provides the standard native Speed I effect while this character is active.",
|
||||
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
|
||||
public void sweep() { effect.sweep(); }
|
||||
@Override public void close() { effect.close(); }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick.passive;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fixed reviewed metadata and native level-I behavior for the basic-stick Knockback passive. */
|
||||
public final class StickKnockback {
|
||||
public static final String ID = "stick_knockback";
|
||||
|
||||
public PassiveDefinition definition() {
|
||||
return new PassiveDefinition(ID, "Knockback", Map.of(Stat.STR, BigInteger.valueOf(12)), Set.of(),
|
||||
BigInteger.ONE, List.of("Your bound basic attack stick carries native Knockback I.",
|
||||
"Only positive accepted basic-stick damage can knock the target back."));
|
||||
}
|
||||
|
||||
public static void apply(org.bukkit.entity.Player source, org.bukkit.entity.LivingEntity target) {
|
||||
double radians = Math.toRadians(source.getLocation().getYaw());
|
||||
double directionX = Math.sin(radians);
|
||||
double directionZ = -Math.cos(radians);
|
||||
if (!Double.isFinite(directionX) || !Double.isFinite(directionZ)) return;
|
||||
boolean sprinting = source.isSprinting();
|
||||
target.knockback(sprinting ? 2 : 1, directionX, directionZ);
|
||||
if (sprinting) source.setSprinting(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication settings for later daily catalogs, camp and champion attempts. */
|
||||
public final class AdventureQuestSettings implements AutoCloseable {
|
||||
public static final String DAILY_COUNT="adventure-daily-site-count", RADIUS="adventure-site-radius",
|
||||
RESET_SECONDS="adventure-site-reset-seconds", DUNGEON_ROOM_LINK_DISTANCE="dungeon-room-link-distance",
|
||||
CAMP_ENEMIES="adventure-camp-enemies",
|
||||
CAMP_REWARD_XP="adventure-camp-reward-xp", CAMP_REWARD_COINS="adventure-camp-reward-coins",
|
||||
CHAMPION_HEALTH="adventure-champion-health", CHAMPION_DAMAGE="adventure-champion-damage",
|
||||
CHAMPION_REWARD_XP="adventure-champion-reward-xp", CHAMPION_REWARD_COINS="adventure-champion-reward-coins";
|
||||
private static final Map<String,String> DEFAULTS=Map.ofEntries(
|
||||
Map.entry(DAILY_COUNT,"3"),Map.entry(RADIUS,"16"),Map.entry(RESET_SECONDS,"30"),
|
||||
Map.entry(DUNGEON_ROOM_LINK_DISTANCE,"10"),Map.entry(CAMP_ENEMIES,"8"),
|
||||
Map.entry(CAMP_REWARD_XP,"100"),Map.entry(CAMP_REWARD_COINS,"20"),Map.entry(CHAMPION_HEALTH,"100"),
|
||||
Map.entry(CHAMPION_DAMAGE,"8"),Map.entry(CHAMPION_REWARD_XP,"150"),Map.entry(CHAMPION_REWARD_COINS,"40"));
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
public record Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins,int dungeonRoomLinkDistance) {
|
||||
public Rules(int dailySiteCount,int radius,int resetSeconds,int campEnemies,
|
||||
BigInteger campRewardXp,BigInteger campRewardCoins,int championHealth,int championDamage,
|
||||
BigInteger championRewardXp,BigInteger championRewardCoins) {
|
||||
this(dailySiteCount,radius,resetSeconds,campEnemies,campRewardXp,campRewardCoins,championHealth,
|
||||
championDamage,championRewardXp,championRewardCoins,10);
|
||||
}
|
||||
}
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence) {
|
||||
public Update { values=Map.copyOf(values); }
|
||||
}
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-adventure-configuration"));
|
||||
private volatile Map<String,String> effective;
|
||||
private Map<String,String> persisted;
|
||||
private long savedSequence,publishedSequence;
|
||||
private boolean closed; private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public AdventureQuestSettings(Path directory,Map<String,String> initial) {
|
||||
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
for(String key:KEYS)values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));
|
||||
parse(values);effective=Map.copyOf(values);persisted=effective;
|
||||
}
|
||||
public static List<String> keys(){return KEYS;}
|
||||
public static String defaultValue(String key){return DEFAULTS.get(key);}
|
||||
public String value(String key){return effective.get(key);}
|
||||
public Rules rules(){return parse(effective);}
|
||||
public static Rules parse(Map<String,String> values){
|
||||
try{return new Rules(bounded(values.get(DAILY_COUNT),0,20),bounded(values.get(RADIUS),4,64),
|
||||
bounded(values.get(RESET_SECONDS),0,600),bounded(values.get(CAMP_ENEMIES),4,64),
|
||||
whole(values.get(CAMP_REWARD_XP)),whole(values.get(CAMP_REWARD_COINS)),
|
||||
bounded(values.get(CHAMPION_HEALTH),1,2048),bounded(values.get(CHAMPION_DAMAGE),1,2048),
|
||||
whole(values.get(CHAMPION_REWARD_XP)),whole(values.get(CHAMPION_REWARD_COINS)),
|
||||
bounded(values.get(DUNGEON_ROOM_LINK_DISTANCE),1,256));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Invalid adventure quest settings",invalid);}
|
||||
}
|
||||
private static int bounded(String raw,int minimum,int maximum){var value=new BigInteger(raw);if(value.compareTo(BigInteger.valueOf(minimum))<0||value.compareTo(BigInteger.valueOf(maximum))>0)throw new IllegalArgumentException();return value.intValueExact();}
|
||||
private static BigInteger whole(String raw){var value=new BigInteger(raw);if(value.signum()<0||value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return value;}
|
||||
public CompletionStage<Update> set(String key,String raw){
|
||||
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown adventure setting");
|
||||
var preview=new LinkedHashMap<>(effective);preview.put(key,new BigInteger(raw).toString());var accepted=preview.get(key);parse(preview);
|
||||
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}
|
||||
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();
|
||||
}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
|
||||
private void save(String key,String value)throws IOException{
|
||||
Files.createDirectories(directory);var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.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(1_048_576);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(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}
|
||||
document.put(key,Integer.parseInt(value));byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);DurableFiles.forceDirectory(directory);}finally{Files.deleteIfExists(temporary);}
|
||||
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",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,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorCatalog;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Durable adapter boundary for the one shared armor catalog. */
|
||||
public interface ArmorCatalogRepository {
|
||||
Optional<ArmorCatalog> load() throws IOException;
|
||||
void save(ArmorCatalog catalog) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorProduct;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable recovery plans spanning one catalog reservation and character gear purchase. */
|
||||
public final class ArmorPurchaseOperationStore implements AutoCloseable {
|
||||
public record Plan(UUID id, UUID character, BigInteger expectedCharacterRevision, UUID catalogRevision,
|
||||
ArmorProduct product, BigInteger price) {
|
||||
public Plan {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(character); Objects.requireNonNull(expectedCharacterRevision);
|
||||
Objects.requireNonNull(catalogRevision); Objects.requireNonNull(product); Objects.requireNonNull(price);
|
||||
if(expectedCharacterRevision.signum()<0||price.signum()<=0)throw new IllegalArgumentException("Invalid armor purchase plan");
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-armor-operation-persistence"));
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public ArmorPurchaseOperationStore(Path data){directory=data.toAbsolutePath().resolve("armor-purchase-operations");}
|
||||
|
||||
public CompletionStage<Plan> prepare(Plan plan){return submit(()->{
|
||||
Files.createDirectories(directory);Path target=file(plan.id());
|
||||
if(Files.exists(target)) {
|
||||
var existing=decode(Files.readString(target,StandardCharsets.UTF_8));
|
||||
if(!existing.equals(plan))throw new IOException("Armor operation identity collision");
|
||||
return existing;
|
||||
}
|
||||
write(target,encode(plan));return plan;
|
||||
});}
|
||||
public CompletionStage<Void> complete(UUID id){return submit(()->{if(Files.deleteIfExists(file(id)))DurableFiles.forceDirectory(directory);return null;});}
|
||||
public CompletionStage<List<Plan>> load(){return submit(this::loadBlocking);}
|
||||
|
||||
private List<Plan> loadBlocking()throws IOException {
|
||||
if(Files.notExists(directory))return List.of();
|
||||
var result=new ArrayList<Plan>();
|
||||
try(var files=Files.list(directory)) {
|
||||
for(var path:files.filter(value->value.getFileName().toString().endsWith(".yml")).sorted().toList()) {
|
||||
if(Files.size(path)>65_536)throw new IOException("Armor operation too large");
|
||||
result.add(decode(Files.readString(path,StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
private Path file(UUID id){return directory.resolve(id+".yml");}
|
||||
private static String encode(Plan plan){Map<String,Object> row=new LinkedHashMap<>();row.put("schema-version",1);
|
||||
row.put("operation-id",plan.id().toString());row.put("character",plan.character().toString());
|
||||
row.put("expected-character-revision",plan.expectedCharacterRevision().toString());
|
||||
row.put("catalog-revision",plan.catalogRevision().toString());row.put("slot",plan.product().slot().name());
|
||||
row.put("tier",plan.product().tier().name());row.put("price",plan.price().toString());return yaml().dump(row);}
|
||||
private static Plan decode(String input){Object decoded=yaml().load(input);if(!(decoded instanceof Map<?,?> row)||
|
||||
!"1".equals(String.valueOf(row.get("schema-version"))))throw new IllegalArgumentException("Invalid armor operation schema");
|
||||
return new Plan(UUID.fromString(text(row,"operation-id")),UUID.fromString(text(row,"character")),
|
||||
new BigInteger(text(row,"expected-character-revision")),UUID.fromString(text(row,"catalog-revision")),
|
||||
new ArmorProduct(GearSlot.valueOf(text(row,"slot")),GearTier.valueOf(text(row,"tier"))),new BigInteger(text(row,"price")));}
|
||||
private static void write(Path target,String content)throws IOException {Path temporary=Files.createTempFile(target.getParent(),"armor-operation-",".tmp");
|
||||
try{byte[] bytes=content.getBytes(StandardCharsets.UTF_8);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){
|
||||
var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}
|
||||
try{Files.move(temporary,target,StandardCopyOption.ATOMIC_MOVE);}catch(AtomicMoveNotSupportedException unsupported){
|
||||
throw new IOException("Atomic armor-operation persistence unavailable",unsupported);}DurableFiles.forceDirectory(target.getParent());}
|
||||
finally{Files.deleteIfExists(temporary);}}
|
||||
private static Yaml yaml(){var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(0);
|
||||
options.setCodePointLimit(65_536);return new Yaml(new SafeConstructor(options));}
|
||||
private static String text(Map<?,?> row,String key){Object value=row.get(key);if(value instanceof String text)return text;throw new IllegalArgumentException("Invalid "+key);}
|
||||
private <T>CompletionStage<T> submit(Callable<T> action){synchronized(this){if(closed)return CompletableFuture.failedStage(
|
||||
new IOException("Armor operation store closed"));pending++;}return CompletableFuture.supplyAsync(()->{try{return action.call();}
|
||||
catch(Exception failure){throw new CompletionException(failure);}finally{finished();}},worker).minimalCompletionStage();}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}
|
||||
public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.ChainLightning;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Chain Lightning settings; publication remains a server-thread caller decision. */
|
||||
public final class ChainLightningSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "chain-lightning-base-damage";
|
||||
public static final String COOLDOWN = "chain-lightning-cooldown-seconds";
|
||||
public static final String INITIAL_RANGE = "chain-lightning-initial-range-blocks";
|
||||
public static final String JUMP_RANGE = "chain-lightning-jump-range-blocks";
|
||||
public static final String ADDITIONAL = "chain-lightning-additional-targets";
|
||||
public static final String MULTIPLIER = "chain-lightning-jump-multiplier";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, INITIAL_RANGE, JUMP_RANGE, ADDITIONAL, MULTIPLIER);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "8", COOLDOWN, "18", INITIAL_RANGE, "16", JUMP_RANGE, "6", ADDITIONAL, "3", MULTIPLIER, "0.75");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, ChainLightning.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-chain-lightning-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public ChainLightningSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
ChainLightningSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public ChainLightning.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Chain Lightning configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static ChainLightning.Settings parse(Map<String, String> values) {
|
||||
try { return new ChainLightning.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(INITIAL_RANGE)), finite(values.get(JUMP_RANGE)),
|
||||
new BigDecimal(values.get(ADDITIONAL)).intValueExact(), new BigDecimal(values.get(MULTIPLIER))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Chain Lightning settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Permanent helmet-free character armor state. */
|
||||
public record CharacterGear(GearTier chestplate,GearTier leggings,GearTier boots) {
|
||||
public CharacterGear{if(chestplate==null||leggings==null||boots==null)throw new IllegalArgumentException("Missing gear tier");}
|
||||
public static CharacterGear none(){return new CharacterGear(GearTier.NONE,GearTier.NONE,GearTier.NONE);}
|
||||
public GearTier tier(GearSlot slot){return switch(slot){case CHESTPLATE->chestplate;case LEGGINGS->leggings;case BOOTS->boots;};}
|
||||
public CharacterGear with(GearSlot slot,GearTier tier){Objects.requireNonNull(slot);Objects.requireNonNull(tier);return switch(slot){case CHESTPLATE->new CharacterGear(tier,leggings,boots);case LEGGINGS->new CharacterGear(chestplate,tier,boots);case BOOTS->new CharacterGear(chestplate,leggings,tier);};}
|
||||
}
|
||||
@@ -16,15 +16,43 @@ import java.util.UUID;
|
||||
public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
boolean dead, UUID deathId, Instant diedAt,
|
||||
BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts, BigInteger pvpCoinEscrow,CharacterGear gear) {
|
||||
public CharacterRecord(UUID id,String name,BigInteger revision,Map<Stat,BigInteger> stats,int level,BigInteger experience,BigInteger unspentPoints,BigDecimal health,Set<String> learned,List<String> loadout,List<String> passives,Map<String,Instant> cooldowns,Map<UUID,CombatRewardRules.KillPosition> restrictedKills,boolean dead,UUID deathId,Instant diedAt,BigInteger coins,boolean coinsInitialized,Set<UUID> rewardReceipts,BigInteger pvpCoinEscrow){this(id,name,revision,stats,level,experience,unspentPoints,health,learned,loadout,passives,cooldowns,restrictedKills,dead,deathId,diedAt,coins,coinsInitialized,rewardReceipts,pvpCoinEscrow,CharacterGear.none());}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills, boolean dead, UUID deathId,
|
||||
Instant diedAt, BigInteger coins, boolean coinsInitialized, Set<UUID> rewardReceipts) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, coins, coinsInitialized, rewardReceipts, BigInteger.ZERO);
|
||||
}
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
cooldowns, Map.of(), dead, deathId, diedAt);
|
||||
List.of("", "", ""), cooldowns, Map.of(), dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout,
|
||||
List.of("", "", ""), cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord(UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, List<String> passives, Map<String, Instant> cooldowns,
|
||||
Map<UUID, CombatRewardRules.KillPosition> restrictedKills,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
this(id, name, revision, stats, level, experience, unspentPoints, health, learned, loadout, passives,
|
||||
cooldowns, restrictedKills, dead, deathId, diedAt, BigInteger.ZERO, true, Set.of());
|
||||
}
|
||||
|
||||
public CharacterRecord {
|
||||
@@ -34,20 +62,31 @@ public record CharacterRecord(
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(unspentPoints);
|
||||
Objects.requireNonNull(health);
|
||||
Objects.requireNonNull(coins);
|
||||
Objects.requireNonNull(pvpCoinEscrow);
|
||||
Objects.requireNonNull(gear);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
passives = List.copyOf(passives);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
restrictedKills = Map.copyOf(restrictedKills);
|
||||
rewardReceipts = Set.copyOf(rewardReceipts);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
|| health.signum() < 0
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| loadout.size() != 7 || passives.size() != 3
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).anyMatch(String::isBlank)
|
||||
|| passives.stream().filter(passiveId -> !passiveId.isEmpty()).distinct().count()
|
||||
!= passives.stream().filter(passiveId -> !passiveId.isEmpty()).count()
|
||||
|| learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| restrictedKills.size() > 1024
|
||||
|| restrictedKills.entrySet().stream().anyMatch(entry -> !entry.getKey().equals(entry.getValue().world()))
|
||||
|| coins.signum() < 0 || pvpCoinEscrow.signum() < 0 || !coinsInitialized && coins.signum() != 0
|
||||
|| rewardReceipts.size() > 4096 || !dead && pvpCoinEscrow.signum() != 0
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
|
||||
@@ -24,14 +24,27 @@ import java.util.concurrent.RejectedExecutionException;
|
||||
public final class CharacterStorage implements AutoCloseable {
|
||||
private final YamlCharacterRepository repository;
|
||||
private final Clock clock;
|
||||
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
|
||||
private final java.util.random.RandomGenerator coinRandom;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock) {
|
||||
this(repository, clock, games.dmg.fantasystick.progression.CoinRules::defaults,
|
||||
java.util.random.RandomGenerator.getDefault());
|
||||
}
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock,
|
||||
java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules,
|
||||
java.util.random.RandomGenerator coinRandom) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.coinRules = Objects.requireNonNull(coinRules);
|
||||
this.coinRandom = Objects.requireNonNull(coinRandom);
|
||||
}
|
||||
|
||||
public void attachUnavailableCharacter(java.util.function.Predicate<UUID> predicate){repository.attachUnavailable(predicate);}
|
||||
|
||||
public CompletableFuture<Void> create(CharacterRecord character) {
|
||||
return submit(() -> {
|
||||
repository.create(character);
|
||||
@@ -53,7 +66,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| record.dead() || record.deathId() != null || record.level() != 1
|
||||
|| record.experience().signum() != 0 || record.stats().values().stream().anyMatch(value -> value.signum() != 0)
|
||||
|| record.health().compareTo(java.math.BigDecimal.TEN) != 0
|
||||
|| !record.learned().isEmpty() || !record.cooldowns().isEmpty()
|
||||
|| !record.learned().isEmpty() || record.passives().stream().anyMatch(id -> !id.isEmpty())
|
||||
|| !record.cooldowns().isEmpty() || !record.coinsInitialized() || !record.rewardReceipts().isEmpty() || !record.gear().equals(CharacterGear.none())
|
||||
|| record.loadout().stream().anyMatch(ability -> !ability.isEmpty())) {
|
||||
throw new IllegalArgumentException("Craft batch requires distinct new characters");
|
||||
}
|
||||
@@ -192,7 +206,20 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> repository.load(id));
|
||||
return submit(() -> load(id));
|
||||
}
|
||||
|
||||
/** Persist a legacy character's one-time configured migration roll before exposing it. */
|
||||
private Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
var found = repository.load(id);
|
||||
if (found.isEmpty() || found.orElseThrow().coinsInitialized()) return found;
|
||||
var old = found.orElseThrow();
|
||||
var migrated = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(),
|
||||
old.cooldowns(), old.restrictedKills(), old.dead(), old.deathId(), old.diedAt(),
|
||||
coinRules.get().starting(coinRandom), true, old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(migrated, old.revision());
|
||||
return Optional.of(migrated);
|
||||
}
|
||||
|
||||
/** Persist an accepted native rename against the latest live record; never create a character from item metadata. */
|
||||
@@ -205,8 +232,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var old = found.orElseThrow();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
});
|
||||
@@ -246,29 +273,90 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new ExperienceAward(ExperienceAwardStatus.APPLIED, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, STALE, UNAVAILABLE }
|
||||
public enum CoinSpendStatus { APPLIED, DUPLICATE, INSUFFICIENT, STALE, UNAVAILABLE }
|
||||
public record CoinSpend(CoinSpendStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Persist one operation-keyed debit; an acknowledged operation can never charge twice. */
|
||||
public CompletableFuture<CoinSpend> spendCoins(UUID characterId, BigInteger expectedRevision, UUID operationId,
|
||||
BigInteger amount) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(operationId); Objects.requireNonNull(amount);
|
||||
if (amount.signum() < 0) throw new IllegalArgumentException("Coin debit must not be negative");
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CoinSpend(CoinSpendStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (old.rewardReceipts().contains(operationId)) {
|
||||
return new CoinSpend(CoinSpendStatus.DUPLICATE, Optional.of(old));
|
||||
}
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CoinSpend(CoinSpendStatus.STALE, Optional.of(old));
|
||||
}
|
||||
if (old.coins().compareTo(amount) < 0) {
|
||||
return new CoinSpend(CoinSpendStatus.INSUFFICIENT, Optional.of(old));
|
||||
}
|
||||
var receipts = new java.util.HashSet<>(old.rewardReceipts());
|
||||
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
|
||||
receipts.add(operationId);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(),
|
||||
old.passives(), old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(),
|
||||
old.coins().subtract(amount), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CoinSpend(CoinSpendStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum CoinRefundStatus { APPLIED, DUPLICATE, UNAVAILABLE }
|
||||
public record CoinRefund(CoinRefundStatus status,Optional<CharacterRecord> record){}
|
||||
/** Compensate a persisted debit exactly once while retaining both audit receipts. */
|
||||
public CompletableFuture<CoinRefund> refundCoins(UUID characterId,UUID debitId,UUID refundId,BigInteger amount){Objects.requireNonNull(characterId);Objects.requireNonNull(debitId);Objects.requireNonNull(refundId);Objects.requireNonNull(amount);if(amount.signum()<0)throw new IllegalArgumentException("Coin refund must not be negative");return submit(()->{var found=repository.load(characterId);if(found.isEmpty())return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(refundId))return new CoinRefund(CoinRefundStatus.DUPLICATE,Optional.of(old));if(!old.rewardReceipts().contains(debitId))return new CoinRefund(CoinRefundStatus.UNAVAILABLE,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().filter(id->!id.equals(debitId)).min(UUID::compareTo).orElseThrow());receipts.add(refundId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),old.dead(),old.deathId(),old.diedAt(),old.coins().add(amount),old.coinsInitialized(),receipts,old.pvpCoinEscrow(),old.gear());repository.replace(saved,old.revision());return new CoinRefund(CoinRefundStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum GearPurchaseStatus { APPLIED,DUPLICATE,INSUFFICIENT,NOT_UPGRADE,STALE,UNAVAILABLE }
|
||||
public record GearPurchase(GearPurchaseStatus status,Optional<CharacterRecord> record){}
|
||||
/** One character-file transaction owns both the exact coin debit and strict gear replacement. */
|
||||
public CompletableFuture<GearPurchase> purchaseGear(UUID characterId,BigInteger expectedRevision,UUID operationId,GearSlot slot,GearTier target,BigInteger price){Objects.requireNonNull(characterId);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(operationId);Objects.requireNonNull(slot);Objects.requireNonNull(target);Objects.requireNonNull(price);if(target==GearTier.NONE||price.signum()<0)throw new IllegalArgumentException("Invalid gear purchase");return submit(()->{var found=repository.load(characterId);if(found.isEmpty()||found.orElseThrow().dead())return new GearPurchase(GearPurchaseStatus.UNAVAILABLE,Optional.empty());var old=found.orElseThrow();if(old.rewardReceipts().contains(operationId))return new GearPurchase(GearPurchaseStatus.DUPLICATE,Optional.of(old));if(!old.revision().equals(expectedRevision))return new GearPurchase(GearPurchaseStatus.STALE,Optional.of(old));if(!target.higherThan(old.gear().tier(slot)))return new GearPurchase(GearPurchaseStatus.NOT_UPGRADE,Optional.of(old));if(old.coins().compareTo(price)<0)return new GearPurchase(GearPurchaseStatus.INSUFFICIENT,Optional.of(old));var receipts=new java.util.HashSet<>(old.rewardReceipts());if(receipts.size()>=4096)receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());receipts.add(operationId);var saved=new CharacterRecord(old.id(),old.name(),old.revision().add(BigInteger.ONE),old.stats(),old.level(),old.experience(),old.unspentPoints(),old.health(),old.learned(),old.loadout(),old.passives(),old.cooldowns(),old.restrictedKills(),false,old.deathId(),old.diedAt(),old.coins().subtract(price),true,receipts,old.pvpCoinEscrow(),old.gear().with(slot,target));repository.replace(saved,old.revision());return new GearPurchase(GearPurchaseStatus.APPLIED,Optional.of(saved));});}
|
||||
|
||||
public enum CombatAwardStatus { APPLIED, SPACING_BLOCKED, AT_CAP, UNCHANGED, DUPLICATE, STALE, UNAVAILABLE }
|
||||
public record CombatAward(CombatAwardStatus status, Optional<CharacterRecord> record, int levels) { }
|
||||
|
||||
/** Persist one combat share and its restricted-kill reference in the same character revision. */
|
||||
/** Compatibility route for callers that do not yet carry a stable reward identity or coins. */
|
||||
public CompletableFuture<CombatAward> awardCombatExperience(UUID characterId, BigInteger expectedRevision,
|
||||
BigInteger reward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision);
|
||||
Objects.requireNonNull(reward); Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (reward.signum() < 0) throw new IllegalArgumentException("Experience reward must not be negative");
|
||||
return awardCombatReward(characterId, expectedRevision, UUID.randomUUID(), reward, BigInteger.ZERO,
|
||||
progression, rewards, restrictedKill);
|
||||
}
|
||||
|
||||
/** Persist one idempotent XP/coin recipient share, receipt and spacing reference in one revision. */
|
||||
public CompletableFuture<CombatAward> awardCombatReward(UUID characterId, BigInteger expectedRevision, UUID rewardId,
|
||||
BigInteger experienceReward, BigInteger coinReward, ProgressionRules progression,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules rewards,
|
||||
games.dmg.fantasystick.progression.CombatRewardRules.KillPosition restrictedKill) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(rewardId);
|
||||
Objects.requireNonNull(experienceReward); Objects.requireNonNull(coinReward);
|
||||
Objects.requireNonNull(progression); Objects.requireNonNull(rewards);
|
||||
if (experienceReward.signum() < 0 || coinReward.signum() < 0) {
|
||||
throw new IllegalArgumentException("Combat rewards must not be negative");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new CombatAward(CombatAwardStatus.UNAVAILABLE, Optional.empty(), 0);
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (old.rewardReceipts().contains(rewardId)) {
|
||||
return new CombatAward(CombatAwardStatus.DUPLICATE, Optional.of(old), 0);
|
||||
}
|
||||
if (!old.revision().equals(expectedRevision)) {
|
||||
return new CombatAward(CombatAwardStatus.STALE, Optional.of(old), 0);
|
||||
}
|
||||
@@ -278,24 +366,94 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (restrictedKill != null) history.put(restrictedKill.world(), restrictedKill);
|
||||
var cap = progression.xpPerLevel().multiply(BigInteger.valueOf(progression.maximumLevel() - 1L));
|
||||
boolean atCap = old.level() >= progression.maximumLevel() || old.experience().compareTo(cap) >= 0;
|
||||
var experience = eligible && !atCap ? old.experience().add(reward).min(cap) : old.experience();
|
||||
var experience = eligible && !atCap ? old.experience().add(experienceReward).min(cap) : old.experience();
|
||||
int level = atCap ? old.level() : experience.divide(progression.xpPerLevel()).intValueExact() + 1;
|
||||
int gained = Math.max(0, level - old.level());
|
||||
var status = !eligible ? CombatAwardStatus.SPACING_BLOCKED : atCap ? CombatAwardStatus.AT_CAP
|
||||
: experience.equals(old.experience()) ? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
if (history.equals(old.restrictedKills()) && experience.equals(old.experience())) {
|
||||
return new CombatAward(status, Optional.of(old), 0);
|
||||
}
|
||||
: experience.equals(old.experience()) && coinReward.signum() == 0
|
||||
? CombatAwardStatus.UNCHANGED : CombatAwardStatus.APPLIED;
|
||||
var receipts = new java.util.HashSet<>(old.rewardReceipts());
|
||||
if (receipts.size() >= 4096) receipts.remove(receipts.stream().min(UUID::compareTo).orElseThrow());
|
||||
receipts.add(rewardId);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
level, experience, old.unspentPoints().add(BigInteger.valueOf(gained)), old.health(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), history, false, old.deathId(), old.diedAt());
|
||||
old.loadout(), old.passives(), old.cooldowns(), history, false, old.deathId(), old.diedAt(),
|
||||
old.coins().add(coinReward), true, receipts,old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new CombatAward(status, Optional.of(saved), gained);
|
||||
});
|
||||
}
|
||||
|
||||
public enum PassiveMutationStatus {
|
||||
APPLIED, STALE, REQUIREMENTS, NO_POINTS, PASSIVE_SLOTS_FULL, ALREADY_LEARNED,
|
||||
NOT_LEARNED, REQUIRED_BY_LEARNED, UNAVAILABLE
|
||||
}
|
||||
public record PassiveMutation(PassiveMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
/** Learn one registered passive in the lowest empty independent slot as one character revision. */
|
||||
public CompletableFuture<PassiveMutation> unlockPassive(UUID characterId, BigInteger expectedRevision, String passive,
|
||||
java.util.Map<Stat, BigInteger> requirements, Set<String> prerequisites, BigInteger cost) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(requirements); Objects.requireNonNull(prerequisites); Objects.requireNonNull(cost);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || !cost.equals(BigInteger.ONE)
|
||||
|| requirements.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(BigInteger.TEN) < 0)
|
||||
|| prerequisites.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive unlock");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.ALREADY_LEARNED, Optional.of(old));
|
||||
if (!old.passives().containsAll(prerequisites) || requirements.entrySet().stream()
|
||||
.anyMatch(entry -> old.stats().get(entry.getKey()).compareTo(entry.getValue()) < 0)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new PassiveMutation(PassiveMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.passives().indexOf("");
|
||||
if (slot < 0) return new PassiveMutation(PassiveMutationStatus.PASSIVE_SLOTS_FULL, Optional.of(old));
|
||||
var passives = new java.util.ArrayList<>(old.passives()); passives.set(slot, passive);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
/** Refund one passive once while refusing removal beneath a registered learned dependent. */
|
||||
public CompletableFuture<PassiveMutation> unlearnPassive(UUID characterId, BigInteger expectedRevision,
|
||||
String passive, Set<String> learnedDependents) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(expectedRevision); Objects.requireNonNull(passive);
|
||||
Objects.requireNonNull(learnedDependents);
|
||||
if (!passive.matches("[a-z][a-z0-9_]{0,63}") || learnedDependents.stream().anyMatch(String::isBlank)) {
|
||||
throw new IllegalArgumentException("Invalid passive refund");
|
||||
}
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) {
|
||||
return new PassiveMutation(PassiveMutationStatus.UNAVAILABLE, Optional.empty());
|
||||
}
|
||||
var old = found.orElseThrow();
|
||||
if (!old.revision().equals(expectedRevision)) return new PassiveMutation(PassiveMutationStatus.STALE, Optional.of(old));
|
||||
if (!old.passives().contains(passive)) return new PassiveMutation(PassiveMutationStatus.NOT_LEARNED, Optional.of(old));
|
||||
if (old.passives().stream().anyMatch(learnedDependents::contains)) {
|
||||
return new PassiveMutation(PassiveMutationStatus.REQUIRED_BY_LEARNED, Optional.of(old));
|
||||
}
|
||||
var passives = old.passives().stream().map(id -> id.equals(passive) ? "" : id).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(), old.learned(), old.loadout(), passives,
|
||||
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new PassiveMutation(PassiveMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
}
|
||||
|
||||
public enum AbilityMutationStatus {
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
APPLIED, UNCHANGED, STALE, REQUIREMENTS, NO_POINTS, LOADOUT_FULL, ALREADY_LEARNED, NOT_LEARNED, COOLDOWN, UNAVAILABLE
|
||||
}
|
||||
public record AbilityMutation(AbilityMutationStatus status, Optional<CharacterRecord> record) { }
|
||||
|
||||
@@ -317,10 +475,13 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
return new AbilityMutation(AbilityMutationStatus.REQUIREMENTS, Optional.of(old));
|
||||
}
|
||||
if (old.unspentPoints().compareTo(cost) < 0) return new AbilityMutation(AbilityMutationStatus.NO_POINTS, Optional.of(old));
|
||||
int slot = old.loadout().indexOf("");
|
||||
if (slot < 0) return new AbilityMutation(AbilityMutationStatus.LOADOUT_FULL, Optional.of(old));
|
||||
var learned = new java.util.HashSet<>(old.learned()); learned.add(ability);
|
||||
var loadout = new java.util.ArrayList<>(old.loadout()); loadout.set(slot, ability);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints().subtract(cost), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -339,8 +500,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
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());
|
||||
old.experience(), old.unspentPoints().add(BigInteger.ONE), old.health(), learned, loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -362,8 +523,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
loadout.set(slot, ability);
|
||||
if (loadout.equals(old.loadout())) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), loadout, old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -386,8 +547,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (readyAt.equals(previous)) return new AbilityMutation(AbilityMutationStatus.UNCHANGED, Optional.of(old));
|
||||
var cooldowns = new java.util.HashMap<>(old.cooldowns()); cooldowns.put(ability, readyAt);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), cooldowns,
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new AbilityMutation(AbilityMutationStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -431,8 +592,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var gain = rawMaximum.min(nativeLimit).subtract(oldMaximum);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
|
||||
old.level(), old.experience(), old.unspentPoints().subtract(BigInteger.ONE),
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt());
|
||||
currentHealth.add(gain), old.learned(), old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), false,
|
||||
old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return new StatPurchase(StatPurchaseStatus.APPLIED, Optional.of(saved));
|
||||
});
|
||||
@@ -457,8 +618,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
var bounded = health.min(old.maximumHealth()).min(nativeLimit);
|
||||
if (old.health().compareTo(bounded) == 0) return Optional.of(old);
|
||||
var saved = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt());
|
||||
old.experience(), old.unspentPoints(), bounded, old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear());
|
||||
repository.replace(saved, old.revision());
|
||||
return Optional.of(saved);
|
||||
});
|
||||
@@ -471,14 +632,18 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
* Callers must retain their one-use session authority until the result resolves.
|
||||
*/
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId) {
|
||||
return death(characterId, null);
|
||||
return death(characterId, null, false);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
return death(characterId, Objects.requireNonNull(expectedRevision));
|
||||
return death(characterId, Objects.requireNonNull(expectedRevision), false);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision) {
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, boolean loseCoins) {
|
||||
return death(characterId, null, loseCoins);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> death(UUID characterId, BigInteger expectedRevision, boolean loseCoins) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Instant diedAt = clock.instant();
|
||||
UUID deathId = UUID.randomUUID();
|
||||
@@ -489,7 +654,9 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (old.dead() || expectedRevision != null && !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt);
|
||||
old.loadout(), old.passives(), old.cooldowns(), old.restrictedKills(), true, deathId, diedAt,
|
||||
loseCoins ? BigInteger.ZERO : old.coins(), old.coinsInitialized(), old.rewardReceipts(),
|
||||
loseCoins ? old.coins() : BigInteger.ZERO,old.gear());
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
@@ -525,8 +692,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|| 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(), health, 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.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),BigInteger.ZERO,old.gear());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Plain values only: no Bukkit item deserialization or arbitrary Java YAML tags. */
|
||||
final class CharacterYaml {
|
||||
public final class CharacterYaml {
|
||||
private CharacterYaml() { }
|
||||
|
||||
private static Yaml yaml() {
|
||||
@@ -27,7 +27,7 @@ final class CharacterYaml {
|
||||
return new Yaml(new SafeConstructor(options));
|
||||
}
|
||||
|
||||
static String encode(CharacterRecord record) {
|
||||
public static String encode(CharacterRecord record) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("schema-version", 1);
|
||||
root.put("character-id", record.id().toString());
|
||||
@@ -42,6 +42,7 @@ final class CharacterYaml {
|
||||
root.put("health", record.health().toString());
|
||||
root.put("learned-abilities", record.learned().stream().sorted().toList());
|
||||
root.put("loadout", record.loadout());
|
||||
root.put("learned-passives", record.passives());
|
||||
List<Map<String, String>> cooldowns = new ArrayList<>();
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
@@ -53,6 +54,11 @@ final class CharacterYaml {
|
||||
"y", position.y().toString(), "z", position.z().toString()));
|
||||
});
|
||||
root.put("restricted-kills", restrictedKills);
|
||||
root.put("coins-initialized", record.coinsInitialized());
|
||||
root.put("coins", record.coins().toString());
|
||||
root.put("reward-receipts", new ArrayList<>(record.rewardReceipts().stream().sorted().map(UUID::toString).toList()));
|
||||
root.put("pvp-coin-escrow", record.pvpCoinEscrow().toString());
|
||||
root.put("gear",Map.of("chestplate",record.gear().chestplate().name(),"leggings",record.gear().leggings().name(),"boots",record.gear().boots().name()));
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
@@ -61,7 +67,7 @@ final class CharacterYaml {
|
||||
return yaml().dump(root);
|
||||
}
|
||||
|
||||
static CharacterRecord decode(String input) {
|
||||
public static CharacterRecord decode(String input) {
|
||||
Map<?, ?> root = object(yaml().load(input));
|
||||
if (!number(root.get("schema-version")).equals(BigInteger.ONE)) {
|
||||
throw new IllegalArgumentException("Unsupported character schema");
|
||||
@@ -93,11 +99,20 @@ final class CharacterYaml {
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns, restrictedKills,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")),
|
||||
root.containsKey("learned-passives") ? strings(root.get("learned-passives")) : List.of("", "", ""),
|
||||
cooldowns, restrictedKills, dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null,
|
||||
root.containsKey("coins") ? number(root.get("coins")) : BigInteger.ZERO,
|
||||
root.containsKey("coins-initialized") && bool(root.get("coins-initialized")),
|
||||
root.containsKey("reward-receipts") ? strings(root.get("reward-receipts")).stream()
|
||||
.map(UUID::fromString).collect(java.util.stream.Collectors.toUnmodifiableSet()) : java.util.Set.of(),
|
||||
root.containsKey("pvp-coin-escrow") ? number(root.get("pvp-coin-escrow")) : BigInteger.ZERO,
|
||||
root.containsKey("gear")?gear(root.get("gear")):CharacterGear.none());
|
||||
}
|
||||
|
||||
private static CharacterGear gear(Object value){var gear=object(value);return new CharacterGear(GearTier.valueOf(text(gear.get("chestplate"))),GearTier.valueOf(text(gear.get("leggings"))),GearTier.valueOf(text(gear.get("boots"))));}
|
||||
|
||||
private static Map<?, ?> object(Object value) {
|
||||
if (value instanceof Map<?, ?> map) return map;
|
||||
throw new IllegalArgumentException("Expected YAML mapping");
|
||||
@@ -112,6 +127,11 @@ final class CharacterYaml {
|
||||
return list(value).stream().map(CharacterYaml::text).toList();
|
||||
}
|
||||
|
||||
private static boolean bool(Object value) {
|
||||
if (value instanceof Boolean result) return result;
|
||||
throw new IllegalArgumentException("Expected YAML boolean");
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof String string) return string;
|
||||
throw new IllegalArgumentException("Expected YAML string");
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.CoinRules;
|
||||
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.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication inclusive coin ranges. */
|
||||
public final class CoinSettings implements AutoCloseable {
|
||||
public static final String START_MIN="starting-coins-min", START_MAX="starting-coins-max";
|
||||
public static final String HOSTILE_MIN="hostile-coin-drop-min", HOSTILE_MAX="hostile-coin-drop-max";
|
||||
public static final String BOSS_MIN="boss-coin-drop-min", BOSS_MAX="boss-coin-drop-max";
|
||||
private static final List<String> KEYS=List.of(START_MIN,START_MAX,HOSTILE_MIN,HOSTILE_MAX,BOSS_MIN,BOSS_MAX);
|
||||
private static final Map<String,String> DEFAULTS=Map.of(START_MIN,"1",START_MAX,"6",HOSTILE_MIN,"1",HOSTILE_MAX,"5",BOSS_MIN,"25",BOSS_MAX,"50");
|
||||
public record Update(String key,String value,CoinRules rules,Map<String,String> values,long sequence){public Update{values=Map.copyOf(values);}}
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-coin-configuration"));
|
||||
private volatile Map<String,String> effective;
|
||||
private Map<String,String> persisted;
|
||||
private long savedSequence,publishedSequence;
|
||||
private boolean closed;
|
||||
|
||||
public CoinSettings(Path directory,Map<String,String> initial){
|
||||
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
for(String key:KEYS)values.put(key,Objects.requireNonNull(initial.get(key)));
|
||||
parse(values);effective=Map.copyOf(values);persisted=effective;
|
||||
}
|
||||
public static List<String> keys(){return KEYS;}
|
||||
public static String defaultValue(String key){return DEFAULTS.get(key);}
|
||||
public String value(String key){return effective.get(key);}
|
||||
public CoinRules rules(){return parse(effective);}
|
||||
public static CoinRules parse(Map<String,String> values){
|
||||
try{return new CoinRules(whole(values.get(START_MIN)),whole(values.get(START_MAX)),whole(values.get(HOSTILE_MIN)),whole(values.get(HOSTILE_MAX)),whole(values.get(BOSS_MIN)),whole(values.get(BOSS_MAX)));}
|
||||
catch(RuntimeException invalid){throw new IllegalArgumentException("Coin settings must be ordered whole numbers from 0 through 2147483647",invalid);}
|
||||
}
|
||||
private static long whole(String value){var number=new java.math.BigInteger(value);if(number.signum()<0||number.compareTo(java.math.BigInteger.valueOf(Integer.MAX_VALUE))>0)throw new IllegalArgumentException();return number.longValueExact();}
|
||||
public CompletionStage<Update> set(String key,String raw){
|
||||
if(!KEYS.contains(key))throw new IllegalArgumentException("Unknown coin setting");
|
||||
String accepted;try{accepted=new java.math.BigInteger(raw).toString();}catch(RuntimeException invalid){throw new IllegalArgumentException("Coin setting must be a whole number",invalid);}
|
||||
var preview=new LinkedHashMap<>(effective);preview.put(key,accepted);parse(preview);
|
||||
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));}
|
||||
return CompletableFuture.supplyAsync(()->{try{var next=new LinkedHashMap<>(persisted);next.put(key,accepted);var rules=parse(next);save(key,accepted);persisted=Map.copyOf(next);return new Update(key,accepted,rules,persisted,++savedSequence);}catch(IOException failure){throw new CompletionException(failure);}},worker).minimalCompletionStage();
|
||||
}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;parse(update.values());effective=update.values();publishedSequence=update.sequence();return true;}
|
||||
private void save(String key,String value)throws IOException{
|
||||
Files.createDirectories(directory);
|
||||
var file=directory.resolve("config.yml");
|
||||
var lockFile=directory.resolve(".config.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(1_048_576);
|
||||
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(1_048_577);}if(bytes.length>1_048_576)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");document.put(savedKey,entry.getValue());}}
|
||||
document.put(key,value);byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);Path temporary=Files.createTempFile(directory,".config-",".tmp");try{Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}finally{Files.deleteIfExists(temporary);}
|
||||
}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy",busy);}
|
||||
}
|
||||
@Override public synchronized void close(){closed=true;worker.shutdown();}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/** Filesystem durability boundary used after atomically replacing or deleting acknowledged state. */
|
||||
final class DurableFiles {
|
||||
private DurableFiles() { }
|
||||
static void forceDirectory(Path directory) throws IOException {
|
||||
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.Fireball;
|
||||
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 Fireball settings; publication remains a server-thread caller decision. */
|
||||
public final class FireballSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE_DAMAGE = "fireball-base-damage";
|
||||
public static final String COOLDOWN = "fireball-cooldown-seconds";
|
||||
public static final String RANGE = "fireball-range-blocks";
|
||||
public static final String LIFETIME = "fireball-lifetime-seconds";
|
||||
public static final String SPEED = "fireball-projectile-speed-blocks-per-tick";
|
||||
private static final List<String> KEYS = List.of(BASE_DAMAGE, COOLDOWN, LIFETIME, SPEED, RANGE);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(BASE_DAMAGE, "6", COOLDOWN, "1.2", RANGE, "32",
|
||||
LIFETIME, "5", SPEED, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, Fireball.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-fireball-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 FireballSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FireballSettings(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 Fireball.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(key, value);
|
||||
var preview = new LinkedHashMap<>(effectiveValues); preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Fireball 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 Fireball.Settings parse(Map<String, String> values) {
|
||||
try {
|
||||
return new Fireball.Settings(new java.math.BigInteger(values.get(BASE_DAMAGE)), seconds(values.get(COOLDOWN)),
|
||||
finite(values.get(RANGE)), seconds(values.get(LIFETIME)), finite(values.get(SPEED)));
|
||||
} catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Fireball settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
return Duration.ofNanos(new java.math.BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
private static double finite(String value) {
|
||||
double result = new java.math.BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.FrostNova;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Frost Nova settings; publication remains a server-thread caller decision. */
|
||||
public final class FrostNovaSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String BASE = "frost-nova-base-damage";
|
||||
public static final String COOLDOWN = "frost-nova-cooldown-seconds";
|
||||
public static final String RADIUS = "frost-nova-radius-blocks";
|
||||
public static final String SLOWNESS = "frost-nova-slowness-amplifier";
|
||||
public static final String DURATION = "frost-nova-slowness-duration-seconds";
|
||||
private static final List<String> KEYS = List.of(BASE, COOLDOWN, RADIUS, SLOWNESS, DURATION);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
BASE, "5", COOLDOWN, "12", RADIUS, "5", SLOWNESS, "1", DURATION, "3");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, FrostNova.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-frost-nova-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public FrostNovaSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
FrostNovaSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public FrostNova.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Frost Nova configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static FrostNova.Settings parse(Map<String, String> values) {
|
||||
try { return new FrostNova.Settings(new BigDecimal(values.get(BASE)).toBigIntegerExact(),
|
||||
seconds(values.get(COOLDOWN)), finite(values.get(RADIUS)),
|
||||
new BigDecimal(values.get(SLOWNESS)).intValueExact(), seconds(values.get(DURATION))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Frost Nova settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public final class FrostShardSettings implements AutoCloseable {
|
||||
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");
|
||||
LIFETIME, "5", SPEED, "3", 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,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.shop.ArmorMarketRules;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication bases, variance and tier stock bounds for future catalogs. */
|
||||
public final class GearSettings implements AutoCloseable {
|
||||
public static final String LEATHER="gear-price-leather", COPPER="gear-price-copper",
|
||||
GOLD="gear-price-gold", IRON="gear-price-iron", DIAMOND="gear-price-diamond",
|
||||
NETHERITE="gear-price-netherite", VARIANCE="gear-price-variance-percent";
|
||||
private static final Map<GearTier,String> PRICE_KEYS=Map.of(
|
||||
GearTier.LEATHER,LEATHER, GearTier.COPPER,COPPER, GearTier.GOLD,GOLD,
|
||||
GearTier.IRON,IRON, GearTier.DIAMOND,DIAMOND, GearTier.NETHERITE,NETHERITE);
|
||||
private static final Map<GearTier,String> TIER_NAMES=Map.of(
|
||||
GearTier.LEATHER,"leather", GearTier.COPPER,"copper", GearTier.GOLD,"gold",
|
||||
GearTier.IRON,"iron", GearTier.DIAMOND,"diamond", GearTier.NETHERITE,"netherite");
|
||||
private static final Map<String,String> DEFAULTS=defaults();
|
||||
private static final List<String> KEYS=DEFAULTS.keySet().stream().sorted().toList();
|
||||
|
||||
public record Update(String key,String value,Map<String,String> values,long sequence) {
|
||||
public Update { values=Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final ExecutorService worker=Executors.newSingleThreadExecutor(r->new Thread(r,"fantasy-gear-configuration"));
|
||||
private volatile Map<String,String> effective;
|
||||
private Map<String,String> persisted;
|
||||
private long savedSequence,publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
|
||||
public GearSettings(Path directory,Map<String,String> initial) {
|
||||
this.directory=Objects.requireNonNull(directory).toAbsolutePath();
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
for(String key:KEYS) values.put(key,Objects.requireNonNull(initial.getOrDefault(key,DEFAULTS.get(key))));
|
||||
validate(values);
|
||||
effective=Map.copyOf(values);
|
||||
persisted=effective;
|
||||
}
|
||||
|
||||
public static List<String> keys(){return KEYS;}
|
||||
public static String defaultValue(String key){return DEFAULTS.get(key);}
|
||||
public String value(String key){return effective.get(key);}
|
||||
public int price(GearTier tier){return wholeNonnegative(effective.get(priceKey(tier)));}
|
||||
|
||||
public ArmorMarketRules rules() {
|
||||
var values=effective;
|
||||
var prices=new EnumMap<GearTier,BigInteger>(GearTier.class);
|
||||
var stock=new EnumMap<GearTier,ArmorMarketRules.StockRange>(GearTier.class);
|
||||
for(var tier:PRICE_KEYS.keySet()) {
|
||||
prices.put(tier,new BigInteger(values.get(priceKey(tier))));
|
||||
stock.put(tier,new ArmorMarketRules.StockRange(
|
||||
wholePositive(values.get(stockKey(tier,true))), wholePositive(values.get(stockKey(tier,false)))));
|
||||
}
|
||||
return new ArmorMarketRules(prices,wholeVariance(values.get(VARIANCE)),stock);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key,String raw) {
|
||||
if(!KEYS.contains(key)) throw new IllegalArgumentException("Unknown gear market setting");
|
||||
String accepted;
|
||||
try { accepted=Integer.toString(parseForKey(key,raw)); }
|
||||
catch(RuntimeException invalid) { throw new IllegalArgumentException("Invalid gear market setting",invalid); }
|
||||
var preview=new LinkedHashMap<>(effective);
|
||||
preview.put(key,accepted);
|
||||
validate(preview);
|
||||
synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration closed"));pending++;}
|
||||
return CompletableFuture.supplyAsync(()->{
|
||||
try {
|
||||
var next=new LinkedHashMap<>(persisted);
|
||||
next.put(key,accepted);
|
||||
validate(next);
|
||||
save(key,accepted);
|
||||
persisted=Map.copyOf(next);
|
||||
return new Update(key,accepted,persisted,++savedSequence);
|
||||
} catch(IOException failure) { throw new CompletionException(failure); }
|
||||
finally { finished(); }
|
||||
},worker).minimalCompletionStage();
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
if(update.sequence()<=publishedSequence)return false;
|
||||
validate(update.values());
|
||||
effective=update.values();
|
||||
publishedSequence=update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Map<String,String> defaults() {
|
||||
var values=new LinkedHashMap<String,String>();
|
||||
values.put(LEATHER,"2"); values.put(COPPER,"10"); values.put(GOLD,"25");
|
||||
values.put(IRON,"75"); values.put(DIAMOND,"150"); values.put(NETHERITE,"500");
|
||||
values.put(VARIANCE,"10");
|
||||
int[][] ranges={{25,50},{20,40},{15,30},{10,20},{5,10},{1,3}};
|
||||
GearTier[] tiers={GearTier.LEATHER,GearTier.COPPER,GearTier.GOLD,GearTier.IRON,GearTier.DIAMOND,GearTier.NETHERITE};
|
||||
for(int i=0;i<tiers.length;i++) {
|
||||
values.put(stockKey(tiers[i],true),Integer.toString(ranges[i][0]));
|
||||
values.put(stockKey(tiers[i],false),Integer.toString(ranges[i][1]));
|
||||
}
|
||||
return Map.copyOf(values);
|
||||
}
|
||||
|
||||
private static void validate(Map<String,String> values) {
|
||||
for(var tier:PRICE_KEYS.keySet()) {
|
||||
wholeNonnegative(values.get(priceKey(tier)));
|
||||
int minimum=wholePositive(values.get(stockKey(tier,true)));
|
||||
int maximum=wholePositive(values.get(stockKey(tier,false)));
|
||||
if(minimum>maximum)throw new IllegalArgumentException("Stock minimum exceeds maximum");
|
||||
}
|
||||
wholeVariance(values.get(VARIANCE));
|
||||
}
|
||||
|
||||
private static int parseForKey(String key,String value) {
|
||||
if(key.equals(VARIANCE))return wholeVariance(value);
|
||||
if(key.startsWith("gear-stock-"))return wholePositive(value);
|
||||
return wholeNonnegative(value);
|
||||
}
|
||||
private static int wholeNonnegative(String value){return bounded(value,0,Integer.MAX_VALUE);}
|
||||
private static int wholePositive(String value){return bounded(value,1,Integer.MAX_VALUE);}
|
||||
private static int wholeVariance(String value){return bounded(value,0,100);}
|
||||
private static int bounded(String value,int minimum,int maximum) {
|
||||
var number=new BigInteger(Objects.requireNonNull(value));
|
||||
if(number.compareTo(BigInteger.valueOf(minimum))<0||number.compareTo(BigInteger.valueOf(maximum))>0)
|
||||
throw new IllegalArgumentException();
|
||||
return number.intValueExact();
|
||||
}
|
||||
private static String priceKey(GearTier tier) {
|
||||
var key=PRICE_KEYS.get(tier);
|
||||
if(key==null)throw new IllegalArgumentException("None is not purchasable");
|
||||
return key;
|
||||
}
|
||||
public static String stockKey(GearTier tier,boolean minimum) {
|
||||
var name=TIER_NAMES.get(tier);
|
||||
if(name==null)throw new IllegalArgumentException("None is not purchasable");
|
||||
return "gear-stock-"+name+(minimum?"-min":"-max");
|
||||
}
|
||||
|
||||
private void save(String key,String value)throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
var file=directory.resolve("config.yml");
|
||||
var lockFile=directory.resolve(".config.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(1_048_576);
|
||||
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(1_048_577);}
|
||||
if(bytes.length>1_048_576)throw new IOException("Configuration too large");
|
||||
Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if(loaded instanceof Map<?,?> map)for(var entry:map.entrySet()) {
|
||||
if(!(entry.getKey() instanceof String savedKey))throw new IOException("Invalid key");
|
||||
document.put(savedKey,entry.getValue());
|
||||
}
|
||||
}
|
||||
document.put(key,Integer.parseInt(value));
|
||||
byte[] bytes=yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
Path temporary=Files.createTempFile(directory,".config-",".tmp");
|
||||
try {
|
||||
Files.write(temporary,bytes,StandardOpenOption.TRUNCATE_EXISTING);
|
||||
try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE)){output.force(true);}
|
||||
Files.move(temporary,file,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch(OverlappingFileLockException busy) { throw new IOException("Configuration busy",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,4 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
/** Purchasable character armor slots; head is intentionally absent and remains cosmetic-only. */
|
||||
public enum GearSlot { CHESTPLATE, LEGGINGS, BOOTS }
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
/** Strict reviewed armor progression; enum order is authoritative for upgrade comparisons. */
|
||||
public enum GearTier {
|
||||
NONE, LEATHER, COPPER, GOLD, IRON, DIAMOND, NETHERITE;
|
||||
public boolean higherThan(GearTier current){return ordinal()>current.ordinal();}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.ability.GuardianBond;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Serialized durable Guardian Bond settings; publication remains a server-thread caller decision. */
|
||||
public final class GuardianBondSettings implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
public static final String COOLDOWN = "guardian-bond-cooldown-seconds";
|
||||
public static final String RANGE = "guardian-bond-range-blocks";
|
||||
public static final String DURATION = "guardian-bond-duration-seconds";
|
||||
public static final String REDIRECT = "guardian-bond-redirect-percent";
|
||||
private static final List<String> KEYS = List.of(COOLDOWN, DURATION, RANGE, REDIRECT);
|
||||
private static final Map<String, String> DEFAULTS = Map.of(
|
||||
COOLDOWN, "20", RANGE, "10", DURATION, "6", REDIRECT, "30");
|
||||
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(String key, String value, GuardianBond.Settings settings, Map<String, String> values, long sequence) {
|
||||
public Update { values = Map.copyOf(values); }
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-guardian-bond-configuration"));
|
||||
private volatile Map<String, String> effectiveValues;
|
||||
private Map<String, String> persistedValues;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public GuardianBondSettings(Path directory, Map<String, String> initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
GuardianBondSettings(Path directory, Map<String, String> initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
var copy = new LinkedHashMap<String, String>();
|
||||
for (String key : KEYS) copy.put(key, Objects.requireNonNull(initial.get(key)));
|
||||
parse(copy); effectiveValues = Map.copyOf(copy); persistedValues = Map.copyOf(copy);
|
||||
}
|
||||
|
||||
public static List<String> keys() { return KEYS; }
|
||||
public static String defaultValue(String key) { return DEFAULTS.get(key); }
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
Objects.requireNonNull(key); Objects.requireNonNull(value);
|
||||
if (!KEYS.contains(key)) return CompletableFuture.failedStage(new IllegalArgumentException("Unknown setting"));
|
||||
String accepted = canonical(value); var preview = new LinkedHashMap<>(effectiveValues);
|
||||
preview.put(key, accepted); parse(preview);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
var next = new LinkedHashMap<>(persistedValues); next.put(key, accepted);
|
||||
var parsed = parse(next); save(key, accepted); persistedValues = Map.copyOf(next);
|
||||
return new Update(key, accepted, parsed, persistedValues, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Guardian Bond configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) {
|
||||
try { return new BigDecimal(raw).stripTrailingZeros().toPlainString(); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Invalid value", invalid); }
|
||||
}
|
||||
|
||||
public static GuardianBond.Settings parse(Map<String, String> values) {
|
||||
try { return new GuardianBond.Settings(seconds(values.get(COOLDOWN)), finite(values.get(RANGE)),
|
||||
seconds(values.get(DURATION)), new BigDecimal(values.get(REDIRECT))); }
|
||||
catch (ArithmeticException | NumberFormatException | NullPointerException invalid) {
|
||||
throw new IllegalArgumentException("Guardian Bond settings must use supported numeric values", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static Duration seconds(String value) {
|
||||
var nanos = new BigDecimal(value).movePointRight(9).toBigIntegerExact().longValueExact();
|
||||
return Duration.ofNanos(nanos);
|
||||
}
|
||||
|
||||
private static double finite(String value) {
|
||||
double result = new BigDecimal(value).doubleValue();
|
||||
if (!Double.isFinite(result)) throw new IllegalArgumentException("Setting is not finite");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unsafe lock");
|
||||
}
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS); var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> document = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String savedKey)) throw new IOException("Invalid configuration key");
|
||||
document.put(savedKey, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(key, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.*;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publication Witch hard-mob XP pool. */
|
||||
public final class HardMobXpSettings implements AutoCloseable {
|
||||
public static final String KEY = "hard-mob-xp";
|
||||
public static final String DEFAULT = "20";
|
||||
private static final BigInteger MAXIMUM = BigInteger.valueOf(Integer.MAX_VALUE);
|
||||
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, BigInteger pool, long sequence) { }
|
||||
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-hard-mob-xp-configuration"));
|
||||
private volatile String effective;
|
||||
private String persisted;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HardMobXpSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
HardMobXpSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
|
||||
public static BigInteger parse(String raw) {
|
||||
try {
|
||||
var value = new BigInteger(raw);
|
||||
if (value.signum() < 0 || value.compareTo(MAXIMUM) > 0) throw new IllegalArgumentException();
|
||||
return value;
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IllegalArgumentException("Hard-mob XP must be a whole number from 0 through 2147483647", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static String canonical(String raw) { return parse(raw).toString(); }
|
||||
public String value() { return effective; }
|
||||
public BigInteger pool() { return parse(effective); }
|
||||
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(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(accepted, parse(accepted), ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new CompletionException(failure instanceof IllegalArgumentException ? failure
|
||||
: new IOException("Hard-mob XP save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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,113 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.passive.HealthSteal;
|
||||
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-publication Health Steal balance. */
|
||||
public final class HealthStealSettings implements AutoCloseable {
|
||||
public static final String KEY = "health-steal-healing-per-kill";
|
||||
public static final String DEFAULT = "1";
|
||||
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, HealthSteal.Settings settings, long sequence) { }
|
||||
private final Path directory; private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-health-steal-configuration"));
|
||||
private volatile String effective; private String persisted; private long persistedSequence, publishedSequence;
|
||||
private boolean closed; private int pending; private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public HealthStealSettings(Path directory, String initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
HealthStealSettings(Path directory, String initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath(); this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = canonical(initial); persisted = effective;
|
||||
}
|
||||
public static HealthSteal.Settings parse(String raw) {
|
||||
try { return new HealthSteal.Settings(new java.math.BigInteger(raw)); }
|
||||
catch (NumberFormatException invalid) { throw new IllegalArgumentException("Bonus must be a whole number", invalid); }
|
||||
}
|
||||
private static String canonical(String raw) { var parsed = parse(raw); return parsed.healing().toString(); }
|
||||
public String value() { return effective; }
|
||||
public HealthSteal.Settings settings() { return parse(effective); }
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
String accepted = canonical(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(accepted, parse(accepted), ++persistedSequence); }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure instanceof IllegalArgumentException
|
||||
? failure : new IOException("Health Steal configuration save failed; effective value unchanged", failure));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished(); return CompletableFuture.failedStage(new IOException("Configuration store is closed", rejected));
|
||||
}
|
||||
}
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = canonical(update.value()); publishedSequence = update.sequence(); return true;
|
||||
}
|
||||
private void save(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 key)) throw new IOException("Invalid configuration key");
|
||||
document.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
document.put(KEY, value); byte[] bytes = yaml.dump(document).getBytes(StandardCharsets.UTF_8);
|
||||
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", 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); }
|
||||
}
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user