feat(presentation): hide held character sheets
Release / release (push) Failing after 8m32s

This commit is contained in:
dmg committed 2026-09-20 15:13:54 -04:00
1 parent 5d574dc6e9
commit 0fcb1e7e9c
19 files changed
+481 -27

No files matched your search

+4 -2
View File
@@ -96,9 +96,11 @@ jobs:
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-fantasy-stick-${{ steps.release.outputs.version }}
path: build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
path: |
build/libs/purpur-fantasy-stick-${{ steps.release.outputs.version }}.jar
build/distributions/purpur-fantasy-stick-${{ steps.release.outputs.version }}-resource-pack.zip
if-no-files-found: error
- name: Publish verified JAR through a draft release
- name: Publish verified JAR and resource pack through a draft release
if: steps.release.outputs.created == 'true'
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
+39 -5
View File
@@ -1,5 +1,6 @@
import java.net.URI
import java.security.MessageDigest
import org.gradle.api.tasks.bundling.Zip
plugins {
java
@@ -7,6 +8,7 @@ plugins {
group = "games.dmg"
version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get()
val pluginVersion = version.toString()
repositories {
mavenCentral()
@@ -33,11 +35,43 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
val resourcePackInput = layout.buildDirectory.dir("generated/resource-pack-input")
val prepareResourcePack = tasks.register<Copy>("prepareResourcePack") {
from("src/resourcePack")
into(resourcePackInput)
filesMatching("pack.mcmeta") { expand("version" to pluginVersion) }
}
val sheetResourcePack = tasks.register<Zip>("sheetResourcePack") {
dependsOn(prepareResourcePack)
from(resourcePackInput)
archiveFileName.set("purpur-fantasy-stick-$pluginVersion-resource-pack.zip")
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}
val resourcePackMetadata = layout.buildDirectory.dir("generated/resource-pack-metadata")
val generateResourcePackMetadata = tasks.register("generateResourcePackMetadata") {
dependsOn(sheetResourcePack)
inputs.file(sheetResourcePack.flatMap { it.archiveFile })
inputs.property("pluginVersion", pluginVersion)
outputs.file(resourcePackMetadata.map { it.file("sheet-resource-pack.properties") })
doLast {
val pack = sheetResourcePack.get().archiveFile.get().asFile
val sha1 = MessageDigest.getInstance("SHA-1").digest(pack.readBytes()).joinToString("") { "%02x".format(it) }
val version = pluginVersion
val output = resourcePackMetadata.get().file("sheet-resource-pack.properties").asFile
output.parentFile.mkdirs()
output.writeText("url=https://git.garvis.dev/dmg/purpur-fantasy-stick/releases/download/v$version/purpur-fantasy-stick-$version-resource-pack.zip\nsha1=$sha1\n")
}
}
sourceSets.main.get().resources.srcDir(resourcePackMetadata)
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
dependsOn(tasks.jar, sheetResourcePack)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
systemProperty("distribution.version", pluginVersion)
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
}
// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base.
@@ -88,13 +122,14 @@ nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
description = "Verifies packaged metadata and native item serialization without starting a server"
dependsOn(tasks.jar)
dependsOn(tasks.jar, sheetResourcePack)
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(nativeRuntime)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("resource.pack.zip", sheetResourcePack.get().archiveFile.get().asFile.absolutePath)
}
val installReleaseTools = tasks.register<Exec>("installReleaseTools") {
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
@@ -105,9 +140,8 @@ val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
}
tasks.check { dependsOn(nativeCompatibilityTest, releaseToolCheck) }
val pluginVersion = version
tasks.processResources {
dependsOn(generateResourcePackMetadata)
inputs.property("pluginVersion", pluginVersion)
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
+2 -1
View File
@@ -9,8 +9,9 @@ try {
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) throw new Error("Invalid input");
const jar = await readFile(`build/libs/purpur-fantasy-stick-${version}.jar`);
const resourcePack = await readFile(`build/distributions/purpur-fantasy-stick-${version}-resource-pack.zip`);
await publishRelease({
credential, version, commit, jar,
credential, version, commit, jar, resourcePack,
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
}, fetch);
console.log(`Published purpur-fantasy-stick v${version}.`);
+25 -16
View File
@@ -8,12 +8,15 @@ const input: ReleaseInput = {
commit: "a".repeat(40),
notes: "Verified changes",
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
resourcePack: new Uint8Array([0x50, 0x4b, 0x03, 0x05]),
};
function success(init: RequestInit): Response {
function success(init: RequestInit, url = ""): Response {
const pack = url.includes("resource-pack.zip");
return Response.json({
id: 42, draft: init.method !== "PATCH", tag_name: "v1.2.3",
name: "purpur-fantasy-stick-1.2.3.jar", size: input.jar.length,
name: pack ? "purpur-fantasy-stick-1.2.3-resource-pack.zip" : "purpur-fantasy-stick-1.2.3.jar",
size: pack ? input.resourcePack.length : input.jar.length,
});
}
@@ -21,7 +24,7 @@ test("creates a draft on the fixed repository and exact source commit, without f
const calls: { url: string; init: RequestInit }[] = [];
const id = await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
return success(init, url);
});
assert.ok(calls.length >= 1);
const first = calls[0]!;
@@ -40,17 +43,21 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
const calls: { url: string; init: RequestInit }[] = [];
await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
return success(init, url);
});
assert.equal(calls.length, 3);
assert.equal(calls.length, 4);
assert.equal(calls[1]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3.jar");
assert.equal(calls[1]!.init.method, "POST");
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream");
assert.equal(calls[2]!.init.method, "PATCH");
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
assert.equal(calls[2]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3-resource-pack.zip");
assert.equal(calls[2]!.init.method, "POST");
assert.deepEqual(new Uint8Array(await new Response(calls[2]!.init.body).arrayBuffer()), input.resourcePack);
assert.equal(calls[3]!.init.method, "PATCH");
assert.equal(calls[3]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42");
assert.deepEqual(JSON.parse(String(calls[3]!.init.body)), { draft: false });
for (const call of calls) {
assert.equal(call.init.redirect, "error");
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
@@ -58,14 +65,14 @@ test("uploads the exact versioned bytes before publishing the draft", async () =
}
});
for (const failureStep of [1, 2, 3]) {
for (const failureStep of [1, 2, 3, 4]) {
for (const status of [302, 401, 409, 500]) {
test(`stops at step ${failureStep} on HTTP ${status} without leaking API error bodies`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
if (calls === failureStep) return new Response(input.credential, { status });
return success(init);
return success(init, _url);
}), (error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(error.message.includes(String(status)));
@@ -85,7 +92,7 @@ test("sanitizes transport errors and does not follow a redirect with the credent
for (const invalid of [
{ credential: "" }, { credential: "header\r\ninjection" }, { version: "../../latest" },
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() },
{ version: "1.0.0-SNAPSHOT" }, { commit: "main" }, { jar: new Uint8Array() }, { resourcePack: new Uint8Array() },
]) {
test(`rejects invalid ${Object.keys(invalid)[0]} before any network request`, async () => {
let calls = 0;
@@ -112,16 +119,18 @@ for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringi
for (const [failureStep, body] of [
[2, {}], [2, { name: "other.jar", size: 4 }],
[2, { name: "purpur-fantasy-stick-1.2.3.jar", size: 0 }],
[3, { id: 42, draft: true, tag_name: "v1.2.3" }],
[3, { id: 77, draft: false, tag_name: "v1.2.3" }],
[3, { id: 42, draft: false, tag_name: "v8.8.8" }],
[3, {}], [3, { name: "other.zip", size: 4 }],
[3, { name: "purpur-fantasy-stick-1.2.3-resource-pack.zip", size: 0 }],
[4, { id: 42, draft: true, tag_name: "v1.2.3" }],
[4, { id: 77, draft: false, tag_name: "v1.2.3" }],
[4, { id: 42, draft: false, tag_name: "v8.8.8" }],
] as const) {
test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
return calls === failureStep ? Response.json(body) : success(init);
}), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." });
return calls === failureStep ? Response.json(body) : success(init, _url);
}), { message: failureStep === 2 || failureStep === 3 ? "Invalid release asset response." : "Invalid published release response." });
assert.equal(calls, failureStep);
});
}
+12 -1
View File
@@ -4,6 +4,7 @@ export interface ReleaseInput {
commit: string;
notes: string;
jar: Uint8Array;
resourcePack: Uint8Array;
}
export type Request = (url: string, init: RequestInit) => Promise<Response>;
@@ -13,7 +14,7 @@ const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/r
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
if (!/^[\x21-\x7e]+$/.test(input.credential)
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0) {
|| !/^[a-f0-9]{40}$/.test(input.commit) || input.jar.length === 0 || input.resourcePack.length === 0) {
throw new Error("Invalid release input.");
}
const send: Request = async (url, init) => {
@@ -49,6 +50,16 @@ export async function publishRelease(input: ReleaseInput, request: Request): Pro
if (asset.name !== `purpur-fantasy-stick-${input.version}.jar` || asset.size !== input.jar.length) {
throw new Error("Invalid release asset response.");
}
const packName = `purpur-fantasy-stick-${input.version}-resource-pack.zip`;
const packResponse = await send(`${releases}/${release.id}/assets?name=${packName}`, {
method: "POST", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/octet-stream" },
body: new Uint8Array(input.resourcePack).buffer,
});
const pack = await readObject(packResponse, "Invalid release asset response.");
if (pack.name !== packName || pack.size !== input.resourcePack.length) {
throw new Error("Invalid release asset response.");
}
const publishedResponse = await send(`${releases}/${release.id}`, {
method: "PATCH", redirect: "error",
headers: { authorization: `token ${input.credential}`, "content-type": "application/json" },
@@ -76,6 +76,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -105,6 +107,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
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();
@@ -166,6 +172,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -272,6 +280,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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()
@@ -304,12 +313,14 @@ 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); }
sheetResourcePack.deliverAll(getServer().getOnlinePlayers());
} catch (RuntimeException | LinkageError failure) {
try { onDisable(); }
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
@@ -360,6 +371,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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;
@@ -389,6 +402,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
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(),
@@ -415,7 +430,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
closingRoleplaySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingRoleplaySettings.drained().toCompletableFuture(),
closingPartySettings == null ? java.util.concurrent.CompletableFuture.completedFuture(null)
: closingPartySettings.drained().toCompletableFuture());
: 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);
@@ -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,6 +27,7 @@ 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;
}
@@ -46,6 +48,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();
@@ -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; }
}
@@ -44,6 +44,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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) {
@@ -216,6 +218,7 @@ 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");
@@ -336,6 +339,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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;
@@ -547,6 +551,8 @@ 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);}});}
@@ -598,7 +604,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (args.length == 2 && (args[0].equals("give") || args[0].equals("revive") && sender instanceof org.bukkit.entity.Player)) return matching(server.getOnlinePlayers().stream()
.filter(org.bukkit.entity.Player::isOnline).map(org.bukkit.entity.Player::getName).toList(), args[1]);
if (args.length == 2 && args[0].equals("config")) {
var keys = new java.util.ArrayList<String>(); keys.add("adventure-mode");keys.add(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY);keys.add(games.dmg.fantasystick.persistence.SwitchDelaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.RoleplaySettings.KEY);keys.add(games.dmg.fantasystick.persistence.PartySettings.KEY);
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());
@@ -615,6 +621,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
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]);
@@ -429,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]);
+2
View File
@@ -7,6 +7,8 @@ admin-revive-selection-timeout-seconds: 60
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
@@ -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");
@@ -84,6 +88,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());
}
@@ -1304,6 +1304,7 @@ 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 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 +1371,7 @@ 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; }
default -> Proxy.getInvocationHandler(core.player).invoke(core.player, method, args);
};
});
@@ -0,0 +1,10 @@
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);f.awaitCharacterAndReceipt(active);f.tick();f.tick();assertNotNull(active.core.marker(),active.messages::toString);assertTrue(active.core.marker().endsWith("|PACKED"));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 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"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"pack": {
"description": "Fantasy Stick ${version} sheet presentation",
"min_format": [88, 0],
"max_format": [88, 0]
}
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Path;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
@@ -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,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());}}
}