Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58c192358 | ||
|
|
0fcb1e7e9c | ||
|
|
5d574dc6e9 | ||
|
|
a71262f115 | ||
|
|
8119673c9f |
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;
|
||||
@@ -74,6 +77,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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);
|
||||
|
||||
@@ -99,6 +106,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
adventureSettings = new games.dmg.fantasystick.persistence.AdventureSettings(getDataFolder().toPath(), adventure);
|
||||
var switchDelay = games.dmg.fantasystick.persistence.SwitchDelaySettings.parse(
|
||||
getConfig().get(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY));
|
||||
var 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();
|
||||
@@ -158,13 +173,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
+ "; 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,
|
||||
@@ -261,6 +281,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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()
|
||||
@@ -293,12 +316,18 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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); }
|
||||
@@ -321,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;
|
||||
@@ -347,6 +377,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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;
|
||||
@@ -374,6 +408,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
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(),
|
||||
@@ -396,7 +434,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
closingAdminReviveSettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingAdminReviveSettings.drained().toCompletableFuture(),
|
||||
closingSwitchDelaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
|
||||
: closingSwitchDelaySettings.drained().toCompletableFuture());
|
||||
: 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);
|
||||
@@ -446,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.
|
||||
|
||||
@@ -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,43 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Durable save-before-publish policy for party capacity checks and book presentation. */
|
||||
public final class PartySettings implements AutoCloseable {
|
||||
public static final String KEY="party-max-size";public static final String DEFAULT="6";
|
||||
private static final int MAX_BYTES=1_048_576;
|
||||
@FunctionalInterface interface Replacement{void move(Path from,Path to)throws IOException;}
|
||||
public record Update(String value,int maximum,long sequence){}
|
||||
private final Path directory;private final Replacement replacement;private final java.util.concurrent.ExecutorService worker=Executors.newSingleThreadExecutor(task->new Thread(task,"fantasy-party-configuration"));
|
||||
private volatile int effective;private long persistedSequence,publishedSequence;private boolean closed;private int pending;private final CompletableFuture<Void> drained=new CompletableFuture<>();
|
||||
public PartySettings(Path directory,int initial){this(directory,initial,(from,to)->Files.move(from,to,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING));}
|
||||
PartySettings(Path directory,int initial,Replacement replacement){this.directory=Objects.requireNonNull(directory).toAbsolutePath();this.replacement=Objects.requireNonNull(replacement);effective=validate(initial);}
|
||||
public static int parse(Object value){if(value==null)return 6;if(!(value instanceof Number number))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigDecimal(number.toString()).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int parseText(String value){if(value==null||!value.matches("[0-9]+"))throw new IllegalArgumentException(KEY+" must be a positive whole number");try{return validate(new java.math.BigInteger(value).intValueExact());}catch(ArithmeticException|NumberFormatException invalid){throw new IllegalArgumentException(KEY+" must be a representable positive whole number",invalid);}}
|
||||
private static int validate(int value){if(value<1)throw new IllegalArgumentException(KEY+" must be a positive whole number");return value;}
|
||||
public int maximum(){return effective;}public String value(){return Integer.toString(effective);}
|
||||
public CompletionStage<Update> set(String raw){int accepted=parseText(raw);synchronized(this){if(closed)return CompletableFuture.failedStage(new IOException("Configuration store is closed"));pending++;}try{return CompletableFuture.supplyAsync(()->{try{save(accepted);return new Update(Integer.toString(accepted),accepted,++persistedSequence);}catch(IOException|RuntimeException failure){throw new java.util.concurrent.CompletionException(new IOException("Party configuration save refused or failed; effective setting unchanged"));}finally{finished();}},worker).minimalCompletionStage();}catch(RejectedExecutionException rejected){finished();return CompletableFuture.failedStage(new IOException("Configuration store is closed"));}}
|
||||
public synchronized boolean publish(Update update){if(update.sequence()<=publishedSequence)return false;effective=validate(update.maximum());publishedSequence=update.sequence();return true;}
|
||||
private void save(int value)throws IOException{Files.createDirectories(directory);if(!Files.isDirectory(directory,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe directory");var file=directory.resolve("config.yml");var lockFile=directory.resolve(".config.lock");if(Files.exists(lockFile,LinkOption.NOFOLLOW_LINKS)&&!Files.isRegularFile(lockFile,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe lock");try(var channel=FileChannel.open(lockFile,StandardOpenOption.CREATE,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS);var lock=channel.tryLock()){if(lock==null)throw new IOException("Configuration busy");var options=new LoaderOptions();options.setAllowDuplicateKeys(false);options.setMaxAliasesForCollections(20);options.setCodePointLimit(MAX_BYTES);var yaml=new Yaml(new SafeConstructor(options));Map<String,Object> values=new LinkedHashMap<>();if(Files.exists(file,LinkOption.NOFOLLOW_LINKS)){if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS))throw new IOException("Unsafe configuration");byte[] bytes;try(var input=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)){bytes=input.readNBytes(MAX_BYTES+1);}if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Object loaded=yaml.load(StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString());if(loaded!=null){if(!(loaded instanceof Map<?,?> map))throw new IOException("Invalid configuration root");for(var entry:map.entrySet()){if(!(entry.getKey() instanceof String key))throw new IOException("Invalid configuration key");values.put(key,entry.getValue());}}}values.put(KEY,value);byte[] bytes=yaml.dump(values).getBytes(StandardCharsets.UTF_8);if(bytes.length>MAX_BYTES)throw new IOException("Configuration too large");Path temporary=Files.createTempFile(directory,".config-",".tmp");try{try(var output=FileChannel.open(temporary,StandardOpenOption.WRITE,LinkOption.NOFOLLOW_LINKS)){var buffer=ByteBuffer.wrap(bytes);while(buffer.hasRemaining())output.write(buffer);output.force(true);}replacement.move(temporary,file);}finally{Files.deleteIfExists(temporary);}}catch(OverlappingFileLockException busy){throw new IOException("Configuration busy");}}
|
||||
private synchronized void finished(){pending--;if(closed&&pending==0)drained.complete(null);}public CompletionStage<Void> drained(){return drained.minimalCompletionStage();}@Override public synchronized void close(){closed=true;worker.shutdown();if(pending==0)drained.complete(null);}
|
||||
}
|
||||
@@ -0,0 +1,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,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; }
|
||||
}
|
||||
@@ -42,6 +42,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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) {
|
||||
@@ -212,6 +216,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -330,6 +337,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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;
|
||||
@@ -541,6 +551,12 @@ 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);}});}
|
||||
@@ -582,10 +598,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return List.of();
|
||||
if (args.length == 1) return matching(sender instanceof org.bukkit.entity.Player
|
||||
? List.of("config", "give", "inspect", "recover", "revive") : List.of("config", "give", "inspect", "recover"), args[0]);
|
||||
if (args.length == 2 && (args[0].equals("inspect") || args[0].equals("recover"))) return matching(
|
||||
server.getOnlinePlayers().stream().filter(org.bukkit.entity.Player::isOnline)
|
||||
.map(player -> player.getUniqueId().toString()).toList(), args[1]);
|
||||
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
|
||||
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
|
||||
if (args.length == 2 && args[0].equals("config")) {
|
||||
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);
|
||||
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());
|
||||
@@ -600,6 +619,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
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]);
|
||||
|
||||
@@ -39,6 +39,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private final CharacterStorage characters;
|
||||
private final EquipmentSessionStore receipts;
|
||||
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;
|
||||
@@ -151,6 +152,12 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
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");
|
||||
@@ -276,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;
|
||||
}
|
||||
@@ -335,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) {
|
||||
@@ -385,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) {
|
||||
@@ -410,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]);
|
||||
@@ -736,6 +757,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -5,6 +5,10 @@ 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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -84,6 +84,39 @@ class EquipmentAdminCommandNativeTest {
|
||||
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);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -424,6 +424,8 @@ class ManualCraftingControllerNativeTest {
|
||||
org.bukkit.Location eyeLocation;
|
||||
boolean online = true;
|
||||
int syncs;
|
||||
final java.util.Set<org.bukkit.NamespacedKey> knownRecipes = new java.util.LinkedHashSet<>();
|
||||
int recipeAwards;
|
||||
Runnable onSync = () -> { };
|
||||
|
||||
Actor(int width, int amount) {
|
||||
@@ -438,6 +440,14 @@ class ManualCraftingControllerNativeTest {
|
||||
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
|
||||
case "isOnline" -> online;
|
||||
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
|
||||
case "getName" -> "CraftingActor";
|
||||
case "hasDiscoveredRecipe" -> knownRecipes.contains((org.bukkit.NamespacedKey) args[0]);
|
||||
case "discoverRecipes" -> {
|
||||
recipeAwards++; int before = knownRecipes.size();
|
||||
((java.util.Collection<?>) args[0]).stream().map(org.bukkit.NamespacedKey.class::cast)
|
||||
.forEach(knownRecipes::add);
|
||||
yield knownRecipes.size() - before;
|
||||
}
|
||||
case "equals" -> object == args[0];
|
||||
case "hashCode" -> System.identityHashCode(object);
|
||||
case "toString" -> "Crafting actor fixture";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.fantasystick;
|
||||
import static org.junit.jupiter.api.Assertions.*;import java.nio.file.Path;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;
|
||||
class PartyCapacityNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
@Test void registeredPolicyDrivesActualBookInvitationsAcceptanceGrandfatheringAndRestart()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();assertEquals(java.util.List.of("party-max-size"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-m"}));assertEquals(java.util.List.of("6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party-max-size",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));for(String bad:java.util.List.of("0","-1","1.5","bad","2147483648")){f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",bad});assertTrue(sender.messages.getLast().contains("invalid"));}sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","party"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2","extra"});assertTrue(sender.messages.getLast().contains("Usage"));save(f,sender,"2");var leader=new FantasyStickLifecycleNativeTest.Actor(data);var member=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);f.players.put(member.core.id,member.player);f.activate(leader);f.activate(member);PartyBookNativeTest.open(f,leader);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Create party]"));PartyBookNativeTest.drain(f);PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));PartyBookNativeTest.open(f,member);PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Invitations]"));PartyBookNativeTest.click(f,member,PartyBookNativeTest.button(member,"[Accept]"));PartyBookNativeTest.drain(f);var parties=PartyLifecycleNativeTest.parties(f);assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyBookNativeTest.text(member).contains("2/2 members"));PartyBookNativeTest.open(f,leader);assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));save(f,sender,"3");var third=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(third.core.id,third.player);f.activate(third);PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("2/3 members"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite players]"));PartyBookNativeTest.click(f,leader,PartyBookNativeTest.button(leader,"[Invite]"));assertEquals(1,parties.invitationsFor(third.core.id).size());save(f,sender,"2");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertTrue(parties.partyOf(third.core.id).isEmpty());assertEquals(2,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"3");PartyBookNativeTest.open(f,third);PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Invitations]"));PartyBookNativeTest.click(f,third,PartyBookNativeTest.button(third,"[Accept]"));PartyBookNativeTest.drain(f);assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());save(f,sender,"2");assertEquals(3,parties.partyOf(leader.core.id).orElseThrow().members().size());assertTrue(PartyCombatNativeTest.hit(f,leader.player,member.player).isCancelled());PartyBookNativeTest.open(f,leader);assertTrue(PartyBookNativeTest.text(leader).contains("3/2 members"));assertFalse(PartyBookNativeTest.text(leader).contains("[Invite players]"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=2"));assertTrue(PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).isEmpty());}}
|
||||
@Test void failedRegisteredSaveLeavesCurrentRosterAndLimitUnchanged()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var sender=new EquipmentAdminCommandNativeTest.Sender();var leader=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(leader.core.id,leader.player);var party=PartyLifecycleNativeTest.parties(f).create(leader.core.id).orElseThrow();java.nio.file.Files.createDirectory(data.resolve("config.yml"));f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size","2"});var callback=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(callback);callback.run();assertTrue(sender.messages.getLast().contains("save failed"));assertEquals(party,PartyLifecycleNativeTest.parties(f).partyOf(leader.core.id).orElseThrow());f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size"});assertTrue(sender.messages.getLast().contains("=6"));}}
|
||||
static void save(FantasyStickLifecycleNativeTest.Fixture f,EquipmentAdminCommandNativeTest.Sender sender,String value)throws Exception{int before=sender.messages.size();f.command.execute(sender.port,"fantasystick",new String[]{"config","party-max-size",value});assertEquals(before,sender.messages.size());while(sender.messages.size()==before){var task=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();}assertTrue(sender.messages.getLast().contains("="+value));}
|
||||
}
|
||||
@@ -0,0 +1,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));}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -82,6 +83,8 @@ class DistributionTest {
|
||||
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);
|
||||
@@ -95,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,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());}}
|
||||
}
|
||||
Reference in new issue
Block a user