Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58c192358 | ||
|
|
0fcb1e7e9c | ||
|
|
5d574dc6e9 | ||
|
|
a71262f115 | ||
|
|
8119673c9f | ||
|
|
cc5300bb03 | ||
|
|
293d28ca29 | ||
|
|
c530576262 |
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 }}
|
||||
|
||||
+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,6 +29,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private ManualCraftingController crafting;
|
||||
private ManualCraftingGuard guard;
|
||||
private CharacterAnvilNames anvilNames;
|
||||
private RecipeDiscovery recipeDiscovery;
|
||||
private boolean recipeRegistered;
|
||||
private EquipmentSessionStore receipts;
|
||||
private EquipmentSessions sessions;
|
||||
@@ -71,6 +74,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.ability.GreatSword greatSword;
|
||||
private games.dmg.fantasystick.ability.Revive revive;
|
||||
private games.dmg.fantasystick.persistence.RevivalStore revivals;
|
||||
private games.dmg.fantasystick.session.AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
private games.dmg.fantasystick.session.NutritionPolicy nutritionPolicy;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -94,7 +104,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
reloadConfig();
|
||||
var adventure = games.dmg.fantasystick.persistence.AdventureSettings.validate(getConfig().get("adventure-mode"));
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = switchDelay();
|
||||
var switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
|
||||
var roleplayDisabled = games.dmg.fantasystick.persistence.RoleplaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.RoleplaySettings.KEY));
|
||||
var partyMaximum = games.dmg.fantasystick.persistence.PartySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.PartySettings.KEY));
|
||||
var sheetPackDefaults = games.dmg.fantasystick.persistence.SheetResourcePackSettings.packagedDefaults();
|
||||
sheetResourcePackSettings = new games.dmg.fantasystick.persistence.SheetResourcePackSettings(getDataFolder().toPath(),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY),
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY), sheetPackDefaults);
|
||||
var rules = progressionRules();
|
||||
var rewardRules = combatRewardRules();
|
||||
var fireballSettings = fireballSettings();
|
||||
@@ -115,6 +134,8 @@ 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 adminReviveTimeout = games.dmg.fantasystick.persistence.AdminReviveSettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY));
|
||||
var factory = new CharacterFactory();
|
||||
characters = new CharacterStorage(new YamlCharacterRepository(getDataFolder().toPath()), Clock.systemUTC());
|
||||
crafting = new ManualCraftingController(characters, () -> factory.create(rules),
|
||||
@@ -151,12 +172,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getLogger().log(Level.WARNING, "Equipment transition/recovery for player " + problem.player()
|
||||
+ "; session " + problem.session(), problem.cause());
|
||||
gameModes = new games.dmg.fantasystick.session.AdventureModeLease();
|
||||
switchDelaySettings = new games.dmg.fantasystick.persistence.SwitchDelaySettings(getDataFolder().toPath(), switchDelay);
|
||||
roleplaySettings = new games.dmg.fantasystick.persistence.RoleplaySettings(getDataFolder().toPath(), roleplayDisabled);
|
||||
partySettings = new games.dmg.fantasystick.persistence.PartySettings(getDataFolder().toPath(), partyMaximum);
|
||||
sheetResourcePack = new games.dmg.fantasystick.presentation.SheetResourcePack(sheetResourcePackSettings,
|
||||
(player, failure) -> getLogger().log(Level.WARNING, "Sheet resource-pack delivery failed for " + player, failure));
|
||||
sessions = new EquipmentSessions(characters, receipts, switchDelay, Clock.systemUTC(), nextTick,
|
||||
notice -> notifyPlayer(generation, notice), diagnostics, presentation, adventureSettings::enabled, gameModes);
|
||||
sessions.attachSwitchDelay(switchDelaySettings::delay);
|
||||
sessions.attachRoleplayDisabled(roleplaySettings::disabled);
|
||||
parties = new games.dmg.fantasystick.party.PartyRoster(Clock.systemUTC(), id -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
return player != null && player.isOnline();
|
||||
});
|
||||
}, partySettings::maximum);
|
||||
partyEvents = new games.dmg.fantasystick.party.PartyEvents(parties);
|
||||
partyCombat = new games.dmg.fantasystick.party.PartyCombat(parties, sessions);
|
||||
combatExperience = new games.dmg.fantasystick.party.CombatExperience(characters, rules, rewardRules,
|
||||
@@ -220,6 +248,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var task = getServer().getScheduler().runTaskLater(this, action, ticks);
|
||||
return task::cancel;
|
||||
};
|
||||
adminReviveSettings = new games.dmg.fantasystick.persistence.AdminReviveSettings(getDataFolder().toPath(), adminReviveTimeout);
|
||||
adminRevival = new games.dmg.fantasystick.session.AdminRevival(characters, revivals, Clock.systemUTC(), nextTick,
|
||||
abilityScheduler::later, (size, title) -> getServer().createInventory(null, size, title),
|
||||
problem -> getLogger().log(Level.WARNING, "Admin revival failed", problem));
|
||||
abilityEngine = new games.dmg.fantasystick.ability.AbilityEngine(abilityRegistry, characters, sessions, parties,
|
||||
Clock.systemUTC(), nextTick, abilityScheduler, report -> { },
|
||||
problem -> getLogger().log(Level.WARNING, "Ability engine failed for character " + problem.character()
|
||||
@@ -247,6 +279,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
quickstepSettings, quickstep, secondWindSettings, secondWind,
|
||||
stoneSkinSettings, stoneSkin, shieldBashSettings, shieldBash, hexSettings, hex,
|
||||
inspireSettings, inspire, abilityRegistry);
|
||||
admin.attachAdminRevival(adminRevival, adminReviveSettings);
|
||||
admin.attachSwitchDelay(switchDelaySettings);
|
||||
admin.attachRoleplaySettings(roleplaySettings);
|
||||
admin.attachPartySettings(partySettings);
|
||||
admin.attachSheetResourcePack(sheetResourcePackSettings, sheetResourcePack);
|
||||
admin.attachCharacterIssuer(new games.dmg.fantasystick.session.AdminCharacterIssuer(characters,
|
||||
() -> factory.create(rules), nextTick, problem -> getLogger().log(Level.WARNING,
|
||||
"Admin character issue " + problem.operation() + " for player " + problem.player()
|
||||
+ "; character " + problem.character(), problem.cause())));
|
||||
var command = java.util.Objects.requireNonNull(getCommand("fantasystick"), "Missing packaged fantasystick command");
|
||||
command.setExecutor(admin);
|
||||
command.setTabCompleter(admin);
|
||||
@@ -270,16 +311,23 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(temporaryHealth, this);
|
||||
getServer().getPluginManager().registerEvents(greatSword, this);
|
||||
getServer().getPluginManager().registerEvents(revive, this);
|
||||
getServer().getPluginManager().registerEvents(adminRevival, this);
|
||||
getServer().getPluginManager().registerEvents(localBasicAttack, this);
|
||||
getServer().getPluginManager().registerEvents(nutritionPolicy, this);
|
||||
getServer().getPluginManager().registerEvents(books, this);
|
||||
getServer().getPluginManager().registerEvents(gameModes, this);
|
||||
getServer().getPluginManager().registerEvents(sheetResourcePack, this);
|
||||
sessionVisuals = new games.dmg.fantasystick.session.SessionVisuals(sessions,
|
||||
failure -> getLogger().log(Level.WARNING, "Fantasy session particle delivery failed", failure));
|
||||
sessionVisuals.install(this);
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
for (var player : getServer().getOnlinePlayers()) { sessions.arrive(player); 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); }
|
||||
@@ -302,6 +350,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingCrafting = crafting; crafting = null;
|
||||
var closingGuard = guard; guard = null;
|
||||
var closingAnvil = anvilNames; anvilNames = null;
|
||||
var closingRecipeDiscovery = recipeDiscovery; recipeDiscovery = null;
|
||||
var closingSessions = sessions; sessions = null;
|
||||
var closingReceipts = receipts; receipts = null;
|
||||
var closingCharacters = characters; characters = null;
|
||||
@@ -325,6 +374,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingHex = hex; hex = null;
|
||||
var closingInspire = inspire; inspire = null;
|
||||
var closingInspireSettings = inspireSettings; inspireSettings = null;
|
||||
var closingAdminRevival = adminRevival; adminRevival = null;
|
||||
var closingAdminReviveSettings = adminReviveSettings; adminReviveSettings = null;
|
||||
var closingSwitchDelaySettings = switchDelaySettings; switchDelaySettings = null;
|
||||
var closingRoleplaySettings = roleplaySettings; roleplaySettings = null;
|
||||
var closingPartySettings = partySettings; partySettings = null;
|
||||
var closingSheetResourcePackSettings = sheetResourcePackSettings; sheetResourcePackSettings = null;
|
||||
var closingSheetResourcePack = sheetResourcePack; sheetResourcePack = null;
|
||||
var closingTemporaryHealth = temporaryHealth; temporaryHealth = null;
|
||||
var closingHexSettings = hexSettings; hexSettings = null;
|
||||
var closingOutgoingDamageMitigation = outgoingDamageMitigation; outgoingDamageMitigation = null;
|
||||
@@ -350,6 +406,12 @@ 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 (closingAdminReviveSettings != null) cleanup("admin revival configuration", closingAdminReviveSettings::close);
|
||||
if (closingSwitchDelaySettings != null) cleanup("switch delay configuration", closingSwitchDelaySettings::close);
|
||||
if (closingRoleplaySettings != null) cleanup("roleplay configuration", closingRoleplaySettings::close);
|
||||
if (closingPartySettings != null) cleanup("party configuration", closingPartySettings::close);
|
||||
if (closingSheetResourcePackSettings != null) cleanup("sheet resource-pack configuration", closingSheetResourcePackSettings::close);
|
||||
if (closingSheetResourcePack != null) cleanup("sheet resource-pack", closingSheetResourcePack::close);
|
||||
configurationDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
closingSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSettings.drained().toCompletableFuture(),
|
||||
@@ -368,8 +430,19 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingHexSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingHexSettings.drained().toCompletableFuture(),
|
||||
closingInspireSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingInspireSettings.drained().toCompletableFuture());
|
||||
: closingInspireSettings.drained().toCompletableFuture(),
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained().toCompletableFuture(),
|
||||
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingRoleplaySettings.drained().toCompletableFuture(),
|
||||
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingPartySettings.drained().toCompletableFuture(),
|
||||
closingSheetResourcePackSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSheetResourcePackSettings.drained().toCompletableFuture());
|
||||
if (closingBooks != null) cleanup("character book", closingBooks::close);
|
||||
if (closingAdminRevival != null) cleanup("admin revival", closingAdminRevival::close);
|
||||
if (closingBasicAttack != null) cleanup("basic attack", closingBasicAttack::close);
|
||||
if (closingCombatExperience != null) cleanup("combat experience", closingCombatExperience::close);
|
||||
if (closingFireball != null) cleanup("Fireball", closingFireball::close);
|
||||
@@ -409,6 +482,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingTemporaryHealth);
|
||||
unregister(closingGreatSword);
|
||||
unregister(closingRevive);
|
||||
unregister(closingAdminRevival);
|
||||
unregister(closingBasicAttack);
|
||||
unregister(closingNutrition);
|
||||
unregister(closingBooks);
|
||||
@@ -416,6 +490,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
unregister(closingCrafting);
|
||||
unregister(closingGuard);
|
||||
unregister(closingAnvil);
|
||||
unregister(closingRecipeDiscovery);
|
||||
if (recipeRegistered) cleanup("character-sheet recipe", () -> {
|
||||
getServer().removeRecipe(CharacterSheets.RECIPE);
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
@@ -568,24 +643,6 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private java.time.Duration switchDelay() {
|
||||
Object configured = getConfig().get("character-swap-cooldown-seconds", 900);
|
||||
String value = String.valueOf(configured);
|
||||
if (!value.matches("[0-9]+")) {
|
||||
throw new IllegalArgumentException("character-swap-cooldown-seconds must be a nonnegative integer");
|
||||
}
|
||||
try {
|
||||
var delay = java.time.Duration.ofSeconds(new java.math.BigInteger(value).longValueExact());
|
||||
var deadline = java.time.Instant.now().plus(delay);
|
||||
if (deadline.atOffset(java.time.ZoneOffset.UTC).getYear() > 9999) {
|
||||
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the persisted UTC timestamp range");
|
||||
}
|
||||
return delay;
|
||||
} catch (ArithmeticException | java.time.DateTimeException invalid) {
|
||||
throw new IllegalArgumentException("character-swap-cooldown-seconds exceeds the supported duration range", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyPlayer(Object generation, EquipmentSessions.Notice notice) {
|
||||
Runnable delivery = () -> {
|
||||
if (lifetime != generation) return;
|
||||
|
||||
@@ -351,7 +351,9 @@ public final class Revive implements AbilityEffect, Listener, AutoCloseable {
|
||||
target.getInventory().setItem(reservation.sourceSlot(), remaining); target.updateInventory();
|
||||
}
|
||||
var nativeLimit = games.dmg.fantasystick.session.NativeHealthLimit.read();
|
||||
characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit)
|
||||
(reservation.administrative()
|
||||
? characters.restoreDeathRecordAdministratively(reservation.character(), reservation.death(), nativeLimit)
|
||||
: characters.restoreDeathRecord(reservation.character(), reservation.death(), nativeLimit))
|
||||
.whenComplete((restored, failure) -> schedule(() -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (restored.isPresent() && installFinalSheet(target, restored.orElseThrow())) {
|
||||
|
||||
@@ -69,12 +69,12 @@ public final class PartyBookControls implements AutoCloseable {
|
||||
} else {
|
||||
var group = party.orElseThrow();
|
||||
boolean leader = group.leader().equals(owner);
|
||||
summary = summary.append(Component.text(group.members().size() + "/6 members\n\n"))
|
||||
summary = summary.append(Component.text(group.members().size() + "/" + parties.maximumSize() + " members\n\n"))
|
||||
.append(build.button("Leave party", () -> mutate(input, () -> parties.leave(owner, group.id()))));
|
||||
if (leader) {
|
||||
summary = summary.append(Component.newline())
|
||||
.append(build.button("Disband party", () -> mutate(input, () -> parties.disband(owner, group.id()))));
|
||||
if (group.members().size() < 6) summary = summary.append(Component.newline())
|
||||
if (group.members().size() < parties.maximumSize()) summary = summary.append(Component.newline())
|
||||
.append(build.button("Invite players", () -> show(input, Menu.PLAYERS, 0)));
|
||||
}
|
||||
build.pages.add(summary);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
private final NamespacedKey recipe;
|
||||
private final Logger logger;
|
||||
|
||||
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
|
||||
this.recipe = Objects.requireNonNull(recipe, "recipe");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
for (var player : players) discover(player);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
try {
|
||||
if (player.hasDiscoveredRecipe(recipe)) return;
|
||||
player.discoverRecipes(List.of(recipe));
|
||||
if (!player.hasDiscoveredRecipe(recipe)) {
|
||||
logger.warning("Could not discover character-sheet recipe for " + label(player));
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String label(Player player) {
|
||||
try { return player.getName(); }
|
||||
catch (RuntimeException ignored) { return "an unidentified player"; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.bukkit.persistence.PersistentDataType;
|
||||
/** Sheet presentation/identity only; saved records and session eligibility remain authoritative. */
|
||||
public final class CharacterSheets {
|
||||
public static final NamespacedKey RECIPE = new NamespacedKey("fantasystick", "character_sheet");
|
||||
public static final NamespacedKey MODEL = new NamespacedKey("fantasystick", "character_sheet");
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "sheet_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
|
||||
@@ -26,15 +27,18 @@ public final class CharacterSheets {
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Definition only: a future craft-completion adapter must persist and mint each actual result. */
|
||||
public ShapelessRecipe recipe() {
|
||||
return new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
var recipe = new ShapelessRecipe(RECIPE, paper("Character sheet"))
|
||||
.addIngredient(Material.STICK).addIngredient(Material.PAPER)
|
||||
.addIngredient(Material.FEATHER).addIngredient(Material.INK_SAC);
|
||||
recipe.setCategory(org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static ItemStack paper(String name) {
|
||||
@@ -46,6 +50,16 @@ public final class CharacterSheets {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Adds only the cosmetic model association to an already authentic sheet. */
|
||||
public boolean applyModel(ItemStack item) {
|
||||
if (identify(item).isEmpty()) return false;
|
||||
var meta = item.getItemMeta();
|
||||
if (MODEL.equals(meta.getItemModel())) return true;
|
||||
meta.setItemModel(MODEL);
|
||||
item.setItemMeta(meta);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A reference, not authorization to activate: the caller must load saved state and claim the session. */
|
||||
public Optional<UUID> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.PAPER || !item.hasItemMeta()) return Optional.empty();
|
||||
|
||||
@@ -25,10 +25,19 @@ public final class PartyRoster implements AutoCloseable {
|
||||
private boolean closed;
|
||||
private final Clock clock;
|
||||
private final Predicate<UUID> online;
|
||||
private final java.util.function.IntSupplier maximumSize;
|
||||
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) {
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online) { this(clock, online, () -> 6); }
|
||||
public PartyRoster(Clock clock, Predicate<UUID> online, java.util.function.IntSupplier maximumSize) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.online = Objects.requireNonNull(online);
|
||||
this.maximumSize = Objects.requireNonNull(maximumSize);
|
||||
maximumSize();
|
||||
}
|
||||
public int maximumSize() {
|
||||
int value = maximumSize.getAsInt();
|
||||
if (value < 1) throw new IllegalStateException("Party maximum must be positive");
|
||||
return value;
|
||||
}
|
||||
public Optional<Party> create(UUID player) {
|
||||
if (closed || !connected(player) || membership.containsKey(player)) return Optional.empty();
|
||||
@@ -45,7 +54,7 @@ public final class PartyRoster implements AutoCloseable {
|
||||
public Optional<Invitation> invite(UUID leader, UUID party, UUID invitee) {
|
||||
expire();
|
||||
var group = managedBy(leader, party);
|
||||
if (group == null || group.members().size() >= 6 || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
|
||||
if (group == null || group.members().size() >= maximumSize() || !connected(invitee) || membership.containsKey(invitee)) return Optional.empty();
|
||||
var existing = invitations.values().stream().filter(i -> i.party().equals(party) && i.invitee().equals(invitee)).findFirst();
|
||||
if (existing.isPresent()) return existing;
|
||||
var invitation = new Invitation(UUID.randomUUID(), party, leader, invitee, clock.instant().plusSeconds(60));
|
||||
@@ -57,7 +66,7 @@ public final class PartyRoster implements AutoCloseable {
|
||||
var offer = invitations.get(invitation);
|
||||
if (offer == null || !offer.invitee().equals(player) || !connected(player) || membership.containsKey(player)) return false;
|
||||
var group = membership.get(offer.leader());
|
||||
if (group == null || group.members().size() >= 6 || !group.id().equals(offer.party())) return false;
|
||||
if (group == null || group.members().size() >= maximumSize() || !group.id().equals(offer.party())) return false;
|
||||
var members = new ArrayList<>(group.members());
|
||||
members.add(player);
|
||||
var updated = new Party(group.id(), group.leader(), members);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for later administrator revival selections. */
|
||||
public final class AdminReviveSettings implements AutoCloseable {
|
||||
public static final String KEY="admin-revive-selection-timeout-seconds";public static final String DEFAULT="60";
|
||||
private static final int MAX_BYTES=1_048_576;private static final long MAX_SECONDS=365L*24*60*60;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,Duration timeout,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-admin-revive-configuration"));
|
||||
private volatile Duration effective;private Duration persisted;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public AdminReviveSettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
AdminReviveSettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);persisted=effective;}
|
||||
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(60);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be positive whole seconds",invalid);}}
|
||||
private static Duration validate(Duration value){long seconds;try{seconds=value.toSeconds();}catch(ArithmeticException invalid){throw new IllegalArgumentException("Invalid admin revival timeout",invalid);}if(value.isZero()||value.isNegative()||value.getNano()!=0||seconds>MAX_SECONDS)throw new IllegalArgumentException(KEY+" must be positive whole seconds through 365 days");return value;}
|
||||
public Duration timeout(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
|
||||
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);persisted=accepted;return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Admin revival configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.timeout());publishedSequence=update.sequence();}
|
||||
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -502,8 +502,18 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
/** Native Revive supplies the current plugin-controlled health ceiling captured on the server thread. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
return restore(characterId, deathId, nativeLimit, true);
|
||||
}
|
||||
|
||||
/** Administrator revival retains death identity/consume-once checks but deliberately ignores death age. */
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecordAdministratively(
|
||||
UUID characterId, UUID deathId, java.math.BigDecimal nativeLimit) {
|
||||
return restore(characterId, deathId, nativeLimit, false);
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<CharacterRecord>> restore(UUID characterId, UUID deathId,
|
||||
java.math.BigDecimal nativeLimit, boolean ageLimited) {
|
||||
Objects.requireNonNull(characterId); Objects.requireNonNull(deathId);
|
||||
if (nativeLimit != null && nativeLimit.signum() <= 0) throw new IllegalArgumentException("Invalid revival health limit");
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
@@ -511,9 +521,8 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
Duration age = Duration.between(old.diedAt(), selectedAt);
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative()
|
||||
|| ageLimited && age.compareTo(Duration.ofHours(24)) >= 0) return Optional.empty();
|
||||
var health = nativeLimit == null ? old.maximumHealth() : old.maximumHealth().min(nativeLimit);
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for party capacity checks and book presentation. */
|
||||
public final class PartySettings implements AutoCloseable {
|
||||
public static final String KEY="party-max-size";public static final String DEFAULT="6";
|
||||
private static final int MAX_BYTES=1_048_576;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,int maximum,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
|
||||
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
|
||||
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
|
||||
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
|
||||
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
|
||||
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -29,10 +29,16 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
public final class RevivalStore implements AutoCloseable {
|
||||
private static final int MAX_FILE_BYTES = 32_768;
|
||||
private static final int MAX_FILES = 4096;
|
||||
private static final Set<String> KEYS = Set.of("schema-version", "reservation-id", "caster-character-id",
|
||||
private static final Set<String> KEYS_V1 = Set.of("schema-version", "reservation-id", "caster-character-id",
|
||||
"target-player-id", "character-id", "death-id", "created-at", "source-slot", "source-amount", "destination-slot");
|
||||
private static final Set<String> KEYS_V2 = java.util.stream.Stream.concat(KEYS_V1.stream(), java.util.stream.Stream.of("administrative"))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
public record Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot, boolean administrative) {
|
||||
public Reservation(UUID id, UUID casterCharacter, UUID targetPlayer, UUID character, UUID death,
|
||||
Instant createdAt, int sourceSlot, int sourceAmount, int destinationSlot) {
|
||||
this(id, casterCharacter, targetPlayer, character, death, createdAt, sourceSlot, sourceAmount, destinationSlot, false);
|
||||
}
|
||||
public Reservation {
|
||||
Objects.requireNonNull(id); Objects.requireNonNull(casterCharacter); Objects.requireNonNull(targetPlayer);
|
||||
Objects.requireNonNull(character); Objects.requireNonNull(death); Objects.requireNonNull(createdAt);
|
||||
@@ -121,13 +127,13 @@ public final class RevivalStore implements AutoCloseable {
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(0);
|
||||
decoded = new Yaml(new SafeConstructor(options)).load(Files.readString(file, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !raw.keySet().equals(KEYS)) throw new IOException("Invalid revival reservation keys");
|
||||
if (!(decoded instanceof Map<?, ?> raw) || !(raw.keySet().equals(KEYS_V1) || raw.keySet().equals(KEYS_V2))) throw new IOException("Invalid revival reservation keys");
|
||||
try {
|
||||
if (!"1".equals(text(raw, "schema-version"))) throw new IllegalArgumentException("schema");
|
||||
String schema=text(raw,"schema-version");if(!(schema.equals("1")&&raw.keySet().equals(KEYS_V1)||schema.equals("2")&&raw.keySet().equals(KEYS_V2)))throw new IllegalArgumentException("schema");
|
||||
var value = new Reservation(uuid(raw, "reservation-id"), uuid(raw, "caster-character-id"),
|
||||
uuid(raw, "target-player-id"), uuid(raw, "character-id"), uuid(raw, "death-id"),
|
||||
Instant.parse(text(raw, "created-at")), integer(raw, "source-slot"), integer(raw, "source-amount"),
|
||||
integer(raw, "destination-slot"));
|
||||
integer(raw, "destination-slot"), schema.equals("2") && bool(raw,"administrative"));
|
||||
if (!file.getFileName().toString().equals(value.character() + ".yml")) throw new IllegalArgumentException("identity");
|
||||
return Optional.of(value);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid revival reservation", invalid); }
|
||||
@@ -135,11 +141,12 @@ public final class RevivalStore implements AutoCloseable {
|
||||
|
||||
private static String encode(Reservation value) {
|
||||
var map = new LinkedHashMap<String, Object>();
|
||||
map.put("schema-version", "1"); map.put("reservation-id", value.id().toString());
|
||||
map.put("schema-version", "2"); map.put("reservation-id", value.id().toString());
|
||||
map.put("caster-character-id", value.casterCharacter().toString()); map.put("target-player-id", value.targetPlayer().toString());
|
||||
map.put("character-id", value.character().toString()); map.put("death-id", value.death().toString());
|
||||
map.put("created-at", value.createdAt().toString()); map.put("source-slot", Integer.toString(value.sourceSlot()));
|
||||
map.put("source-amount", Integer.toString(value.sourceAmount())); map.put("destination-slot", Integer.toString(value.destinationSlot()));
|
||||
map.put("administrative", Boolean.toString(value.administrative()));
|
||||
return new Yaml().dump(map);
|
||||
}
|
||||
|
||||
@@ -148,6 +155,7 @@ public final class RevivalStore implements AutoCloseable {
|
||||
}
|
||||
private static UUID uuid(Map<?, ?> map, String key) { var value = UUID.fromString(text(map, key)); if (!value.toString().equals(text(map, key))) throw new IllegalArgumentException(key); return value; }
|
||||
private static int integer(Map<?, ?> map, String key) { return Integer.parseInt(text(map, key)); }
|
||||
private static boolean bool(Map<?,?> map,String key){String value=text(map,key);if(!value.equals("true")&&!value.equals("false"))throw new IllegalArgumentException(key);return Boolean.parseBoolean(value);}
|
||||
|
||||
@FunctionalInterface private interface Work<T> { T run() throws IOException; }
|
||||
private <T> CompletableFuture<T> submit(Work<T> work) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for roleplay availability and coordinated exits. */
|
||||
public final class RoleplaySettings implements AutoCloseable {
|
||||
public static final String KEY = "roleplay-disabled";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Update(boolean disabled, long sequence) { }
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-roleplay-configuration"));
|
||||
private volatile boolean disabled;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public RoleplaySettings(Path directory, boolean initial) {
|
||||
this(directory, initial, (from, to) -> Files.move(from, to,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
RoleplaySettings(Path directory, boolean initial, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
disabled = initial;
|
||||
}
|
||||
|
||||
public static boolean parse(Object value) {
|
||||
if (value == null) return false;
|
||||
if (value instanceof Boolean parsed) return parsed;
|
||||
throw new IllegalArgumentException(KEY + " must be true or false");
|
||||
}
|
||||
|
||||
public boolean disabled() { return disabled; }
|
||||
public String value() { return Boolean.toString(disabled); }
|
||||
|
||||
public CompletionStage<Update> set(String raw) {
|
||||
if (!"true".equals(raw) && !"false".equals(raw)) {
|
||||
throw new IllegalArgumentException(KEY + " must be true or false");
|
||||
}
|
||||
boolean accepted = Boolean.parseBoolean(raw);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(accepted);
|
||||
return new Update(accepted, ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Roleplay configuration save refused or failed; effective setting unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
disabled = update.disabled();
|
||||
publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(20);
|
||||
options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes;
|
||||
try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
}
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key)) throw new IOException("Invalid configuration key");
|
||||
values.put(key, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put(KEY, value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() {
|
||||
pending--;
|
||||
if (closed && pending == 0) drained.complete(null);
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
|
||||
@Override public synchronized void close() {
|
||||
closed = true;
|
||||
worker.shutdown();
|
||||
if (pending == 0) drained.complete(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable paired resource-pack overrides; each publication carries the latest complete saved snapshot. */
|
||||
public final class SheetResourcePackSettings implements AutoCloseable {
|
||||
public static final String URL_KEY = "sheet-resource-pack-url";
|
||||
public static final String SHA1_KEY = "sheet-resource-pack-sha1";
|
||||
private static final int MAX_BYTES = 1_048_576;
|
||||
@FunctionalInterface interface Replacement { void move(Path from, Path to) throws IOException; }
|
||||
public record Defaults(String url, String sha1) {
|
||||
public Defaults { url = effectiveUrl(url, new DefaultsMarker()); sha1 = normalizeHash(sha1, false); }
|
||||
}
|
||||
private static final class DefaultsMarker { }
|
||||
public record Snapshot(String configuredUrl, String configuredSha1, String url, String sha1) { }
|
||||
public record Update(Snapshot snapshot, long sequence) { }
|
||||
private final Path directory;
|
||||
private final Defaults defaults;
|
||||
private final Replacement replacement;
|
||||
private final java.util.concurrent.ExecutorService worker;
|
||||
private volatile Snapshot effective;
|
||||
private String persistedUrl;
|
||||
private String persistedSha1;
|
||||
private long persistedSequence;
|
||||
private long publishedSequence;
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public SheetResourcePackSettings(Path directory, Object configuredUrl, Object configuredSha1, Defaults defaults) {
|
||||
this(directory, raw(configuredUrl, URL_KEY), raw(configuredSha1, SHA1_KEY), defaults,
|
||||
(from, to) -> Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
SheetResourcePackSettings(Path directory, String configuredUrl, String configuredSha1, Defaults defaults, Replacement replacement) {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
this.defaults = Objects.requireNonNull(defaults);
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
effective = snapshot(configuredUrl, configuredSha1);
|
||||
worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "fantasy-sheet-resource-pack-configuration"));
|
||||
persistedUrl = configuredUrl;
|
||||
persistedSha1 = configuredSha1;
|
||||
}
|
||||
|
||||
public static Defaults packagedDefaults() {
|
||||
try (var input = SheetResourcePackSettings.class.getResourceAsStream("/sheet-resource-pack.properties")) {
|
||||
if (input == null) throw new IOException("Missing packaged sheet resource-pack metadata");
|
||||
var values = new Properties(); values.load(input);
|
||||
return new Defaults(values.getProperty("url"), values.getProperty("sha1"));
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
throw new IllegalStateException("Invalid packaged sheet resource-pack metadata", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String raw(Object value, String key) {
|
||||
if (value == null) return "";
|
||||
if (value instanceof String text) return text;
|
||||
throw new IllegalArgumentException(key + " must be a string");
|
||||
}
|
||||
|
||||
public static String effectiveUrl(String configured, Defaults defaults) {
|
||||
Objects.requireNonNull(defaults);
|
||||
return configured == null || configured.isEmpty() ? defaults.url() : validateUrl(configured);
|
||||
}
|
||||
|
||||
private static String effectiveUrl(String value, DefaultsMarker ignored) { return validateUrl(value); }
|
||||
|
||||
private static String validateUrl(String value) {
|
||||
if (value == null || value.length() > 2048 || value.chars().anyMatch(Character::isWhitespace)) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL");
|
||||
}
|
||||
try {
|
||||
var uri = URI.create(value);
|
||||
if (!"https".equals(uri.getScheme()) || uri.getHost() == null || uri.getHost().isBlank()
|
||||
|| uri.getRawUserInfo() != null || uri.getRawFragment() != null
|
||||
|| uri.getRawPath() == null || uri.getRawPath().isBlank()) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be an absolute HTTPS asset URL without credentials or fragment");
|
||||
}
|
||||
return uri.toASCIIString();
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IllegalArgumentException(URL_KEY + " must be a supported HTTPS URL", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
public static String effectiveSha1(String configured, Defaults defaults) {
|
||||
Objects.requireNonNull(defaults);
|
||||
return configured == null || configured.isEmpty() ? defaults.sha1() : normalizeHash(configured, false);
|
||||
}
|
||||
|
||||
private static String normalizeHash(String value, boolean allowEmpty) {
|
||||
if (allowEmpty && (value == null || value.isEmpty())) return "";
|
||||
if (value == null || !value.matches("[a-fA-F0-9]{40}")) {
|
||||
throw new IllegalArgumentException(SHA1_KEY + " must be exactly 40 hexadecimal characters");
|
||||
}
|
||||
return value.toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private Snapshot snapshot(String configuredUrl, String configuredSha1) {
|
||||
String url = configuredUrl == null ? "" : configuredUrl;
|
||||
String sha1 = configuredSha1 == null ? "" : normalizeHash(configuredSha1, true);
|
||||
return new Snapshot(url, sha1, effectiveUrl(url, defaults), effectiveSha1(sha1, defaults));
|
||||
}
|
||||
|
||||
public String url() { return effective.url(); }
|
||||
public String sha1() { return effective.sha1(); }
|
||||
public String value(String key) {
|
||||
var state = effective;
|
||||
return switch (key) {
|
||||
case URL_KEY -> state.url();
|
||||
case SHA1_KEY -> state.sha1();
|
||||
default -> throw new IllegalArgumentException("Unknown sheet resource-pack key");
|
||||
};
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String raw) {
|
||||
if (!URL_KEY.equals(key) && !SHA1_KEY.equals(key)) throw new IllegalArgumentException("Unknown sheet resource-pack key");
|
||||
if (raw == null) throw new IllegalArgumentException(key + " requires a value");
|
||||
if (URL_KEY.equals(key) && !raw.isEmpty()) validateUrl(raw);
|
||||
if (SHA1_KEY.equals(key)) raw = normalizeHash(raw, true);
|
||||
final String accepted = raw;
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(key, accepted);
|
||||
if (URL_KEY.equals(key)) persistedUrl = accepted; else persistedSha1 = accepted;
|
||||
return new Update(snapshot(persistedUrl, persistedSha1), ++persistedSequence);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Sheet resource-pack configuration save refused or failed; effective settings unchanged"));
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean publish(Update update) {
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
effective = update.snapshot();
|
||||
publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void save(String key, String value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml"); var lockFile = directory.resolve(".config.lock");
|
||||
if (Files.exists(lockFile, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(lockFile, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe lock");
|
||||
try (var channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Configuration busy");
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false); options.setMaxAliasesForCollections(20); options.setCodePointLimit(MAX_BYTES);
|
||||
var yaml = new Yaml(new SafeConstructor(options)); Map<String, Object> values = new LinkedHashMap<>();
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe configuration");
|
||||
byte[] bytes; try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { bytes = input.readNBytes(MAX_BYTES + 1); }
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Object loaded = yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());
|
||||
if (loaded != null) {
|
||||
if (!(loaded instanceof Map<?, ?> map)) throw new IOException("Invalid configuration root");
|
||||
for (var entry : map.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String name)) throw new IOException("Invalid configuration key");
|
||||
values.put(name, entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
values.put(key, value);
|
||||
byte[] bytes = yaml.dump(values).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Configuration too large");
|
||||
Path temporary = Files.createTempFile(directory, ".config-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes); while (buffer.hasRemaining()) output.write(buffer); output.force(true);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
private synchronized void finished() { pending--; if (closed && pending == 0) drained.complete(null); }
|
||||
public CompletionStage<Void> drained() { return drained.minimalCompletionStage(); }
|
||||
@Override public synchronized void close() { closed = true; worker.shutdown(); if (pending == 0) drained.complete(null); }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for character-switch delay checks and later exits. */
|
||||
public final class SwitchDelaySettings implements AutoCloseable {
|
||||
public static final String KEY="character-swap-cooldown-seconds";public static final String DEFAULT="900";
|
||||
private static final int MAX_BYTES=1_048_576;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,Duration delay,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-switch-delay-configuration"));
|
||||
private volatile Duration effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public SwitchDelaySettings(Path directory,Duration initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
SwitchDelaySettings(Path directory,Duration initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
|
||||
public static Duration parse(Object value){if(value==null)return Duration.ofSeconds(900);try{return validate(Duration.ofSeconds(value instanceof Number n?new java.math.BigDecimal(n.toString()).longValueExact():Long.parseLong(value.toString())));}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds",invalid);}}
|
||||
private static Duration validate(Duration value){if(value.isNegative()||value.getNano()!=0)throw new IllegalArgumentException(KEY+" must be nonnegative whole seconds");try{var deadline=java.time.Instant.now().plus(value);if(deadline.atOffset(java.time.ZoneOffset.UTC).getYear()>9999)throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range");return value;}catch(java.time.DateTimeException|ArithmeticException invalid){throw new IllegalArgumentException(KEY+" exceeds the persisted timestamp range",invalid);}}
|
||||
public Duration delay(){return effective;}public String value(){return Long.toString(effective.toSeconds());}
|
||||
public CompletionStage<Update> set(String raw){Duration accepted=parse(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Long.toString(accepted.toSeconds()),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Character switch configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized void publish(Update update){if(update.sequence()<=publishedSequence)return;effective=validate(update.delay());publishedSequence=update.sequence();}
|
||||
private void save(Duration value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,Long.toString(value.toSeconds()));byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package games.dmg.fantasystick.presentation;
|
||||
|
||||
import games.dmg.fantasystick.persistence.SheetResourcePackSettings;
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerResourcePackStatusEvent;
|
||||
|
||||
/** Optional cosmetic pack delivery. Status never authorizes or cancels gameplay. */
|
||||
public final class SheetResourcePack implements Listener, AutoCloseable {
|
||||
public static final UUID ID = UUID.fromString("0d53e627-f13b-5a49-b8d5-882d3f4122bb");
|
||||
private final SheetResourcePackSettings settings;
|
||||
private final BiConsumer<UUID, Throwable> failures;
|
||||
private boolean closed;
|
||||
|
||||
public SheetResourcePack(SheetResourcePackSettings settings, BiConsumer<UUID, Throwable> failures) {
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void deliverAll(Collection<? extends Player> players) {
|
||||
if (closed) return;
|
||||
for (var player : players) if (player != null && player.isOnline()) deliver(player);
|
||||
}
|
||||
|
||||
public void deliver(Player player) {
|
||||
if (closed || !player.isOnline()) return;
|
||||
try {
|
||||
player.addResourcePack(ID, settings.url(), java.util.HexFormat.of().parseHex(settings.sha1()),
|
||||
"Optional Fantasy Stick first-person sheet presentation", false);
|
||||
} catch (RuntimeException failure) {
|
||||
report(player.getUniqueId(), failure);
|
||||
player.sendMessage("Fantasy Stick cosmetic resource pack could not be offered; gameplay is unchanged.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler public void join(PlayerJoinEvent event) { deliver(event.getPlayer()); }
|
||||
|
||||
@EventHandler public void status(PlayerResourcePackStatusEvent event) {
|
||||
if (closed || !ID.equals(event.getID())) return;
|
||||
switch (event.getStatus()) {
|
||||
case DECLINED, FAILED_DOWNLOAD, INVALID_URL, FAILED_RELOAD, DISCARDED ->
|
||||
event.getPlayer().sendMessage("Fantasy Stick cosmetic resource pack is unavailable; gameplay is unchanged.");
|
||||
default -> { /* Acceptance/loading is cosmetic and needs no gameplay callback. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void report(UUID player, Throwable failure) {
|
||||
try { failures.accept(player, failure); }
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot make a cosmetic pack mandatory. */ }
|
||||
}
|
||||
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Persists one fresh character before handing its authentic sheet to a still-valid native inventory slot. */
|
||||
public final class AdminCharacterIssuer implements AutoCloseable {
|
||||
public enum Outcome { DELIVERED, FULL, BUSY, STALE, SAVE_FAILED, RETAINED }
|
||||
public record Failure(UUID player, UUID character, String operation, Throwable cause) { }
|
||||
|
||||
private final CharacterStorage storage;
|
||||
private final Supplier<CharacterRecord> allocate;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Failure> failures;
|
||||
private final ConcurrentHashMap<UUID, Attempt> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
|
||||
public AdminCharacterIssuer(CharacterStorage storage, Supplier<CharacterRecord> allocate,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> failures) {
|
||||
this.storage = Objects.requireNonNull(storage);
|
||||
this.allocate = Objects.requireNonNull(allocate);
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
/** Must be entered on the server thread. Completion is delivered on that thread. */
|
||||
public void issue(Player player, Consumer<Outcome> response) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(response);
|
||||
if (closed || !player.isOnline()) { response.accept(Outcome.STALE); return; }
|
||||
int slot = player.getInventory().firstEmpty();
|
||||
if (slot < 0) { response.accept(Outcome.FULL); return; }
|
||||
var attempt = new Attempt(player, slot, response);
|
||||
if (pending.putIfAbsent(attempt.playerId, attempt) != null) { response.accept(Outcome.BUSY); return; }
|
||||
try {
|
||||
attempt.record = allocate.get();
|
||||
attempt.preparation = storage.prepareCraft(List.of(attempt.record));
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
} catch (RuntimeException failure) {
|
||||
pending.remove(attempt.playerId, attempt);
|
||||
abandon(attempt);
|
||||
report(attempt, "prepare", failure);
|
||||
response.accept(Outcome.SAVE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.playerId) != attempt) { abandon(attempt); return; }
|
||||
try { nextTick.accept(() -> complete(attempt, batch, failure)); }
|
||||
catch (RuntimeException rejected) {
|
||||
pending.remove(attempt.playerId, attempt); abandon(attempt); report(attempt, "schedule", rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void complete(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || !pending.remove(attempt.playerId, attempt)) { abandon(attempt); return; }
|
||||
if (failure != null) {
|
||||
abandon(attempt); report(attempt, "save", failure); respond(attempt, Outcome.SAVE_FAILED); return;
|
||||
}
|
||||
var occupying = attempt.player.getInventory().getItem(attempt.slot);
|
||||
if (!attempt.player.isOnline() || attempt.slot < 0 || attempt.slot >= 36
|
||||
|| occupying != null && !occupying.getType().isAir()) {
|
||||
abandon(attempt); respond(attempt, Outcome.STALE); return;
|
||||
}
|
||||
var claimed = batch.claimForDelivery();
|
||||
if (claimed.isEmpty() || claimed.orElseThrow().size() != 1
|
||||
|| !claimed.orElseThrow().getFirst().id().equals(attempt.record.id())) {
|
||||
abandon(attempt); respond(attempt, Outcome.RETAINED); return;
|
||||
}
|
||||
try {
|
||||
var sheet = new CharacterSheets().create(attempt.record);
|
||||
attempt.player.getInventory().setItem(attempt.slot, sheet);
|
||||
attempt.delivered = true;
|
||||
} catch (RuntimeException uncertain) {
|
||||
report(attempt, "delivery", uncertain);
|
||||
respond(attempt, Outcome.RETAINED);
|
||||
return;
|
||||
}
|
||||
try { attempt.player.updateInventory(); }
|
||||
catch (RuntimeException syncFailure) { report(attempt, "sync", syncFailure); }
|
||||
respond(attempt, Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
private void respond(Attempt attempt, Outcome outcome) {
|
||||
try { attempt.response.accept(outcome); }
|
||||
catch (RuntimeException failure) { report(attempt, "response", failure); }
|
||||
}
|
||||
|
||||
private void abandon(Attempt attempt) {
|
||||
if (attempt.preparation == null || attempt.delivered || !attempt.abandoned.compareAndSet(false, true)) return;
|
||||
attempt.preparation.abandon().thenAccept(cleanup -> {
|
||||
if (!cleanup.retained().isEmpty()) report(attempt, "cleanup-retained", null);
|
||||
});
|
||||
}
|
||||
|
||||
private void report(Attempt attempt, String operation, Throwable cause) {
|
||||
try { failures.accept(new Failure(attempt.playerId, attempt.record == null ? null : attempt.record.id(), operation, cause)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
for (var attempt : List.copyOf(pending.values())) {
|
||||
if (pending.remove(attempt.playerId, attempt)) abandon(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Attempt {
|
||||
final Player player; final UUID playerId; final int slot; final Consumer<Outcome> response;
|
||||
final java.util.concurrent.atomic.AtomicBoolean abandoned = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
CharacterRecord record; CharacterStorage.CraftPreparation preparation; volatile boolean delivered;
|
||||
Attempt(Player player, int slot, Consumer<Outcome> response) { this.player=player; this.playerId=player.getUniqueId(); this.slot=slot; this.response=response; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.fantasystick.session;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.CharacterStorage;
|
||||
import games.dmg.fantasystick.persistence.RevivalStore;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
/** Admin-owned, expiring Tail selection with authoritative consume-once restoration. */
|
||||
public final class AdminRevival implements Listener, AutoCloseable {
|
||||
public interface PopupFactory { Inventory create(int size, Component title); }
|
||||
public interface Scheduler { AutoCloseable later(Duration delay, Runnable action); }
|
||||
private record Candidate(int slot, ItemStack item, TailBooks.Identity identity) { }
|
||||
private record Choice(Candidate candidate, CharacterRecord record) { }
|
||||
private final CharacterStorage characters; private final RevivalStore reservations; private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick; private final Scheduler scheduler; private final PopupFactory popups;
|
||||
private final Consumer<Throwable> failures; private final TailBooks tails=new TailBooks();private final CharacterSheets sheets=new CharacterSheets();
|
||||
private final Map<Inventory,View> views=new IdentityHashMap<>();private boolean closed;
|
||||
|
||||
private final class View implements AutoCloseable {
|
||||
final Player admin,target;final Inventory inventory;final Map<Integer,Choice> choices;final Instant deadline;final AutoCloseable expiry;boolean selecting,finished;
|
||||
View(Player admin,Player target,Inventory inventory,Map<Integer,Choice> choices,Instant deadline){this.admin=admin;this.target=target;this.inventory=inventory;this.choices=choices;this.deadline=deadline;this.expiry=scheduler.later(Duration.between(clock.instant(),deadline),this::close);}
|
||||
boolean current(){return !closed&&!finished&&admin.isOnline()&&admin.hasPermission(EquipmentAdminCommand.PERMISSION)&&target.isOnline()&&clock.instant().isBefore(deadline);}
|
||||
@Override public void close(){if(finished)return;finished=true;views.remove(inventory,this);inventory.clear();try{expiry.close();}catch(Exception e){report(e);}try{admin.closeInventory();}catch(RuntimeException e){report(e);}}
|
||||
}
|
||||
|
||||
public AdminRevival(CharacterStorage characters,RevivalStore reservations,Clock clock,Consumer<Runnable> nextTick,Scheduler scheduler,PopupFactory popups,Consumer<Throwable> failures){this.characters=Objects.requireNonNull(characters);this.reservations=Objects.requireNonNull(reservations);this.clock=Objects.requireNonNull(clock);this.nextTick=Objects.requireNonNull(nextTick);this.scheduler=Objects.requireNonNull(scheduler);this.popups=Objects.requireNonNull(popups);this.failures=Objects.requireNonNull(failures);}
|
||||
|
||||
public void open(Player admin,Player target,Duration timeout){Objects.requireNonNull(admin);Objects.requireNonNull(target);if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline()){admin.sendMessage("Admin revival refused: authorization or target is no longer available.");return;}var candidates=candidates(target);if(candidates.isEmpty()){admin.sendMessage("That player has no authentic direct-inventory Tails.");return;}var loads=candidates.stream().map(c->characters.find(c.identity.characterId())).toList();CompletableFuture.allOf(loads.toArray(CompletableFuture[]::new)).whenComplete((ignored,failure)->schedule(()->{if(failure!=null){report(failure);admin.sendMessage("Admin revival could not inspect those Tails safely.");return;}if(closed||!admin.isOnline()||!admin.hasPermission(EquipmentAdminCommand.PERMISSION)||!target.isOnline())return;var choices=new ArrayList<Choice>();for(int i=0;i<candidates.size();i++){var found=loads.get(i).join();if(found.isPresent()&&eligible(found.orElseThrow(),candidates.get(i).identity)&&unchanged(target,candidates.get(i)))choices.add(new Choice(candidates.get(i),found.orElseThrow()));}if(choices.isEmpty()){admin.sendMessage("That player has no current unconsumed Tail.");return;}show(admin,target,choices,timeout);}));}
|
||||
private List<Candidate> candidates(Player target){var result=new ArrayList<Candidate>();for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())continue;var identity=tails.identify(item);if(identity.isPresent())result.add(new Candidate(slot,item.clone(),identity.orElseThrow()));}return List.copyOf(result);}
|
||||
private boolean eligible(CharacterRecord record,TailBooks.Identity id){return record.dead()&&record.id().equals(id.characterId())&&Objects.equals(record.deathId(),id.deathId())&&!record.diedAt().isAfter(clock.instant());}
|
||||
private static boolean unchanged(Player target,Candidate candidate){var item=target.getInventory().getItem(candidate.slot);return item!=null&&item.equals(candidate.item);}
|
||||
private void show(Player admin,Player target,List<Choice> eligible,Duration timeout){int size=Math.min(54,Math.max(9,((eligible.size()+8)/9)*9));var inventory=popups.create(size,Component.text("Choose a Tail for "+target.getName()));var choices=new LinkedHashMap<Integer,Choice>();for(int i=0;i<eligible.size()&&i<size;i++){var choice=eligible.get(i);var item=choice.candidate.item.clone();item.setAmount(1);var meta=item.getItemMeta();meta.lore(List.of(Component.text("Character: "+choice.record.name()),Component.text("Level "+choice.record.level()+" · target slot "+choice.candidate.slot),Component.text("Click to restore this exact death")));item.setItemMeta(meta);inventory.setItem(i,item);choices.put(i,choice);}var view=new View(admin,target,inventory,Map.copyOf(choices),clock.instant().plus(timeout));views.put(inventory,view);admin.openInventory(inventory);}
|
||||
|
||||
@EventHandler(priority=EventPriority.HIGHEST) public void click(org.bukkit.event.inventory.InventoryClickEvent event){var view=views.get(event.getView().getTopInventory());if(view==null)return;event.setCancelled(true);if(event.getWhoClicked()!=view.admin||event.getRawSlot()<0||event.getRawSlot()>=view.inventory.getSize())return;var choice=view.choices.get(event.getRawSlot());if(choice==null||view.selecting)return;view.selecting=true;views.remove(view.inventory,view);view.inventory.clear();view.admin.closeInventory();confirm(view,choice);}
|
||||
@EventHandler(priority=EventPriority.HIGHEST) public void drag(org.bukkit.event.inventory.InventoryDragEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&event.getRawSlots().stream().anyMatch(slot->slot<view.inventory.getSize()))event.setCancelled(true);}
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void close(org.bukkit.event.inventory.InventoryCloseEvent event){var view=views.get(event.getView().getTopInventory());if(view!=null&&!view.selecting)view.close();}
|
||||
@EventHandler(priority=EventPriority.MONITOR) public void quit(org.bukkit.event.player.PlayerQuitEvent event){for(var view:List.copyOf(views.values()))if(view.admin==event.getPlayer()||view.target==event.getPlayer())view.close();}
|
||||
|
||||
private void confirm(View view,Choice choice){if(!view.current()||!unchanged(view.target,choice.candidate)){view.close();return;}characters.find(choice.record.id()).whenComplete((found,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival could not recheck that Tail; no item changed.");view.close();return;}if(!view.current()||found.isEmpty()||!eligible(found.orElseThrow(),choice.candidate.identity)||!unchanged(view.target,choice.candidate)){view.admin.sendMessage("That Tail selection is stale; no item changed.");view.close();return;}reserve(view,choice.candidate,found.orElseThrow());}));}
|
||||
private void reserve(View view,Candidate candidate,CharacterRecord dead){var source=view.target.getInventory().getItem(candidate.slot);int destination=source.getAmount()==1?-1:emptySlot(view.target,candidate.slot);if(source.getAmount()>1&&destination<0){view.admin.sendMessage("Admin revival needs one empty direct inventory slot to preserve copied Tails.");view.close();return;}var reservation=new RevivalStore.Reservation(UUID.randomUUID(),view.admin.getUniqueId(),view.target.getUniqueId(),dead.id(),dead.deathId(),clock.instant().truncatedTo(ChronoUnit.SECONDS),candidate.slot,source.getAmount(),destination,true);reservations.reserve(reservation).whenComplete((saved,failure)->schedule(()->{if(failure!=null){report(failure);view.admin.sendMessage("Admin revival reservation failed; no item changed.");view.close();return;}if(saved.isEmpty()||!view.current()||!unchanged(view.target,candidate)){view.admin.sendMessage("Admin revival became stale after reservation; no item changed.");view.close();return;}deliver(view,dead,reservation);}));}
|
||||
private static int emptySlot(Player target,int source){for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40||slot==source)continue;var item=target.getInventory().getItem(slot);if(item==null||item.isEmpty())return slot;}return-1;}
|
||||
private void deliver(View view,CharacterRecord dead,RevivalStore.Reservation reservation){try{var provisional=new CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());var sheet=sheets.create(provisional);var source=view.target.getInventory().getItem(reservation.sourceSlot());if(reservation.sourceAmount()==1)view.target.getInventory().setItem(reservation.sourceSlot(),sheet);else{view.target.getInventory().setItem(reservation.destinationSlot(),sheet);var remaining=source.clone();remaining.setAmount(reservation.sourceAmount()-1);view.target.getInventory().setItem(reservation.sourceSlot(),remaining);}view.target.updateInventory();}catch(RuntimeException uncertain){report(uncertain);view.admin.sendMessage("Admin revival delivery is uncertain; retained proof requires recovery.");view.close();return;}characters.restoreDeathRecordAdministratively(reservation.character(),reservation.death(),NativeHealthLimit.read()).whenComplete((restored,failure)->schedule(()->{if(failure!=null||restored.isEmpty()){if(failure!=null)report(failure);view.admin.sendMessage("The Tail changed, but restoration is pending proof-based recovery.");view.close();return;}if(installFinal(view.target,restored.orElseThrow())){reservations.clear(reservation.character(),reservation.id()).exceptionally(error->{report(error);return false;});view.admin.sendMessage("Administratively restored "+restored.orElseThrow().name()+" for "+view.target.getName()+".");view.target.sendMessage("An administrator restored character sheet: "+restored.orElseThrow().name()+".");}else view.admin.sendMessage("Character restoration was saved, but sheet custody is ambiguous; retained proof requires recovery.");view.close();}));}
|
||||
private boolean installFinal(Player target,CharacterRecord record){int found=-1;for(int slot=0;slot<=40;slot++){if(slot>35&&slot!=40)continue;if(sheets.identify(target.getInventory().getItem(slot)).filter(record.id()::equals).isEmpty())continue;if(found>=0)return false;found=slot;}if(found<0)return false;target.getInventory().setItem(found,sheets.create(record));target.updateInventory();return true;}
|
||||
private void schedule(Runnable action){try{nextTick.accept(action);}catch(RuntimeException rejected){report(rejected);}}
|
||||
private void report(Throwable failure){try{failures.accept(failure);}catch(RuntimeException ignored){}}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var view:List.copyOf(views.values()))view.close();views.clear();}
|
||||
}
|
||||
@@ -38,6 +38,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.ability.Hex hex;
|
||||
private games.dmg.fantasystick.persistence.InspireSettings inspireSettings;
|
||||
private games.dmg.fantasystick.ability.Inspire inspire;
|
||||
private AdminCharacterIssuer characterIssuer;
|
||||
private AdminRevival adminRevival;
|
||||
private games.dmg.fantasystick.persistence.AdminReviveSettings adminReviveSettings;
|
||||
private games.dmg.fantasystick.persistence.SwitchDelaySettings switchDelaySettings;
|
||||
private games.dmg.fantasystick.persistence.RoleplaySettings roleplaySettings;
|
||||
private games.dmg.fantasystick.persistence.PartySettings partySettings;
|
||||
private games.dmg.fantasystick.persistence.SheetResourcePackSettings sheetResourcePackSettings;
|
||||
private games.dmg.fantasystick.presentation.SheetResourcePack sheetResourcePack;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -207,6 +215,21 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
this.inspireSettings=Objects.requireNonNull(inspireSettings);this.inspire=Objects.requireNonNull(inspire);
|
||||
}
|
||||
|
||||
public void attachSwitchDelay(games.dmg.fantasystick.persistence.SwitchDelaySettings settings){if(closed||switchDelaySettings!=null)throw new IllegalStateException("Switch delay already attached or command closed");switchDelaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachRoleplaySettings(games.dmg.fantasystick.persistence.RoleplaySettings settings){if(closed||roleplaySettings!=null)throw new IllegalStateException("Roleplay settings already attached or command closed");roleplaySettings=Objects.requireNonNull(settings);}
|
||||
public void attachPartySettings(games.dmg.fantasystick.persistence.PartySettings settings){if(closed||partySettings!=null)throw new IllegalStateException("Party settings already attached or command closed");partySettings=Objects.requireNonNull(settings);}
|
||||
public void attachSheetResourcePack(games.dmg.fantasystick.persistence.SheetResourcePackSettings settings,games.dmg.fantasystick.presentation.SheetResourcePack delivery){if(closed||sheetResourcePackSettings!=null)throw new IllegalStateException("Sheet resource pack already attached or command closed");sheetResourcePackSettings=Objects.requireNonNull(settings);sheetResourcePack=Objects.requireNonNull(delivery);}
|
||||
|
||||
public void attachAdminRevival(AdminRevival revival, games.dmg.fantasystick.persistence.AdminReviveSettings settings) {
|
||||
if (closed || adminRevival != null) throw new IllegalStateException("Admin revival already attached or command closed");
|
||||
adminRevival=Objects.requireNonNull(revival);adminReviveSettings=Objects.requireNonNull(settings);
|
||||
}
|
||||
|
||||
public void attachCharacterIssuer(AdminCharacterIssuer issuer) {
|
||||
if (closed || characterIssuer != null) throw new IllegalStateException("Character issuer already attached or command closed");
|
||||
characterIssuer = Objects.requireNonNull(issuer);
|
||||
}
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
@@ -217,6 +240,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
configuration(sender, args);
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("give")) {
|
||||
give(sender, args);
|
||||
return true;
|
||||
}
|
||||
if (args.length > 0 && args[0].equals("revive")) {
|
||||
adminRevive(sender,args);return true;
|
||||
}
|
||||
UUID owner;
|
||||
try {
|
||||
if (args.length != 2 || !(args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
@@ -277,9 +307,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void adminRevive(CommandSender sender,String[] args){if(args.length!=2||args[1].isBlank()||adminRevival==null||!(sender instanceof org.bukkit.entity.Player admin)){sender.sendMessage("Usage: /fantasystick revive <online-player> (an in-game administrator must select the Tail)");return;}var matches=server.getOnlinePlayers().stream().filter(player->player.isOnline()&&player.getName().equalsIgnoreCase(args[1])).toList();if(matches.size()!=1){sender.sendMessage("Admin revival refused: target must name one online player. No item changed.");return;}adminRevival.open(admin,matches.getFirst(),adminReviveSettings.timeout());}
|
||||
|
||||
private void give(CommandSender sender, String[] args) {
|
||||
if (args.length != 2 || args[1].isBlank() || characterIssuer == null) {
|
||||
sender.sendMessage("Usage: /fantasystick give <online-player>"); return;
|
||||
}
|
||||
var matches = server.getOnlinePlayers().stream().filter(player -> player.isOnline()
|
||||
&& player.getName().equalsIgnoreCase(args[1])).toList();
|
||||
if (matches.size() != 1) {
|
||||
sender.sendMessage("Character issue refused: target must name one online player. No character or item was created."); return;
|
||||
}
|
||||
var target = matches.getFirst();
|
||||
characterIssuer.issue(target, outcome -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(switch (outcome) {
|
||||
case DELIVERED -> "Issued one new character sheet to " + target.getName() + ".";
|
||||
case FULL -> "Character issue refused: " + target.getName() + " has no inventory space. No character was created.";
|
||||
case BUSY -> "Character issue refused: another delivery to " + target.getName() + " is pending.";
|
||||
case STALE -> "Character issue refused: target disconnected or the reserved inventory slot changed. No sheet was delivered.";
|
||||
case SAVE_FAILED -> "Character issue failed before delivery; no sheet was delivered. See server diagnostics.";
|
||||
case RETAINED -> "Character issue has ambiguous retained state; no retry was performed. See server diagnostics.";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void configuration(CommandSender sender, String[] args) {
|
||||
if (args.length < 2 || args.length > 3) { configurationUsage(sender); return; }
|
||||
if (args[1].equals("adventure-mode")) { adventureConfiguration(sender, args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)) { switchDelayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)) { roleplayConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)) { partyConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)) { sheetResourcePackConfiguration(sender,args); return; }
|
||||
if (args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)) { adminReviveConfiguration(sender,args); return; }
|
||||
if (longbowSettings != null && games.dmg.fantasystick.persistence.LongbowSettings.keys().contains(args[1])) {
|
||||
longbowConfiguration(sender, args); return;
|
||||
}
|
||||
@@ -490,6 +551,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
private void sheetResourcePackConfiguration(CommandSender sender,String[] args){if(sheetResourcePackSettings==null){configurationUsage(sender);return;}String key=args[1];if(args.length==2){sender.sendMessage(key+"="+sheetResourcePackSettings.value(key));return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SheetResourcePackSettings.Update> operation;try{operation=sheetResourcePackSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Sheet resource-pack value is invalid; use an absolute HTTPS URL or 40 hexadecimal SHA-1 characters.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-sheet-resource-pack",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=sheetResourcePackSettings.publish(saved);if(applied)sheetResourcePack.deliverAll(server.getOnlinePlayers());}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Sheet resource-pack configuration save failed; effective values are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+key+"="+sheetResourcePackSettings.value(key)+".");else sender.sendMessage("Saved "+key+"="+sheetResourcePackSettings.value(key)+"; optional delivery was requested for online players.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void partyConfiguration(CommandSender sender,String[] args){if(partySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+" (existing parties are retained)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PartySettings.Update> operation;try{operation=partySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Party maximum is invalid; enter a positive whole number.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-party",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null)applied=partySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Party configuration save failed; the effective limit and rosters are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+partySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.PartySettings.KEY+"="+saved.value()+"; existing members are retained.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void roleplayConfiguration(CommandSender sender,String[] args){if(roleplaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value());return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RoleplaySettings.Update> operation;try{operation=roleplaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Usage: /fantasystick config roleplay-disabled [true|false]");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-roleplay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;boolean applied=false;if(failure==null){applied=roleplaySettings.publish(saved);if(applied&&saved.disabled())sessions.disableRoleplay();}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;if(failure!=null)sender.sendMessage("Roleplay configuration save failed; policy and sessions are unchanged.");else if(!applied)sender.sendMessage("Saved request was superseded; effective "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+roleplaySettings.value()+".");else sender.sendMessage("Saved "+games.dmg.fantasystick.persistence.RoleplaySettings.KEY+"="+saved.disabled()+(saved.disabled()?"; ordinary exits were requested for active sessions.":"; normal sheet activation is available."));});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void switchDelayConfiguration(CommandSender sender,String[] args){if(switchDelaySettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+switchDelaySettings.value()+" (new exits use this delay; existing deadlines are unchanged)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SwitchDelaySettings.Update> operation;try{operation=switchDelaySettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Character switch delay is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-switch-delay",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)switchDelaySettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY+"="+saved.value()+"; new exits use it and existing deadlines are unchanged.":"Character switch delay save failed; the effective value is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
sender.sendMessage("Usage: /fantasystick config adventure-mode [true|false]"); return;
|
||||
@@ -522,12 +593,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
catch (RuntimeException ignored) { /* Diagnostics cannot authorize recovery. */ }
|
||||
}
|
||||
|
||||
// Never fall back to Bukkit's online-player-name suggestions for a UUID-only admin interface.
|
||||
// Return only explicit implemented routes; never fall back to Bukkit's broad default suggestions.
|
||||
@Override public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(List.of("config", "inspect", "recover"), args[0]);
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
|
||||
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
|
||||
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
|
||||
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY);keys.add(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY);
|
||||
keys.addAll(games.dmg.fantasystick.persistence.FrostShardSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.LongbowSettings.keys());
|
||||
keys.addAll(games.dmg.fantasystick.persistence.QuickstepSettings.keys());
|
||||
@@ -540,6 +617,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
if (args.length == 3 && args[0].equals("config")) {
|
||||
if (args[1].equals("adventure-mode")) return matching(List.of("false", "true"), args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY)&&adminReviveSettings!=null){String current=adminReviveSettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.AdminReviveSettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY)&&switchDelaySettings!=null){String current=switchDelaySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.SwitchDelaySettings.DEFAULT),args[2]);}
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.RoleplaySettings.KEY)&&roleplaySettings!=null)return matching(List.of("false","true"),args[2]);
|
||||
if(args[1].equals(games.dmg.fantasystick.persistence.PartySettings.KEY)&&partySettings!=null){String current=partySettings.value();return matching(current.equals(games.dmg.fantasystick.persistence.PartySettings.DEFAULT)?List.of(current):List.of(current,games.dmg.fantasystick.persistence.PartySettings.DEFAULT),args[2]);}
|
||||
if(sheetResourcePackSettings!=null&&(args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.URL_KEY)||args[1].equals(games.dmg.fantasystick.persistence.SheetResourcePackSettings.SHA1_KEY)))return matching(List.of(sheetResourcePackSettings.value(args[1])),args[2]);
|
||||
if (frostSettings != null && games.dmg.fantasystick.persistence.FrostShardSettings.keys().contains(args[1])) {
|
||||
String current = frostSettings.value(args[1]);
|
||||
String defaults = games.dmg.fantasystick.persistence.FrostShardSettings.defaultValue(args[1]);
|
||||
@@ -582,5 +664,5 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private static List<String> matching(java.util.Collection<String> values, String prefix) {
|
||||
return values.stream().filter(value -> value.startsWith(prefix)).distinct().sorted().toList();
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public void close() { closed = true; if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); }
|
||||
}
|
||||
@@ -38,7 +38,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
private final Duration switchDelay;
|
||||
private Supplier<Duration> switchDelay;
|
||||
private java.util.function.BooleanSupplier roleplayDisabled = () -> false;
|
||||
private final Clock clock;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Notice> notices;
|
||||
@@ -91,6 +92,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean uncertainSave;
|
||||
boolean nativeUncertain;
|
||||
boolean exitRequested;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
Attempt recoveryOwner;
|
||||
org.bukkit.Location entryLocation;
|
||||
@@ -118,7 +120,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
Consumer<Runnable> nextTick, Consumer<Notice> notices, Consumer<Failure> diagnostics) {
|
||||
this.characters = Objects.requireNonNull(characters);
|
||||
this.receipts = Objects.requireNonNull(receipts);
|
||||
this.switchDelay = Objects.requireNonNull(switchDelay);
|
||||
var initialSwitchDelay = Objects.requireNonNull(switchDelay);
|
||||
this.switchDelay = () -> initialSwitchDelay;
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
if (switchDelay.isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
@@ -142,6 +145,19 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
this.gameModes = Objects.requireNonNull(gameModes);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so later checks/exits capture the acknowledged live policy. */
|
||||
public synchronized void attachSwitchDelay(Supplier<Duration> policy) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
switchDelay = Objects.requireNonNull(policy);
|
||||
if (switchDelay.get().isNegative()) throw new IllegalArgumentException("Negative switch delay");
|
||||
}
|
||||
|
||||
/** Install before exposing entry so pending and new activation work revalidates availability. */
|
||||
public synchronized void attachRoleplayDisabled(java.util.function.BooleanSupplier policy) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
roleplayDisabled = Objects.requireNonNull(policy);
|
||||
}
|
||||
|
||||
/** Install before exposing entry so registered definitions drive delivered control metadata. */
|
||||
public synchronized void attachAbilityRegistry(games.dmg.fantasystick.ability.AbilityRegistry registry) {
|
||||
if (!owned.isEmpty()) throw new IllegalStateException("Sessions already started");
|
||||
@@ -267,6 +283,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void enter(Player player) {
|
||||
UUID owner = player.getUniqueId();
|
||||
if (closed) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
notice(owner, "ROLEPLAY_DISABLED", "Fantasy roleplaying is disabled; the character sheet was not activated.");
|
||||
return;
|
||||
}
|
||||
if (transitioning.containsKey(owner)) {
|
||||
notice(owner, "BUSY", "An equipment transition is still completing; retry afterwards."); return;
|
||||
}
|
||||
@@ -326,6 +346,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private synchronized void loaded(Attempt attempt, Loaded loaded, Throwable failure) {
|
||||
if (!entryCurrent(attempt) || attempt.claim != null) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { fail(attempt, failure); return; }
|
||||
var record = loaded.character().orElse(null);
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
@@ -337,7 +361,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!state.canEnter(attempt.character, switchDelay, now)) {
|
||||
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
|
||||
Duration remaining = Duration.between(now, state.lastExit().deadline());
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
|
||||
refuse(attempt, "COOLDOWN", "Wait " + seconds + " seconds to switch characters (until "
|
||||
@@ -376,6 +400,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
|
||||
private synchronized void complete(Attempt attempt, Optional<CharacterRecord> fresh, Throwable failure) {
|
||||
if (!entryCurrent(attempt)) return;
|
||||
if (roleplayDisabled.getAsBoolean()) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { fail(attempt, failure); return; }
|
||||
var record = fresh.orElse(null);
|
||||
if (record == null || record.dead() || record.health().signum() <= 0) {
|
||||
@@ -401,6 +429,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
refuse(attempt, result.name(), "Equipment entry refused: " + result); return;
|
||||
}
|
||||
packed = true;
|
||||
try { sheets.applyModel(attempt.player.getInventory().getItemInOffHand()); }
|
||||
catch (RuntimeException cosmeticFailure) { diagnostic(attempt, cosmeticFailure); }
|
||||
if (interrupted(attempt)) return;
|
||||
for (int slot = 0; slot < 9; slot++) {
|
||||
attempt.player.getInventory().setItem(slot, items[slot]);
|
||||
@@ -454,6 +484,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
UUID owner = player.getUniqueId();
|
||||
var transition = transitioning.get(owner);
|
||||
if (transition != null) {
|
||||
captureExitDelay(transition);
|
||||
active.remove(owner, transition);
|
||||
withdrawPresentation(transition);
|
||||
transition.exitRequested = true;
|
||||
@@ -463,6 +494,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
cancelEntry(owner);
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
|
||||
captureExitDelay(attempt);
|
||||
return restoreExit(attempt);
|
||||
}
|
||||
|
||||
@@ -607,6 +639,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
|
||||
captureExitDelay(attempt);
|
||||
active.remove(attempt.owner, attempt);
|
||||
withdrawPresentation(attempt);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
@@ -703,19 +736,35 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void captureExitDelay(Attempt attempt) {
|
||||
if (attempt.exitDelay != null) return;
|
||||
var delay = Objects.requireNonNull(switchDelay.get());
|
||||
if (delay.isNegative()) throw new IllegalStateException("Negative switch delay policy");
|
||||
attempt.exitDelay = delay;
|
||||
}
|
||||
|
||||
private void persistExit(Attempt attempt, boolean restored) {
|
||||
captureExitDelay(attempt); // Recovery attempts have no process-local exit request to capture earlier.
|
||||
attempt.nativeRestored = restored;
|
||||
boolean complete = restored && (attempt.healthAcknowledged || attempt.defeated);
|
||||
attempt.saving = true;
|
||||
persist(() -> complete
|
||||
? receipts.completeExit(attempt.owner, attempt.session, switchDelay)
|
||||
: receipts.recordExit(attempt.owner, attempt.session, switchDelay), (state, failure) -> {
|
||||
? receipts.completeExit(attempt.owner, attempt.session, attempt.exitDelay)
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) diagnostic(attempt, failure);
|
||||
else if (complete) { attempt.gearAcknowledged = true; release(attempt); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel pending activation and route every active player through ordinary restoration. */
|
||||
public synchronized void disableRoleplay() {
|
||||
for (var attempt : java.util.List.copyOf(pending.values())) {
|
||||
refuse(attempt, "ROLEPLAY_DISABLED", "Fantasy roleplaying was disabled before activation completed.");
|
||||
}
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
}
|
||||
|
||||
public synchronized void cancelEntry(UUID player) {
|
||||
var attempt = pending.remove(player);
|
||||
if (attempt != null) abandon(attempt);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
# Captured when a new fantasy session starts; changing this does not alter active sessions.
|
||||
# Admin command: /fantasystick config adventure-mode [true|false]
|
||||
adventure-mode: true
|
||||
admin-revive-selection-timeout-seconds: 60
|
||||
|
||||
# Real-time delay before this player may enter a different character after exit.
|
||||
character-swap-cooldown-seconds: 900
|
||||
roleplay-disabled: false
|
||||
party-max-size: 6
|
||||
sheet-resource-pack-url: ''
|
||||
sheet-resource-pack-sha1: ''
|
||||
|
||||
# New-character and transferable fantasy progression defaults.
|
||||
starting-points: 40
|
||||
|
||||
@@ -7,10 +7,10 @@ description: Transferable characters, native fantasy health, recoverable equipme
|
||||
author: dmg.games
|
||||
commands:
|
||||
fantasystick:
|
||||
description: Inspect/recover equipment sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
description: Issue or revive character sheets, inspect/recover sessions or configure Fantasy Stick.
|
||||
usage: /fantasystick <give|revive> <online-player> | <inspect|recover> <player-uuid> | config <setting> [value]
|
||||
permission: fantasystick.admin
|
||||
permissions:
|
||||
fantasystick.admin:
|
||||
description: Inspect/recover equipment sessions and configure fantasy mode.
|
||||
description: Issue/revive sheets, inspect/recover sessions and configure fantasy mode.
|
||||
default: op
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class AdminRevivalNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void commandCompletionConfigurationAndValidationArePermissionAwareAndDurable()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);assertEquals(java.util.List.of("revive"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"rev"}));assertEquals(java.util.List.of("Target"),f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Tar"}));assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive","Target",""}).isEmpty());doReturn(false).when(admin.player).hasPermission("fantasystick.admin");assertTrue(f.command.tabComplete(admin.player,"fantasystick",new String[]{"revive",""}).isEmpty());doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.command.execute(admin.player,"fantasystick",new String[]{"revive"});assertTrue(admin.messages.getLast().contains("Usage"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Missing"});assertTrue(admin.messages.getLast().contains("target must name"));var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertEquals(java.util.List.of("admin-revive-selection-timeout-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-rev"}));assertEquals(java.util.List.of("60"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));for(String invalid:java.util.List.of("0","-1","1.5","bad")){f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds",invalid});assertTrue(sender.messages.getLast().contains("invalid"));}var pendingDead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(pendingDead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1200L,f.delayedTicks.getLast());int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});assertEquals(before,sender.messages.size());var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("=90"));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});((games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters")).find(pendingDead.id()).get();f.tick();assertEquals(1800L,f.delayedTicks.getLast());assertTrue(f.delayedTicks.contains(1200L));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=90"));}
|
||||
var failed=data.resolve("failed-write");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds","90"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","admin-revive-selection-timeout-seconds"});assertTrue(sender.messages.getLast().contains("=60"));}
|
||||
for(String invalid:java.util.List.of("0","-1","1.5","bad")){var dir=data.resolve(invalid.replace('.','_'));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),"admin-revive-selection-timeout-seconds: '"+invalid+"'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
|
||||
@Test void registeredAdminSelectsAnExpiredDirectTailAndRestoresSameCharacterWithoutChangingTargetState()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);f.activate(target);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);tail.setAmount(2);target.core.inventory.setItem(10,tail);var beforeHealth=target.core.health;var beforeMode=target.core.gameMode;var beforeLocation=target.core.location.clone();
|
||||
assertTrue(f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"}));var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.find(dead.id()).get();f.tick();assertEquals(1,admin.inventories.size());assertTrue(target.inventories.isEmpty());var popup=admin.inventories.getFirst();assertEquals(1,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);var click=new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);f.dispatcher.accept(click);assertTrue(click.isCancelled());PartyBookNativeTest.drain(f);((games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals")).find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var restored=storage.find(dead.id()).get().orElseThrow();assertFalse(restored.dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(dead.deathId(),new games.dmg.fantasystick.items.TailBooks().identify(target.core.inventory.getItem(10)).orElseThrow().deathId());assertEquals(restored.maximumHealth(),restored.health());assertEquals(dead.stats(),restored.stats());assertEquals(dead.level(),restored.level());assertEquals(dead.experience(),restored.experience());assertEquals(dead.unspentPoints(),restored.unspentPoints());assertEquals(dead.learned(),restored.learned());assertEquals(dead.loadout(),restored.loadout());assertEquals(dead.cooldowns(),restored.cooldowns());f.dispatcher.accept(click);assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());assertEquals(1,target.core.inventory.getItem(10).getAmount());assertEquals(beforeHealth,target.core.health);assertEquals(beforeMode,target.core.gameMode);assertEquals(beforeLocation,target.core.location);assertEquals(target.core.record.id(),((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).orElseThrow().character());}}
|
||||
@Test void failedCharacterRestoreRetainsOneSheetAndReservationWithoutFalseSuccess()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);var popup=admin.inventories.getFirst();var corrupted=new java.util.concurrent.atomic.AtomicBoolean();target.core.mutation=()->{if(!corrupted.compareAndSet(false,true))return;try{var path=data.resolve("characters").resolve(dead.id()+".yml");java.nio.file.Files.delete(path);java.nio.file.Files.createDirectory(path);}catch(Exception e){throw new RuntimeException(e);}};var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertTrue(revival.find(dead.id()).join().isPresent());assertFalse(admin.messages.stream().anyMatch(message->message.startsWith("Administratively restored")));target.core.mutation=()->{};}}
|
||||
@Test void competingAdministratorsCanConsumeOneDeathOnlyOnce()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("AdminA").when(a.player).getName();doReturn("AdminB").when(b.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(a.player).hasPermission("fantasystick.admin");doReturn(true).when(b.player).hasPermission("fantasystick.admin");f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(target.core.id,target.player);var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));target.core.inventory.setItem(10,new games.dmg.fantasystick.items.TailBooks().create(dead));f.command.execute(a.player,"fantasystick",new String[]{"revive","Target"});f.command.execute(b.player,"fantasystick",new String[]{"revive","Target"});PartyBookNativeTest.drain(f);assertEquals(1,a.inventories.size());assertEquals(1,b.inventories.size());for(var admin:java.util.List.of(a,b)){var popup=admin.inventories.getFirst();var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,0,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));}PartyBookNativeTest.drain(f);var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(dead.id()).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());var sheets=new games.dmg.fantasystick.items.CharacterSheets();assertEquals(1,java.util.stream.IntStream.range(0,41).filter(slot->(slot<=35||slot==40)&&sheets.identify(target.core.inventory.getItem(slot)).filter(dead.id()::equals).isPresent()).count());}}
|
||||
@Test void counterfeitLivingAndLifecycleCancelledSelectionsNeverChangeItems()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(data);var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);target.core.inventory.setItem(10,new org.bukkit.inventory.ItemStack(org.bukkit.Material.WRITTEN_BOOK));f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});assertTrue(admin.messages.getLast().contains("no authentic"));var livingTailRecord=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var livingTail=new games.dmg.fantasystick.items.TailBooks().create(livingTailRecord);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");storage.restoreDeathRecordAdministratively(livingTailRecord.id(),livingTailRecord.deathId(),new java.math.BigDecimal("1024")).get();target.core.inventory.setItem(10,livingTail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(livingTailRecord.id()).get();f.tick();assertTrue(admin.messages.getLast().contains("no current"));var dead=dead(data,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var tail=new games.dmg.fantasystick.items.TailBooks().create(dead);target.core.inventory.setItem(10,tail);f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(dead.id()).get();f.tick();var popup=admin.inventories.getFirst();f.plugin.setEnabled(false);assertNull(popup.getItem(0));assertEquals(tail,target.core.inventory.getItem(10));}}
|
||||
@Test void administrativeReservationResumesAfterRestartWithoutWeakeningDeathIdentity()throws Exception{var target=new FantasyStickLifecycleNativeTest.Actor(data);doReturn("Target").when(target.player).getName();var dead=dead(data,Instant.now().minusSeconds(172800).truncatedTo(ChronoUnit.SECONDS));var provisional=new games.dmg.fantasystick.persistence.CharacterRecord(dead.id(),dead.name(),dead.revision(),dead.stats(),dead.level(),dead.experience(),dead.unspentPoints(),dead.maximumHealth(),dead.learned(),dead.loadout(),dead.cooldowns(),dead.restrictedKills(),false,dead.deathId(),dead.diedAt());target.core.inventory.setItem(10,new games.dmg.fantasystick.items.CharacterSheets().create(provisional));var reservation=new games.dmg.fantasystick.persistence.RevivalStore.Reservation(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),target.core.id,dead.id(),dead.deathId(),Instant.now().truncatedTo(ChronoUnit.SECONDS),10,1,-1,true);try(var store=new games.dmg.fantasystick.persistence.RevivalStore(data)){assertTrue(store.reserve(reservation).join().isPresent());}try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.players.put(target.core.id,target.player);f.plugin.setEnabled(true);var revivals=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revivals.target(target.core.id).join();for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(dead.id()).get().orElseThrow().dead());assertTrue(revivals.find(dead.id()).join().isEmpty());assertEquals(dead.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());}}
|
||||
@Test void multipleChoicesSelectExactItemWhilePermissionLossMovementAndTimeoutCancelWithoutMutation()throws Exception{for(String route:java.util.List.of("select-recent","select-second","permission","movement","timeout")){var dir=data.resolve(route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var admin=new FantasyStickLifecycleNativeTest.Actor(dir);var target=new FantasyStickLifecycleNativeTest.Actor(dir);doReturn("Admin").when(admin.player).getName();doReturn("Target").when(target.player).getName();doReturn(true).when(admin.player).hasPermission("fantasystick.admin");f.players.put(admin.core.id,admin.player);f.players.put(target.core.id,target.player);var first=dead(dir,Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.SECONDS));var second=dead(dir,Instant.now().minusSeconds(86400).truncatedTo(ChronoUnit.SECONDS));var firstTail=new games.dmg.fantasystick.items.TailBooks().create(first);var secondTail=new games.dmg.fantasystick.items.TailBooks().create(second);target.core.inventory.setItem(10,firstTail);target.core.inventory.setItem(11,secondTail);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");f.command.execute(admin.player,"fantasystick",new String[]{"revive","Target"});storage.find(first.id()).get();f.tick();var popup=admin.inventories.getFirst();assertEquals(2,java.util.stream.IntStream.range(0,popup.getSize()).filter(i->popup.getItem(i)!=null).count());assertTrue(f.delayedTicks.contains(1200L));if(route.equals("timeout")){f.delayed.getLast().run();assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(secondTail,target.core.inventory.getItem(11));continue;}var view=mock(org.bukkit.inventory.InventoryView.class);when(view.getTopInventory()).thenReturn(popup);when(view.getBottomInventory()).thenReturn(admin.core.inventory);when(view.getPlayer()).thenReturn(admin.player);if(route.equals("permission"))doReturn(false).when(admin.player).hasPermission("fantasystick.admin");if(route.equals("movement"))target.core.inventory.setItem(11,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));int selected=route.equals("select-recent")?0:1;f.dispatcher.accept(new org.bukkit.event.inventory.InventoryClickEvent(view,org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,selected,org.bukkit.event.inventory.ClickType.LEFT,org.bukkit.event.inventory.InventoryAction.PICKUP_ALL));PartyBookNativeTest.drain(f);if(route.startsWith("select-")){var selectedRecord=route.equals("select-recent")?first:second;var revival=(games.dmg.fantasystick.persistence.RevivalStore)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"revivals");revival.find(selectedRecord.id()).join();for(int i=0;i<3;i++)PartyBookNativeTest.drain(f);assertEquals(!route.equals("select-recent"),storage.find(first.id()).get().orElseThrow().dead());assertEquals(route.equals("select-recent"),storage.find(second.id()).get().orElseThrow().dead());if(route.equals("select-recent")){assertEquals(first.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(10)).orElseThrow());assertEquals(secondTail,target.core.inventory.getItem(11));}else{assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(second.id(),new games.dmg.fantasystick.items.CharacterSheets().identify(target.core.inventory.getItem(11)).orElseThrow());}}else{assertTrue(storage.find(first.id()).get().orElseThrow().dead());assertTrue(storage.find(second.id()).get().orElseThrow().dead());assertEquals(firstTail,target.core.inventory.getItem(10));assertEquals(route.equals("movement")?org.bukkit.Material.DIAMOND:org.bukkit.Material.WRITTEN_BOOK,target.core.inventory.getItem(11).getType());}}}}
|
||||
static games.dmg.fantasystick.persistence.CharacterRecord dead(Path data,Instant died)throws Exception{var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var record=new games.dmg.fantasystick.persistence.CharacterRecord(base.id(),"Ancient Mira",base.revision(),base.stats(),7,BigInteger.valueOf(642),BigInteger.valueOf(11),java.math.BigDecimal.ZERO,java.util.Set.of("fireball"),java.util.List.of("fireball","","","","","",""),java.util.Map.of("fireball",Instant.now().plusSeconds(30)),base.restrictedKills(),true,java.util.UUID.randomUUID(),died);new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);return record;}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.bukkit.craftbukkit.inventory.CraftRecipe;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pinned Purpur recipe-book persistence and placement without starting a listening server/client. */
|
||||
class CharacterSheetRecipeBookNativeTest {
|
||||
private static Server previousServer;
|
||||
private static Server server;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception {
|
||||
NativeRuntime.bootstrap(); previousServer = Bukkit.getServer(); server = mock(Server.class); setServer(server);
|
||||
}
|
||||
@BeforeEach void configure() {
|
||||
reset(server);
|
||||
when(server.getItemFactory()).thenAnswer(call -> CraftItemFactory.instance());
|
||||
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(server.isPrimaryThread()).thenReturn(true);
|
||||
}
|
||||
@AfterAll static void restore() throws Exception { setServer(previousServer); }
|
||||
private static void setServer(Server value) throws Exception {
|
||||
var field = Bukkit.class.getDeclaredField("server"); field.setAccessible(true); field.set(null, value);
|
||||
}
|
||||
|
||||
@Test void nativeBookAwardsOneMiscHighlightAndPreservesItWithUnrelatedState() {
|
||||
var bukkit = new CharacterSheets().recipe(); var nativeRecipe = nativeRecipe(bukkit); var key = key();
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key, nativeRecipe);
|
||||
var events = new ArrayList<org.bukkit.event.Event>(); var manager = mock(PluginManager.class);
|
||||
doAnswer(call -> { events.add(call.getArgument(0)); return null; }).when(manager).callEvent(any());
|
||||
when(server.getPluginManager()).thenReturn(manager);
|
||||
var book = new Book((requested, consumer) -> consumer.accept(new net.minecraft.world.item.crafting.display.RecipeDisplayEntry(
|
||||
new net.minecraft.world.item.crafting.display.RecipeDisplayId(7), nativeRecipe.display().getFirst(),
|
||||
OptionalInt.empty(), nativeRecipe.recipeBookCategory(), Optional.of(nativeRecipe.placementInfo().ingredients()))));
|
||||
var player = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
when(player.getAdvancements()).thenReturn(mock(net.minecraft.server.PlayerAdvancements.class));
|
||||
|
||||
assertEquals(1, book.addRecipes(List.of(holder), player));
|
||||
assertEquals(0, book.addRecipes(List.of(holder), player));
|
||||
assertTrue(book.contains(key));
|
||||
assertEquals(List.of(key), book.pack().highlight());
|
||||
assertEquals(net.minecraft.world.item.crafting.CraftingBookCategory.MISC, nativeRecipe.category());
|
||||
assertEquals(1, events.size());
|
||||
var event = assertInstanceOf(org.bukkit.event.player.PlayerRecipeDiscoverEvent.class, events.getFirst());
|
||||
assertTrue(event.shouldShowNotification());
|
||||
|
||||
var unrelated = nativeKey(new org.bukkit.NamespacedKey("minecraft", "stick"));
|
||||
book.add(unrelated); book.highlight(unrelated);
|
||||
book.setBookSetting(net.minecraft.world.inventory.RecipeBookType.CRAFTING, true, true);
|
||||
var tag = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.encodeStart(net.minecraft.nbt.NbtOps.INSTANCE, book.pack()).getOrThrow();
|
||||
var packed = net.minecraft.stats.ServerRecipeBook.Packed.CODEC.parse(net.minecraft.nbt.NbtOps.INSTANCE, tag).getOrThrow();
|
||||
var restored = new Book(); restored.loadUntrusted(packed, ignored -> true);
|
||||
assertTrue(restored.contains(key)); assertTrue(restored.contains(unrelated));
|
||||
assertEquals(new LinkedHashSet<>(packed.highlight()), new LinkedHashSet<>(restored.pack().highlight()));
|
||||
assertTrue(restored.isOpen(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
assertTrue(restored.isFiltering(net.minecraft.world.inventory.RecipeBookType.CRAFTING));
|
||||
int known = restored.pack().known().size(); int highlighted = restored.pack().highlight().size();
|
||||
restored.add(key);
|
||||
assertEquals(known, restored.pack().known().size()); assertEquals(highlighted, restored.pack().highlight().size());
|
||||
}
|
||||
|
||||
@Test void nativeAutofillPlacesExactIngredientsAndPreviewCannotMintACharacter() {
|
||||
var sheets = new CharacterSheets(); var bukkit = sheets.recipe(); var nativeRecipe = nativeRecipe(bukkit);
|
||||
var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(key(), nativeRecipe);
|
||||
var player = mock(net.minecraft.world.entity.player.Player.class);
|
||||
when(player.level()).thenReturn(mock(net.minecraft.world.level.Level.class));
|
||||
when(player.getBukkitEntity()).thenReturn(mock(org.bukkit.craftbukkit.entity.CraftPlayer.class));
|
||||
var inventory = new net.minecraft.world.entity.player.Inventory(player, new net.minecraft.world.entity.EntityEquipment());
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
var ingredients = List.of(Material.STICK, Material.PAPER, Material.FEATHER, Material.INK_SAC);
|
||||
for (int index = 0; index < ingredients.size(); index++) inventory.setItem(index,
|
||||
CraftItemStack.asNMSCopy(new ItemStack(ingredients.get(index))));
|
||||
var unrelated = new ItemStack(Material.DIAMOND, 7); inventory.setItem(20, CraftItemStack.asNMSCopy(unrelated));
|
||||
var grid = new net.minecraft.world.inventory.TransientCraftingContainer(
|
||||
mock(net.minecraft.world.inventory.AbstractContainerMenu.class), 3, 3, player);
|
||||
grid.setItem(0, CraftItemStack.asNMSCopy(new ItemStack(Material.DIRT, 3)));
|
||||
var slots = java.util.stream.IntStream.range(0, 9)
|
||||
.mapToObj(slot -> new net.minecraft.world.inventory.Slot(grid, slot, 0, 0)).toList();
|
||||
var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess<net.minecraft.world.item.crafting.ShapelessRecipe>() {
|
||||
@Override public void fillCraftSlotsStackedContents(net.minecraft.world.entity.player.StackedItemContents contents) {
|
||||
grid.fillStackedContents(contents);
|
||||
}
|
||||
@Override public void clearCraftingContent() { grid.clearContent(); }
|
||||
@Override public boolean recipeMatches(net.minecraft.world.item.crafting.RecipeHolder<net.minecraft.world.item.crafting.ShapelessRecipe> candidate) {
|
||||
return candidate.value().matches(input(grid), mock(net.minecraft.world.level.Level.class));
|
||||
}
|
||||
};
|
||||
|
||||
var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe(
|
||||
access, 3, 3, slots, slots, inventory, holder, false, false);
|
||||
|
||||
assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action);
|
||||
assertTrue(nativeRecipe.matches(input(grid), mock(net.minecraft.world.level.Level.class)));
|
||||
for (var material : ingredients) assertEquals(1, count(grid, material));
|
||||
assertEquals(unrelated, CraftItemStack.asBukkitCopy(inventory.getItem(20)));
|
||||
assertEquals(3, inventory.getNonEquipmentItems().stream().map(CraftItemStack::asBukkitCopy)
|
||||
.filter(item -> item.getType() == Material.DIRT).mapToInt(ItemStack::getAmount).sum());
|
||||
var before = grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList();
|
||||
var preview = CraftItemStack.asBukkitCopy(nativeRecipe.assemble(input(grid)));
|
||||
assertEquals(Material.PAPER, preview.getType()); assertTrue(sheets.identify(preview).isEmpty());
|
||||
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
|
||||
assertEquals(before, grid.getItems().stream().map(CraftItemStack::asBukkitCopy).toList());
|
||||
}
|
||||
|
||||
private static net.minecraft.world.item.crafting.ShapelessRecipe nativeRecipe(org.bukkit.inventory.ShapelessRecipe recipe) {
|
||||
var ingredients = recipe.getChoiceList().stream().map(choice -> CraftRecipe.toIngredient(choice, false)).toList();
|
||||
return new net.minecraft.world.item.crafting.ShapelessRecipe(
|
||||
new net.minecraft.world.item.crafting.Recipe.CommonInfo(true),
|
||||
new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo(
|
||||
CraftRecipe.getCategory(recipe.getCategory()), recipe.getGroup()),
|
||||
CraftItemStack.asTemplate(recipe.getResult()), ingredients);
|
||||
}
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key() {
|
||||
return nativeKey(CharacterSheets.RECIPE);
|
||||
}
|
||||
private static net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> nativeKey(org.bukkit.NamespacedKey key) {
|
||||
return net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.RECIPE,
|
||||
net.minecraft.resources.Identifier.fromNamespaceAndPath(key.getNamespace(), key.getKey()));
|
||||
}
|
||||
private static net.minecraft.world.item.crafting.CraftingInput input(net.minecraft.world.Container grid) {
|
||||
return net.minecraft.world.item.crafting.CraftingInput.of(3, 3,
|
||||
java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem).toList());
|
||||
}
|
||||
private static long count(net.minecraft.world.Container grid, Material material) {
|
||||
return java.util.stream.IntStream.range(0, grid.getContainerSize()).mapToObj(grid::getItem)
|
||||
.map(CraftItemStack::asBukkitCopy).filter(item -> item.getType() == material).mapToInt(ItemStack::getAmount).sum();
|
||||
}
|
||||
private static final class Book extends net.minecraft.stats.ServerRecipeBook {
|
||||
Book() { this((key, consumer) -> { }); }
|
||||
Book(DisplayResolver resolver) { super(resolver); }
|
||||
void highlight(net.minecraft.resources.ResourceKey<net.minecraft.world.item.crafting.Recipe<?>> key) { highlight.add(key); }
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,16 @@ class CharacterSheetsNativeTest {
|
||||
meta.displayName(Component.text("Different presentation"));
|
||||
sheet.setItemMeta(meta);
|
||||
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
|
||||
assertEquals(CharacterSheets.MODEL, sheet.getItemMeta().getItemModel());
|
||||
var legacy = sheet.clone(); var legacyMeta = legacy.getItemMeta(); legacyMeta.setItemModel(null); legacy.setItemMeta(legacyMeta);
|
||||
assertTrue(sheets.applyModel(legacy)); assertEquals(CharacterSheets.MODEL, legacy.getItemMeta().getItemModel());
|
||||
var fake = new ItemStack(Material.PAPER);
|
||||
var fakeMeta = fake.getItemMeta();
|
||||
fakeMeta.displayName(meta.displayName());
|
||||
fakeMeta.lore(meta.lore());
|
||||
fake.setItemMeta(fakeMeta);
|
||||
assertTrue(sheets.identify(fake).isEmpty());
|
||||
assertFalse(sheets.applyModel(fake)); assertFalse(fake.getItemMeta().hasItemModel());
|
||||
assertTrue(sheets.identify(new ItemStack(Material.STICK)).isEmpty());
|
||||
assertTrue(sheets.identify(null).isEmpty());
|
||||
var version = new NamespacedKey("fantasystick", "sheet_version");
|
||||
@@ -72,6 +76,7 @@ class CharacterSheetsNativeTest {
|
||||
var sheets = new CharacterSheets();
|
||||
var recipe = sheets.recipe();
|
||||
assertEquals(new NamespacedKey("fantasystick", "character_sheet"), recipe.getKey());
|
||||
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(4, recipe.getChoiceList().size());
|
||||
assertTrue(recipe.getChoiceList().stream().allMatch(choice ->
|
||||
choice instanceof RecipeChoice.MaterialChoice materials && materials.getChoices().size() == 1));
|
||||
@@ -84,6 +89,7 @@ class CharacterSheetsNativeTest {
|
||||
assertEquals(1, preview.getAmount());
|
||||
assertTrue(sheets.identify(preview).isEmpty());
|
||||
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
|
||||
assertFalse(preview.getItemMeta().hasItemModel());
|
||||
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
|
||||
Component.text("to enter fantasy world.")), preview.getItemMeta().lore());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,106 @@ class EquipmentAdminCommandNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredGivePersistsOneFreshSheetThenDeliversActivatesAndReloads() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id, target.player);
|
||||
var before = target.core.snapshot(); var sender = new Sender();
|
||||
assertTrue(f.command.execute(sender.port, "fantasystick", new String[]{"give", "Account"}));
|
||||
assertTrue(sender.messages.isEmpty(), "Queued persistence is not successful delivery");
|
||||
((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
|
||||
.find(java.util.UUID.randomUUID()).get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callback = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(callback); callback.run(); callback.run();
|
||||
assertTrue(sender.messages.getLast().contains("Issued one new character sheet"));
|
||||
assertFalse(java.util.Arrays.equals(before, target.core.snapshot()));
|
||||
var sheets = new games.dmg.fantasystick.items.CharacterSheets();
|
||||
var delivered = java.util.stream.IntStream.range(0, 36).mapToObj(target.core.inventory::getItem)
|
||||
.filter(item -> sheets.identify(item).isPresent()).filter(item -> !sheets.identify(item).orElseThrow().equals(target.core.record.id())).toList();
|
||||
assertEquals(1, delivered.size(), "Repeated callback must not duplicate delivery");
|
||||
assertEquals(target.core.record.id(),sheets.identify(target.core.inventory.getItemInOffHand()).orElseThrow());
|
||||
assertTrue(((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(target.core.id).isEmpty());
|
||||
var id = sheets.identify(delivered.getFirst()).orElseThrow();
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var record = storage.find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(java.math.BigInteger.valueOf(40), record.unspentPoints()); assertEquals(1, record.level());
|
||||
assertEquals(java.math.BigInteger.ZERO, record.experience()); assertFalse(record.dead()); assertTrue(record.learned().isEmpty());
|
||||
assertTrue(record.stats().values().stream().allMatch(java.math.BigInteger.ZERO::equals));assertEquals(new java.math.BigDecimal("10"),record.health());
|
||||
assertEquals(java.util.Collections.nCopies(7,""),record.loadout());assertTrue(record.cooldowns().isEmpty());assertFalse(record.name().isBlank());
|
||||
target.core.inventory.remove(delivered.getFirst()); target.core.inventory.setItemInOffHand(delivered.getFirst());
|
||||
f.activate(target); assertEquals(id, ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).active(target.core.id).orElseThrow().character());
|
||||
f.plugin.setEnabled(false); f.plugin.setEnabled(true);
|
||||
assertEquals(record, ((games.dmg.fantasystick.persistence.CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).find(id).get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void giveCompletionAndRejectedExecutionArePermissionAwareStrictAndReadOnly() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var target = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(target.core.id,target.player);
|
||||
var sender=new Sender();
|
||||
assertEquals(List.of("config","give","inspect","recover"),f.command.tabComplete(sender.port,"fantasystick",new String[]{""}));
|
||||
assertEquals(List.of("give"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"g"}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}));
|
||||
assertEquals(List.of("Account"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Acc"}));
|
||||
assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give","Account",""}).isEmpty());
|
||||
sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"give",""}).isEmpty());
|
||||
var original=target.core.snapshot();f.command.getExecutor().onCommand(sender.port,f.command,"fantasystick",new String[]{"give","Account"});
|
||||
sender.allowed=true;for(String[] args:List.of(new String[]{"give"},new String[]{"give","Missing"},new String[]{"give","Account","extra"}))f.command.execute(sender.port,"fantasystick",args);
|
||||
assertArrayEquals(original,target.core.snapshot());assertTrue(f.tasks.isEmpty());
|
||||
assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
|
||||
for(int slot=0;slot<36;slot++)target.core.inventory.setItem(slot,new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
var full=target.core.snapshot();f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});assertTrue(sender.messages.getLast().contains("no inventory space"));
|
||||
assertArrayEquals(full,target.core.snapshot());assertTrue(f.tasks.isEmpty());assertEquals(1,java.nio.file.Files.list(data.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void giveSaveFailureDisconnectSlotRaceAndLifecycleInterruptionNeverDuplicateOrOverwrite() throws Exception {
|
||||
for(String route:List.of("save","disconnect","slot","close")){var dir=data.resolve(route);java.nio.file.Files.createDirectories(dir);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){
|
||||
f.plugin.setEnabled(true);var target=new FantasyStickLifecycleNativeTest.Actor(dir);f.players.put(target.core.id,target.player);var sender=new Sender();var original=target.core.snapshot();
|
||||
var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");
|
||||
var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(storage,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await(5,java.util.concurrent.TimeUnit.SECONDS);}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"give","Account"});
|
||||
if(route.equals("save")){var issuer=FantasyStickLifecycleNativeTest.Fixture.field(f.command.getExecutor(),"characterIssuer");var pending=(java.util.Map<?,?>)FantasyStickLifecycleNativeTest.Fixture.field(issuer,"pending");var attempt=pending.values().iterator().next();var record=(games.dmg.fantasystick.persistence.CharacterRecord)FantasyStickLifecycleNativeTest.Fixture.field(attempt,"record");java.nio.file.Files.createDirectory(dir.resolve("characters").resolve(record.id()+".yml"));}
|
||||
release.countDown();storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);
|
||||
if(route.equals("disconnect"))target.core.online=false;if(route.equals("slot"))target.core.inventory.setItem(1,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));if(route.equals("close"))f.plugin.setEnabled(false);callback.run();
|
||||
if(!route.equals("close"))storage.find(java.util.UUID.randomUUID()).get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertFalse(sender.messages.stream().anyMatch(message->message.startsWith("Issued one")));assertEquals(route.equals("slot")?org.bukkit.Material.DIAMOND:original[1]==null?null:original[1].getType(),target.core.inventory.getItem(1)==null?null:target.core.inventory.getItem(1).getType());
|
||||
if(!route.equals("save"))assertEquals(1,java.nio.file.Files.list(dir.resolve("characters")).filter(path->path.toString().endsWith(".yml")).count());
|
||||
release.countDown();}}
|
||||
}
|
||||
|
||||
@Test void recoveryCompletionIsPermissionAwarePrefixFilteredUuidOnlyAndReadOnly() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var first = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var offline = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
offline.core.online = false;
|
||||
f.players.put(first.core.id, first.player); f.players.put(second.core.id, second.player); f.players.put(offline.core.id, offline.player);
|
||||
var sender = new Sender();
|
||||
var expected = java.util.stream.Stream.of(first.core.id.toString(), second.core.id.toString()).sorted().toList();
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("inspect"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"in"}));
|
||||
assertEquals(List.of("recover"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"rec"}));
|
||||
assertEquals(expected, f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}));
|
||||
String prefix = expected.getFirst().substring(0, 8);
|
||||
assertEquals(expected.stream().filter(id -> id.startsWith(prefix)).toList(),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{"recover", prefix}));
|
||||
assertFalse(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", ""}).contains("Account"));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"inspect", expected.getFirst(), ""}).isEmpty());
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown", ""}).isEmpty());
|
||||
assertEquals(List.of("adventure-mode"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-m"}));
|
||||
assertEquals(List.of("false", "true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", ""}));
|
||||
assertEquals(List.of("true"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "t"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "adventure-mode", "true", ""}).isEmpty());
|
||||
sender.allowed = false;
|
||||
for (String[] args : List.of(new String[]{""}, new String[]{"inspect", ""}, new String[]{"config", ""},
|
||||
new String[]{"config", "adventure-mode", ""})) {
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", args).isEmpty());
|
||||
}
|
||||
assertTrue(f.tasks.isEmpty()); assertFalse(Files.exists(data.resolve("sessions")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void adventureConfigurationCommandReadsValidatesPersistsAndReopensWithoutTouchingPlayers() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
@@ -73,6 +73,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(fixture.order.indexOf("BasicAttack") < recipe);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") >= 0);
|
||||
assertTrue(fixture.order.indexOf("NutritionPolicy") < recipe);
|
||||
assertTrue(fixture.order.indexOf("RecipeDiscovery") > recipe,
|
||||
"Discovery must not become active before successful recipe registration");
|
||||
assertNotNull(fixture.command);
|
||||
assertEquals("EquipmentAdminCommand", fixture.command.getExecutor().getClass().getSimpleName());
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
@@ -138,7 +140,7 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test
|
||||
void recipeCollisionAndPartialListenerRegistrationFailureRollbackOnlyThisEnableAttempt() throws Exception {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer")) {
|
||||
for (String stage : List.of("collision", "recipe", "ManualCraftingGuard", "CharacterAnvilNames", "EquipmentSessionEvents", "CharacterChatNames", "ProtocolPresentation", "CombatExperience", "Fireball", "presentation-timer", "RecipeDiscovery")) {
|
||||
boolean collision = stage.equals("collision");
|
||||
try (var fixture = new Fixture(data)) {
|
||||
var existing = new org.bukkit.inventory.ShapelessRecipe(CharacterSheets.RECIPE,
|
||||
@@ -173,6 +175,10 @@ class FantasyStickLifecycleNativeTest {
|
||||
try (var fixture = new Fixture(directory)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
((games.dmg.fantasystick.crafting.RecipeDiscovery) Fixture.field(fixture.plugin, "recipeDiscovery"))
|
||||
.discover(actor.player);
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), actor.knownRecipes);
|
||||
assertEquals(1, actor.recipeAwards);
|
||||
assertFalse(java.nio.file.Files.exists(directory.resolve("characters")));
|
||||
assertTrue(new CharacterSheets().identify(fixture.recipes.get(CharacterSheets.RECIPE).getResult()).isEmpty());
|
||||
var cancelled = actor.fixture.event(org.bukkit.event.inventory.ClickType.LEFT,
|
||||
@@ -1304,6 +1310,9 @@ class FantasyStickLifecycleNativeTest {
|
||||
int inventoryCloses;
|
||||
final List<Integer> particles = new ArrayList<>();
|
||||
final List<org.bukkit.Sound> sounds = new ArrayList<>();
|
||||
final List<Object[]> resourcePacks = new ArrayList<>();
|
||||
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
|
||||
final List<List<org.bukkit.NamespacedKey>> recipeAwards = new ArrayList<>();
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeMap healthAttributes = new net.minecraft.world.entity.ai.attributes.AttributeMap(
|
||||
net.minecraft.world.entity.ai.attributes.AttributeSupplier.builder().add(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).build());
|
||||
final net.minecraft.world.entity.ai.attributes.AttributeInstance maximumHealth = java.util.Objects.requireNonNull(
|
||||
@@ -1370,6 +1379,15 @@ class FantasyStickLifecycleNativeTest {
|
||||
case "closeInventory" -> { inventoryCloses++; yield null; }
|
||||
case "spawnParticle" -> { particles.add((Integer) args[2]); yield null; }
|
||||
case "playSound" -> { if (args.length > 1 && args[1] instanceof org.bukkit.Sound sound) sounds.add(sound); yield null; }
|
||||
case "addResourcePack" -> { resourcePacks.add(args.clone()); yield null; }
|
||||
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
var requested = ((java.util.Collection<?>) args[0]).stream()
|
||||
.map(org.bukkit.NamespacedKey.class::cast).toList();
|
||||
recipeAwards.add(requested); int before = knownRecipes.size(); knownRecipes.addAll(requested);
|
||||
yield knownRecipes.size() - before;
|
||||
}
|
||||
case "undiscoverRecipes" -> throw new AssertionError("Recipe discovery must not be revoked");
|
||||
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -348,7 +348,7 @@ class LongbowNativeTest {
|
||||
assertThrows(AssertionError.class, () -> abilityButton(unavailable, "[Learn]", "longbow"));
|
||||
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(java.util.List.of("config", "inspect", "recover"),
|
||||
assertEquals(java.util.List.of("config", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(java.util.List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"c"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unknown"}).isEmpty());
|
||||
|
||||
@@ -424,6 +424,8 @@ class ManualCraftingControllerNativeTest {
|
||||
org.bukkit.Location eyeLocation;
|
||||
boolean online = true;
|
||||
int syncs;
|
||||
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
|
||||
int recipeAwards;
|
||||
Runnable onSync = () -> { };
|
||||
|
||||
Actor(int width, int amount) {
|
||||
@@ -438,6 +440,14 @@ class ManualCraftingControllerNativeTest {
|
||||
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
|
||||
case "isOnline" -> online;
|
||||
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
|
||||
case "getName" -> "CraftingActor";
|
||||
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
recipeAwards++; int before = knownRecipes.size();
|
||||
((java.util.Collection<?>) args[0]).stream().map(org.bukkit.NamespacedKey.class::cast)
|
||||
.forEach(knownRecipes::add);
|
||||
yield knownRecipes.size() - before;
|
||||
}
|
||||
case "equals" -> object == args[0];
|
||||
case "hashCode" -> System.identityHashCode(object);
|
||||
case "toString" -> "Crafting actor fixture";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class PartyCapacityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredPolicyDrivesActualBookInvitationsAcceptanceGrandfatheringAndRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));assertEquals(java.util.List.of("6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-max-size",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"2");var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);PartyBookNativeTest.open(f,leader);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Create party]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));PartyBookNativeTest.open(f,member);PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Invitations]"));PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Accept]"));PartyBookNativeTest.drain(f);var parties=PartyLifecycleNativeTest.parties(f);assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyBookNativeTest.text(member).contains("2/2 members"));PartyBookNativeTest.open(f,leader);assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));save(f,sender,"3");var third=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(third.core.id,third.player);f.activate(third);PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("2/3 members"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));assertEquals(1,parties.invitationsFor(third.core.id).size());save(f,sender,"2");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertTrue(parties.partyOf(third.core.id).isEmpty());assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"3");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyCombatNativeTest.hit(f,leader.player,member.player).isCancelled());PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("3/2 members"));assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=2"));assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).isEmpty());}}
|
||||
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party,PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class QuickstepNativeTest {
|
||||
assertEquals(0, new java.math.BigDecimal("6").compareTo(definition.parameters().get("distance")));
|
||||
assertEquals(0, new java.math.BigDecimal("1.5").compareTo(definition.parameters().get("speed")));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
assertEquals(List.of("config"), f.command.tabComplete(sender.port, "fantasystick", new String[]{"co"}));
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"unsupported"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class RecipeDiscoveryNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void packagedRegistrationDiscoversOnlineAndJoinPlayersOnceWithoutMintingAndRetainsKnowledge() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
var online = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(online.core.id, online.player);
|
||||
long records = records(data);
|
||||
|
||||
f.plugin.setEnabled(true);
|
||||
|
||||
var recipe = (org.bukkit.inventory.ShapelessRecipe) f.recipes.get(CharacterSheets.RECIPE);
|
||||
assertNotNull(recipe);
|
||||
assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.MISC, recipe.getCategory());
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), online.knownRecipes);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
|
||||
assertEquals(records, records(data));
|
||||
assertTrue(new CharacterSheets().identify(recipe.getResult()).isEmpty());
|
||||
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(joining.core.id, joining.player);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined"));
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "joined again"));
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
assertEquals(records + 1, records(data));
|
||||
|
||||
f.plugin.setEnabled(false);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "disabled"));
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
|
||||
f.plugin.setEnabled(true);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("player-failure"))) {
|
||||
var failing = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
|
||||
var healthy = new FantasyStickLifecycleNativeTest.Actor(data.resolve("player-failure"));
|
||||
var fail = new AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
|
||||
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
|
||||
.map(org.bukkit.NamespacedKey.class::cast).toList();
|
||||
failing.recipeAwards.add(requested); failing.knownRecipes.addAll(requested); return requested.size();
|
||||
}).when(failing.player).discoverRecipes(anyCollection());
|
||||
f.players.put(failing.core.id, failing.player); f.players.put(healthy.core.id, healthy.player);
|
||||
var logs = new java.util.ArrayList<LogRecord>();
|
||||
var handler = new Handler() {
|
||||
@Override public void publish(LogRecord record) { logs.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
};
|
||||
f.plugin.getLogger().addHandler(handler);
|
||||
try {
|
||||
f.plugin.setEnabled(true);
|
||||
assertTrue(f.recipes.containsKey(CharacterSheets.RECIPE));
|
||||
assertTrue(failing.knownRecipes.isEmpty());
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), healthy.knownRecipes);
|
||||
assertTrue(logs.stream().anyMatch(log -> log.getMessage().contains("discover character-sheet recipe")));
|
||||
f.dispatcher.accept(new PlayerJoinEvent(failing.player, "retry"));
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
|
||||
} finally { f.plugin.getLogger().removeHandler(handler); }
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
|
||||
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
|
||||
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
|
||||
assertThrows(IllegalStateException.class, () -> f.plugin.setEnabled(true));
|
||||
assertTrue(online.knownRecipes.isEmpty());
|
||||
assertTrue(online.recipeAwards.isEmpty());
|
||||
assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private static long records(Path directory) throws Exception {
|
||||
var characters = directory.resolve("characters");
|
||||
if (!java.nio.file.Files.isDirectory(characters)) return 0;
|
||||
try (var files = java.nio.file.Files.list(characters)) {
|
||||
return files.filter(file -> file.getFileName().toString().endsWith(".yml")).count();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class RoleplayAvailabilityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredToggleExitsEveryActiveSessionBlocksEntryAndAllowsRecoveryThenLaterEntry()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("roleplay-disabled"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}));assertEquals(java.util.List.of("false","true"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));for(String bad:java.util.List.of("TRUE","1","yes","")){f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",bad});assertTrue(sender.messages.getLast().contains("true|false"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","rolep"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true","extra"});assertTrue(sender.messages.getLast().contains("Usage"));assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true",""}).isEmpty());var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);var ordinary=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.players.put(ordinary.core.id,ordinary.player);var aBefore=a.core.snapshot();var bBefore=b.core.snapshot();var ordinaryBefore=ordinary.core.snapshot();assertDoesNotThrow(()->f.activate(a),"initial A activation");assertDoesNotThrow(()->f.activate(b),"initial B activation");a.core.health=4;save(f,sender,"true");f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertTrue(sessions.active(b.core.id).isEmpty());assertArrayEquals(aBefore,a.core.snapshot());assertArrayEquals(bBefore,b.core.snapshot());assertArrayEquals(ordinaryBefore,ordinary.core.snapshot());assertTrue(a.core.online&&b.core.online);var storage=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(0,new java.math.BigDecimal("4").compareTo(storage.find(a.core.record.id()).toCompletableFuture().get().orElseThrow().health()));var c=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(c.core.id,c.player);var cBefore=c.core.snapshot();f.changed(c);PartyBookNativeTest.drain(f);assertTrue(sessions.active(c.core.id).isEmpty());assertArrayEquals(cBefore,c.core.snapshot());assertTrue(c.messages.stream().anyMatch(message->message.toLowerCase().contains("disabled")));
|
||||
var recovery=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(recovery.core.id,recovery.player);var recoveryBefore=recovery.core.snapshot();var session=java.util.UUID.randomUUID();f.receipts().prepare(recovery.core.id,session,recovery.core.record.id()).toCompletableFuture().get();assertEquals(games.dmg.fantasystick.items.EquipmentStorage.PackResult.PACKED,new games.dmg.fantasystick.items.EquipmentStorage().pack(recovery.core.inventory,recovery.core.id,session));recovery.core.marker(session,"PACKED");f.command.execute(sender.port,"fantasystick",new String[]{"recover",recovery.core.id.toString()});f.tick();assertArrayEquals(recoveryBefore,recovery.core.snapshot());save(f,sender,"false");var policy=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");assertFalse(policy.disabled());f.reenter(c);assertEquals(c.core.record.id(),sessions.active(c.core.id).orElseThrow().character());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=false"));}}
|
||||
@Test void reenableWaitsForAcceptedRoleplayWriteAndOldLifetimeCannotReplayExit()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var old=(games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(old,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});f.plugin.setEnabled(false);f.plugin.setEnabled(true);assertNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));release.countDown();f.tick();assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions"));assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);assertArrayEquals(before,a.core.snapshot());}}
|
||||
@Test void startupLoadsDisabledPolicyAndInvalidTypedValuesFailBeforeRegistration()throws Exception{var enabled=data.resolve("enabled");java.nio.file.Files.createDirectories(enabled);java.nio.file.Files.writeString(enabled.resolve("config.yml"),"roleplay-disabled: true\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(enabled)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(enabled);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.changed(a);PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled"});assertTrue(sender.messages.getLast().contains("=true"));}var invalid=data.resolve("invalid");java.nio.file.Files.createDirectories(invalid);java.nio.file.Files.writeString(invalid.resolve("config.yml"),"roleplay-disabled: 'true'\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(invalid)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());assertTrue(f.tasks.isEmpty());}}
|
||||
@Test void failedRestorationRetainsEvidenceWhileDisabled()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);int box=java.util.stream.IntStream.range(9,36).filter(slot->a.core.inventory.getItem(slot)!=null&&a.core.inventory.getItem(slot).getType()==org.bukkit.Material.SHULKER_BOX).findFirst().orElseThrow();a.core.inventory.setItem(box,null);var before=a.core.snapshot();save(f,sender,"true");var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());var state=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();assertNotNull(state.pending());assertNotNull(state.lastExit());assertTrue(((games.dmg.fantasystick.persistence.RoleplaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"roleplaySettings")).disabled());assertTrue(a.core.online);}}
|
||||
@Test void acknowledgedDisableCancelsPendingEntryAndFailedSaveChangesNothing()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var before=a.core.snapshot();var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(characters,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException e){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));f.changed(a);save(f,sender,"true");release.countDown();characters.find(java.util.UUID.randomUUID()).toCompletableFuture().get();PartyBookNativeTest.drain(f);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isEmpty());assertArrayEquals(before,a.core.snapshot());f.plugin.setEnabled(false);}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);f.activate(a);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled","true"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.active(a.core.id).isPresent());}}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","roleplay-disabled",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SecondWindNativeTest {
|
||||
assertEquals(BigInteger.TEN.pow(500).subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(6)),
|
||||
games.dmg.fantasystick.ability.SecondWind.healing(BigInteger.TEN.pow(500)));
|
||||
var sender = new EquipmentAdminCommandNativeTest.Sender();
|
||||
assertEquals(List.of("config", "inspect", "recover"),
|
||||
assertEquals(List.of("config", "give", "inspect", "recover"),
|
||||
f.command.tabComplete(sender.port, "fantasystick", new String[]{""}));
|
||||
sender.allowed = false;
|
||||
assertTrue(f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "second"}).isEmpty());
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*;import java.nio.file.Path;import java.util.*;import net.kyori.adventure.text.Component;import org.bukkit.event.player.PlayerResourcePackStatusEvent;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SheetResourcePackNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
record Request(UUID id,String url,byte[] hash,String prompt,boolean required){}
|
||||
@Test void packagedStartupJoinAndStatusesUseOptionalNativeDeliveryWithoutGameplayMutation()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){var a=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(null);f.players.put(a.core.id,a.player);var before=a.core.snapshot();f.plugin.setEnabled(true);f.awaitCharacterAndReceipt(a);PartyBookNativeTest.drain(f);assertEquals(1,a.resourcePacks.size());var request=request(a.resourcePacks.getFirst());assertEquals(games.dmg.fantasystick.presentation.SheetResourcePack.ID,request.id());assertTrue(request.url().contains("/v0.1.0-SNAPSHOT/purpur-fantasy-stick-0.1.0-SNAPSHOT-resource-pack.zip"));assertEquals(20,request.hash().length);assertFalse(request.required());assertArrayEquals(before,a.core.snapshot());for(var status:List.of(PlayerResourcePackStatusEvent.Status.DECLINED,PlayerResourcePackStatusEvent.Status.FAILED_DOWNLOAD,PlayerResourcePackStatusEvent.Status.INVALID_URL,PlayerResourcePackStatusEvent.Status.FAILED_RELOAD,PlayerResourcePackStatusEvent.Status.DISCARDED)){f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),status));assertTrue(a.messages.getLast().toLowerCase().contains("cosmetic"));assertArrayEquals(before,a.core.snapshot());}int messages=a.messages.size();f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.ACCEPTED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DOWNLOADED));f.dispatcher.accept(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.SUCCESSFULLY_LOADED));assertEquals(messages,a.messages.size());var active=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(active.core.id,active.player);var legacyMeta=active.core.inventory.getItemInOffHand().getItemMeta();legacyMeta.setItemModel(null);active.core.inventory.getItemInOffHand().setItemMeta(legacyMeta);var activeBefore=active.core.snapshot();var expectedMeta=activeBefore[40].getItemMeta();expectedMeta.setItemModel(games.dmg.fantasystick.items.CharacterSheets.MODEL);activeBefore[40].setItemMeta(expectedMeta);var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.enter(active.player);awaitPacked(f,active);assertEquals(games.dmg.fantasystick.items.CharacterSheets.MODEL,active.core.inventory.getItemInOffHand().getItemMeta().getItemModel());sessions.exit(active.player);f.awaitCharacterAndReceipt(active);assertArrayEquals(activeBefore,active.core.snapshot());var retained=(games.dmg.fantasystick.presentation.SheetResourcePack)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sheetResourcePack");f.plugin.setEnabled(false);retained.status(new PlayerResourcePackStatusEvent(a.player,request.id(),PlayerResourcePackStatusEvent.Status.DECLINED));assertEquals(messages,a.messages.size());f.plugin.setEnabled(true);var late=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(late.core.id,late.player);f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(late.player,(Component)null));assertEquals(1,late.resourcePacks.size());}}
|
||||
@Test void invalidStartupAndFailedRegisteredWriteNeverDeliverOrChangeEffectiveDefaults()throws Exception{int route=0;for(String value:List.of("sheet-resource-pack-url: 'http://bad.test/x.zip'\n","sheet-resource-pack-sha1: bad\n")){var dir=data.resolve("invalid-"+route++);java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),value);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty(),"native registration order "+f.order);assertTrue(f.tasks.isEmpty(),"queued tasks");}}var failed=data.resolve("failed");java.nio.file.Files.createDirectories(failed);try(var f=new FantasyStickLifecycleNativeTest.Fixture(failed)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(failed);f.players.put(a.core.id,a.player);java.nio.file.Files.createDirectory(failed.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"),sender.messages::toString);assertTrue(a.resourcePacks.isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("git.garvis.dev"));}}
|
||||
@Test void registeredConfigurationDurablyPublishesAndRedeliversWhileFailureRetainsPair()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(List.of("sheet-resource-pack-sha1","sheet-resource-pack-url"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}));assertEquals(1,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url",""}).size());sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","sheet-r"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/sheet.zip"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url","https://packs.example.test/x.zip","extra"});assertTrue(sender.messages.getLast().contains("Usage"));for(var pair:List.of(new String[]{"sheet-resource-pack-url","http://bad.test/x.zip"},new String[]{"sheet-resource-pack-sha1","bad"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",pair[0],pair[1]});assertTrue(sender.messages.getLast().contains("invalid"));}save(f,sender,"sheet-resource-pack-url","https://packs.example.test/sheet.zip");assertEquals(1,a.resourcePacks.size());assertEquals("https://packs.example.test/sheet.zip",request(a.resourcePacks.getLast()).url());save(f,sender,"sheet-resource-pack-sha1","a".repeat(40));assertEquals(2,a.resourcePacks.size());assertArrayEquals(java.util.HexFormat.of().parseHex("a".repeat(40)),request(a.resourcePacks.getLast()).hash());f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","sheet-resource-pack-url"});assertTrue(sender.messages.getLast().contains("https://packs.example.test/sheet.zip"));}}
|
||||
static void awaitPacked(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor actor)throws Exception{long deadline=System.nanoTime()+java.util.concurrent.TimeUnit.SECONDS.toNanos(10);while(System.nanoTime()<deadline){var marker=actor.core.marker();if(marker!=null&&marker.endsWith("|PACKED"))return;var task=f.tasks.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();}fail("Session did not activate; notices="+actor.messages);}
|
||||
static Request request(Object[] args){return new Request((UUID)args[0],(String)args[1],(byte[])args[2],(String)args[3],(Boolean)args[4]);}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String key,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config",key,value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("Saved "+key));}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import java.time.Duration;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SwitchDelayAdminNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredCommandDurablyChangesActualExitAndEntryPolicyWithoutRewritingDeadlines()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("character-swap-cooldown-seconds"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-s"}));assertEquals(java.util.List.of("900"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","character"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;for(String bad:java.util.List.of("-1","1.5","bad","9223372036854775807")){f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",bad});assertTrue(sender.messages.getLast().contains("invalid"),bad+" -> "+sender.messages);}f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","37","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"37");
|
||||
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);sessions.exit(a.player);sessions.exit(b.player);f.awaitCharacterAndReceipt(a);f.awaitCharacterAndReceipt(b);var aExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();var bExit=f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(aExit.exitedAt(),aExit.deadline()));assertEquals(Duration.ofSeconds(37),Duration.between(bExit.exitedAt(),bExit.deadline()));var otherA=new FantasyStickLifecycleNativeTest.Actor(data);var otherB=new FantasyStickLifecycleNativeTest.Actor(data);a.core.inventory.setItemInOffHand(otherA.core.inventory.getItemInOffHand().clone());b.core.inventory.setItemInOffHand(otherB.core.inventory.getItemInOffHand().clone());save(f,sender,"0");f.activate(a);assertEquals(otherA.core.record.id(),sessions.active(a.core.id).orElseThrow().character());assertEquals(aExit,f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit());save(f,sender,"900");var policy=(games.dmg.fantasystick.persistence.SwitchDelaySettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchDelaySettings");assertEquals(Duration.ofSeconds(900),policy.delay());assertFalse(f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().canEnter(otherB.core.record.id(),policy.delay(),java.time.Instant.now()));assertTrue(sessions.active(b.core.id).isEmpty(),"B remained active after exit");f.changed(b);f.receipts().find(b.core.id).toCompletableFuture().get();PartyBookNativeTest.drain(f);assertTrue(sessions.active(b.core.id).isEmpty(),b.messages::toString);assertEquals(bExit,f.receipts().find(b.core.id).toCompletableFuture().get().orElseThrow().lastExit());sessions.exit(a.player);f.awaitCharacterAndReceipt(a);var newExit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(900),Duration.between(newExit.exitedAt(),newExit.deadline()));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
|
||||
@Test void exitRetainsPolicyCapturedWhenItsNativeTransitionStarts()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();save(f,sender,"37");var a=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(a.core.id,a.player);f.activate(a);var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var worker=(java.util.concurrent.ExecutorService)FantasyStickLifecycleNativeTest.Fixture.field(characters,"worker");var entered=new java.util.concurrent.CountDownLatch(1);var release=new java.util.concurrent.CountDownLatch(1);worker.execute(()->{entered.countDown();try{release.await();}catch(InterruptedException interrupted){Thread.currentThread().interrupt();}});assertTrue(entered.await(5,java.util.concurrent.TimeUnit.SECONDS));var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(a.player);save(f,sender,"0");release.countDown();f.awaitCharacterAndReceipt(a);var exit=f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow().lastExit();assertEquals(Duration.ofSeconds(37),Duration.between(exit.exitedAt(),exit.deadline()));}}
|
||||
@Test void failedRegisteredWriteLeavesEffectivePolicyAndUnrelatedStateUntouched()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);java.nio.file.Files.createDirectory(data.resolve("config.yml"));var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds","0"});var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("save failed"));f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds"});assertTrue(sender.messages.getLast().contains("=900"));}}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","character-swap-cooldown-seconds",value});assertEquals(before,sender.messages.size());var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertTrue(sender.messages.getLast().contains("="+value));}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:select",
|
||||
"property": "minecraft:display_context",
|
||||
"cases": [
|
||||
{
|
||||
"when": [
|
||||
"firstperson_lefthand",
|
||||
"firstperson_righthand"
|
||||
],
|
||||
"model": {
|
||||
"type": "minecraft:empty"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"type": "minecraft:model",
|
||||
"model": "minecraft:item/paper"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Fantasy Stick ${version} sheet presentation",
|
||||
"min_format": [88, 0],
|
||||
"max_format": [88, 0]
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.zip.ZipFile;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -81,6 +82,9 @@ class DistributionTest {
|
||||
"inspire-cooldown-seconds: 15", "inspire-range-blocks: 12", "inspire-duration-seconds: 6")) {
|
||||
assertTrue(defaults.contains(setting), "Missing packaged Inspire default: " + setting);
|
||||
}
|
||||
assertTrue(defaults.contains("admin-revive-selection-timeout-seconds: 60"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-url: ''"));
|
||||
assertTrue(defaults.contains("sheet-resource-pack-sha1: ''"));
|
||||
assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class));
|
||||
var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class");
|
||||
assertNotNull(entrypoint);
|
||||
@@ -94,4 +98,38 @@ class DistributionTest {
|
||||
"Server API and test dependencies must not ship in the plugin");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void packagesVersionedContextSensitiveSheetResourcePackAndMatchingRuntimeMetadata() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
Path pack = Path.of(System.getProperty("resource.pack.zip"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-fantasy-stick-" + version + "-resource-pack.zip", pack.getFileName().toString());
|
||||
byte[] bytes = java.nio.file.Files.readAllBytes(pack);
|
||||
String sha1 = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-1").digest(bytes));
|
||||
try (var zip = new ZipFile(pack.toFile())) {
|
||||
var manifest = zip.getEntry("pack.mcmeta"); assertNotNull(manifest);
|
||||
String manifestJson; try (var input = zip.getInputStream(manifest)) { manifestJson = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); }
|
||||
assertTrue(manifestJson.contains("\"min_format\": [88, 0]"));
|
||||
assertTrue(manifestJson.contains("\"max_format\": [88, 0]"));
|
||||
assertTrue(manifestJson.contains(version));
|
||||
var model = zip.getEntry("assets/fantasystick/items/character_sheet.json");
|
||||
assertNotNull(model);
|
||||
String json;
|
||||
try (var input = zip.getInputStream(model)) { json = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); }
|
||||
assertTrue(json.contains("minecraft:select"));
|
||||
assertTrue(json.contains("minecraft:display_context"));
|
||||
assertTrue(json.contains("firstperson_lefthand"));
|
||||
assertTrue(json.contains("firstperson_righthand"));
|
||||
assertTrue(json.contains("minecraft:empty"));
|
||||
assertTrue(json.contains("minecraft:item/paper"), "Inventory and other contexts retain Paper presentation");
|
||||
}
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
var metadata = jar.getJarEntry("sheet-resource-pack.properties"); assertNotNull(metadata);
|
||||
var values = new java.util.Properties();
|
||||
try (var input = jar.getInputStream(metadata)) { values.load(input); }
|
||||
assertEquals(sha1, values.getProperty("sha1"));
|
||||
assertEquals("https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v" + version
|
||||
+ "/purpur-fantasy-stick-" + version + "-resource-pack.zip", values.getProperty("url"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package games.dmg.fantasystick.crafting;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecipeDiscoveryTest {
|
||||
@Test void onlineJoinRepeatedFailureAndRetryAreIsolatedAndIdempotent() {
|
||||
var warnings = new ArrayList<LogRecord>();
|
||||
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
});
|
||||
var firstKnown = new LinkedHashSet<NamespacedKey>(); var firstAwards = new ArrayList<List<NamespacedKey>>();
|
||||
var secondKnown = new LinkedHashSet<NamespacedKey>(); var secondAwards = new ArrayList<List<NamespacedKey>>();
|
||||
var fail = new AtomicBoolean(true);
|
||||
var first = player("First", firstKnown, firstAwards, fail);
|
||||
var second = player("Second", secondKnown, secondAwards, new AtomicBoolean(false));
|
||||
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
|
||||
|
||||
discovery.discoverAll(List.of(first, second));
|
||||
discovery.onJoin(new PlayerJoinEvent(first, "joined"));
|
||||
discovery.onJoin(new PlayerJoinEvent(first, "joined again"));
|
||||
discovery.discover(second);
|
||||
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), firstKnown);
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), secondKnown);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), firstAwards);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), secondAwards);
|
||||
assertEquals(1, warnings.size());
|
||||
assertTrue(warnings.getFirst().getMessage().contains("First"));
|
||||
}
|
||||
|
||||
@Test void missingNativePostconditionIsLoggedAndRetried() {
|
||||
var warnings = new ArrayList<LogRecord>();
|
||||
var logger = Logger.getAnonymousLogger(); logger.setUseParentHandlers(false);
|
||||
logger.addHandler(new Handler() {
|
||||
@Override public void publish(LogRecord record) { warnings.add(record); }
|
||||
@Override public void flush() { }
|
||||
@Override public void close() { }
|
||||
});
|
||||
var known = new LinkedHashSet<NamespacedKey>(); var awards = new ArrayList<List<NamespacedKey>>();
|
||||
var omit = new AtomicBoolean(true);
|
||||
var player = proxy("Ada", known, awards, omit);
|
||||
var discovery = new RecipeDiscovery(CharacterSheets.RECIPE, logger);
|
||||
|
||||
discovery.discover(player);
|
||||
discovery.discover(player);
|
||||
|
||||
assertEquals(Set.of(CharacterSheets.RECIPE), known);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE), List.of(CharacterSheets.RECIPE)), awards);
|
||||
assertEquals(1, warnings.size());
|
||||
}
|
||||
|
||||
private static Player player(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
AtomicBoolean fail) {
|
||||
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getName")) return name;
|
||||
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
|
||||
if (method.getName().equals("discoverRecipes")) {
|
||||
if (fail.getAndSet(false)) throw new IllegalStateException("simulated discovery failure");
|
||||
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested); known.addAll(requested); return requested.size();
|
||||
}
|
||||
if (method.getName().equals("toString")) return name;
|
||||
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
|
||||
if (method.getName().equals("equals")) return proxy == args[0];
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static Player proxy(String name, Set<NamespacedKey> known, List<List<NamespacedKey>> awards,
|
||||
AtomicBoolean omitFirst) {
|
||||
return (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class<?>[]{Player.class}, (proxy, method, args) -> {
|
||||
if (method.getName().equals("getName")) return name;
|
||||
if (method.getName().equals("hasDiscoveredRecipe")) return known.contains(args[0]);
|
||||
if (method.getName().equals("discoverRecipes")) {
|
||||
var requested = ((Collection<?>) args[0]).stream().map(NamespacedKey.class::cast).toList();
|
||||
awards.add(requested); if (!omitFirst.getAndSet(false)) known.addAll(requested); return requested.size();
|
||||
}
|
||||
if (method.getName().equals("toString")) return name;
|
||||
if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
|
||||
if (method.getName().equals("equals")) return proxy == args[0];
|
||||
throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,28 @@ class PartyRosterTest {
|
||||
assertEquals(6, parties.partyOf(leader).orElseThrow().members().size());
|
||||
}
|
||||
|
||||
@Test void liveCapacityGrandfathersExistingMembersAndRechecksPendingInvitations() {
|
||||
var limit = new java.util.concurrent.atomic.AtomicInteger(3);
|
||||
try (var dynamic = new PartyRoster(clock, online::contains, limit::get)) {
|
||||
UUID leader = player(), first = player(), second = player(), pending = player(), later = player();
|
||||
var party = dynamic.create(leader).orElseThrow();
|
||||
assertTrue(dynamic.accept(first, dynamic.invite(leader, party.id(), first).orElseThrow().id()));
|
||||
var pendingOffer = dynamic.invite(leader, party.id(), pending).orElseThrow();
|
||||
assertTrue(dynamic.accept(second, dynamic.invite(leader, party.id(), second).orElseThrow().id()));
|
||||
limit.set(2);
|
||||
assertEquals(3, dynamic.partyOf(leader).orElseThrow().members().size());
|
||||
assertFalse(dynamic.accept(pending, pendingOffer.id()));
|
||||
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty());
|
||||
assertTrue(dynamic.leave(first, party.id()));
|
||||
assertTrue(dynamic.invite(leader, party.id(), later).isEmpty(), "At the limit cannot grow");
|
||||
assertTrue(dynamic.leave(second, party.id()));
|
||||
assertTrue(dynamic.accept(later, dynamic.invite(leader, party.id(), later).orElseThrow().id()));
|
||||
limit.set(4);
|
||||
assertEquals(4, dynamic.maximumSize());
|
||||
assertTrue(dynamic.invite(leader, party.id(), pending).isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void onlyTheCurrentOnlineLeaderCanKickOrDisbandTheMatchingParty() {
|
||||
UUID leader = player(), member = player(), stranger = player();
|
||||
var party = parties.create(leader).orElseThrow();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class AdminReviveSettingsTest{@TempDir Path data;
|
||||
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedUpdates()throws Exception{assertEquals(Duration.ofSeconds(60),AdminReviveSettings.parse(null));for(Object bad:new Object[]{0,-1,"1.5","no",31536001L})assertThrows(IllegalArgumentException.class,()->AdminReviveSettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nadmin-revive-selection-timeout-seconds: 60\n");try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){var update=settings.set("90").toCompletableFuture().get();assertEquals("60",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("90",settings.value());assertEquals(Duration.ofSeconds(90),settings.timeout());settings.publish(update);assertEquals("90",settings.value());}}
|
||||
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new AdminReviveSettings(data,Duration.ofSeconds(60))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("90").toCompletableFuture().get());assertEquals("60",settings.value());}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class PartySettingsTest{@TempDir Path data;
|
||||
@Test void validatesPersistsPreservesUnrelatedAndRejectsStalePublication()throws Exception{assertEquals(6,PartySettings.parse(null));assertEquals(1,PartySettings.parse(1));assertEquals(Integer.MAX_VALUE,PartySettings.parse(Integer.MAX_VALUE));for(Object bad:new Object[]{0,-1,"1",1.5,"2147483648"})assertThrows(IllegalArgumentException.class,()->PartySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nparty-max-size: 6\n");try(var settings=new PartySettings(data,6)){var one=settings.set("2").toCompletableFuture().get();var two=settings.set("3").toCompletableFuture().get();assertEquals(6,settings.maximum());settings.publish(two);settings.publish(one);assertEquals(3,settings.maximum());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
|
||||
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new PartySettings(data,6)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("2").toCompletableFuture().get());assertEquals(6,settings.maximum());}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class RoleplaySettingsTest{@TempDir Path data;
|
||||
@Test void validatesPersistsPreservesUnrelatedAndPublishesOnlyAcknowledgedSequence()throws Exception{assertFalse(RoleplaySettings.parse(null));assertFalse(RoleplaySettings.parse(false));assertTrue(RoleplaySettings.parse(true));for(Object bad:new Object[]{"true","false",1,"yes"})assertThrows(IllegalArgumentException.class,()->RoleplaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\nroleplay-disabled: false\n");try(var settings=new RoleplaySettings(data,false)){var first=settings.set("true").toCompletableFuture().get();var second=settings.set("false").toCompletableFuture().get();assertFalse(settings.disabled());settings.publish(second);settings.publish(first);assertFalse(settings.disabled());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
|
||||
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new RoleplaySettings(data,false)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("true").toCompletableFuture().get());assertFalse(settings.disabled());}}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SheetResourcePackSettingsTest{@TempDir Path data;static final SheetResourcePackSettings.Defaults DEFAULTS=new SheetResourcePackSettings.Defaults("https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v1.2.3/purpur-fantasy-stick-1.2.3-resource-pack.zip","0123456789abcdef0123456789abcdef01234567");
|
||||
@Test void validatesDefaultsSecureOverridesAndHash(){assertEquals(DEFAULTS.url(),SheetResourcePackSettings.effectiveUrl("",DEFAULTS));assertEquals(DEFAULTS.sha1(),SheetResourcePackSettings.effectiveSha1("",DEFAULTS));assertEquals("https://packs.example.test/sheet.zip",SheetResourcePackSettings.effectiveUrl("https://packs.example.test/sheet.zip",DEFAULTS));assertEquals("abcdefabcdefabcdefabcdefabcdefabcdefabcd",SheetResourcePackSettings.effectiveSha1("ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD",DEFAULTS));for(String bad:java.util.List.of("http://packs.example.test/x.zip","relative.zip","https://user@packs.example.test/x.zip","https://packs.example.test/x.zip#fragment","https://packs.example.test/a b"))assertThrows(IllegalArgumentException.class,()->SheetResourcePackSettings.effectiveUrl(bad,DEFAULTS));for(String bad:java.util.List.of("x","0".repeat(39),"g".repeat(40)))assertThrows(IllegalArgumentException.class,()->SheetResourcePackSettings.effectiveSha1(bad,DEFAULTS));}
|
||||
@Test void serializesConcurrentUpdatesPreservesUnrelatedAndPublishesNewestSnapshot()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\nsheet-resource-pack-url: ''\nsheet-resource-pack-sha1: ''\n");try(var settings=new SheetResourcePackSettings(data,"","",DEFAULTS)){var url=settings.set(SheetResourcePackSettings.URL_KEY,"https://packs.example.test/sheet.zip").toCompletableFuture();var hash=settings.set(SheetResourcePackSettings.SHA1_KEY,"a".repeat(40)).toCompletableFuture();var first=url.get();var second=hash.get();settings.publish(second);settings.publish(first);assertEquals("https://packs.example.test/sheet.zip",settings.url());assertEquals("a".repeat(40),settings.sha1());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));}}
|
||||
@Test void failedWriteRetainsEffectivePair()throws Exception{try(var settings=new SheetResourcePackSettings(data,"","",DEFAULTS)){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set(SheetResourcePackSettings.URL_KEY,"https://packs.example.test/x.zip").toCompletableFuture().get());assertEquals(DEFAULTS.url(),settings.url());assertEquals(DEFAULTS.sha1(),settings.sha1());}}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.*;import java.time.Duration;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class SwitchDelaySettingsTest{@TempDir Path data;
|
||||
@Test void validatesZeroPersistsPreservesUnrelatedAndPublishesAfterAcknowledgment()throws Exception{assertEquals(Duration.ofSeconds(900),SwitchDelaySettings.parse(null));assertEquals(Duration.ZERO,SwitchDelaySettings.parse("0"));for(Object bad:new Object[]{-1,"1.5","bad","9223372036854775807","253402300800"})assertThrows(IllegalArgumentException.class,()->SwitchDelaySettings.parse(bad));Files.writeString(data.resolve("config.yml"),"unrelated: keep\ncharacter-swap-cooldown-seconds: 900\n");try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var update=settings.set("37").toCompletableFuture().get();assertEquals("900",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));settings.publish(update);assertEquals("37",settings.value());assertEquals(Duration.ofSeconds(37),settings.delay());}}
|
||||
@Test void concurrentWritesSerializeAndStalePublicationCannotRollBackNewerPolicy()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){var one=settings.set("1").toCompletableFuture();var two=settings.set("2").toCompletableFuture();var first=one.get();var second=two.get();settings.publish(second);settings.publish(first);assertEquals("2",settings.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("character-swap-cooldown-seconds: '2'"));}}
|
||||
@Test void failedWriteRetainsEffectiveValue()throws Exception{try(var settings=new SwitchDelaySettings(data,Duration.ofSeconds(900))){Files.createDirectory(data.resolve("config.yml"));assertThrows(Exception.class,()->settings.set("0").toCompletableFuture().get());assertEquals("900",settings.value());}}
|
||||
}
|
||||
Reference in new issue
Block a user