feat(persistence): save characters and create signed Tail books
This commit is contained in:
1 parent
638965e474
commit
b35cac3d1c
11 files changed
+991
-3
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
**Development foundation only. No playable gameplay is registered, and this JAR is not ready for deployment.**
|
||||
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. It is not yet connected to crafting, native health, the book or persistence.
|
||||
Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These remain planned native features. The server-independent progression core now tests initial stats/points, stat purchases, CON's equal maximum/current-health gain, capped XP and `MAX` presentation. YAML persistence, backend death/revival transitions and a native signed-book factory are also present. None is yet wired into playable crafting, health, session or spell interactions.
|
||||
|
||||
## Build and verify
|
||||
|
||||
@@ -15,10 +15,22 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
|
||||
./gradlew check jar -PreleaseVersion=0.1.0
|
||||
```
|
||||
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime **without starting a listening server or creating a gameplay world**. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime and loads vanilla registries/components **without starting a listening server or creating a gameplay world**. The item tests decode signed written books with Minecraft's real codec, without a plugin instance or YAML lookup. A clean build repeats that preparation. Dependencies and runtime downloads require network access.
|
||||
|
||||
Build output is `build/libs/purpur-fantasy-stick-<version>.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Bootstrap registers no commands, permissions, recipes, sessions or gameplay listeners.
|
||||
|
||||
## Character storage and keepsakes
|
||||
|
||||
`YamlCharacterRepository` stores schema-version-1 records at `<plugin data directory>/characters/<character UUID>.yml`. Character identity, name, progress, seven-slot loadout, ability cooldowns, explicit `dead` flag and latest death ID/time are preserved; the record has no holder-owned equipment or ordinary-health/nutrition snapshot. Integer values and decimal health retain exact precision, and timestamps use UTC RFC 3339 strings. Unknown learned ability IDs and unlearned cooldowns survive reload.
|
||||
|
||||
The file adapter uses safe YAML construction, duplicate-key rejection, bounded reads (1 MiB per record), typed validation, UUID filenames and no symlinked record reads. Replacement writes a complete temporary file, flushes it and atomically renames it; expected revisions and transient OS file locks reject duplicate/stale writers. Empty `.lock` files are coordination files, not administrator-unlock flags. Unsupported atomic replacement fails rather than falling back to a partial overwrite. Malformed files are retained, not reset.
|
||||
|
||||
`CharacterStorage` queues disk operations on its persistence worker and acknowledges successful writes only after commit. Its backend restoration checks the saved dead flag, matching current death ID and death age strictly under 24 hours at the request/selection time. It preserves progress/cooldowns and restores full character health; old books do not become eligible after a later death. This method is **not** a complete Revive spell: the engine must still validate the caster and native dialog, spend its cooldown, recheck item possession and perform physical conversion safely.
|
||||
|
||||
`TailBooks` creates vanilla `WRITTEN_BOOK` items with static pages. The full character name remains in display/pages; the native title is bounded to vanilla's 32 UTF-16 units without splitting a surrogate pair. `fantasystick:tail_version`, `character_id` and `death_id` identify the saved record. Pages/title are never read as authoritative stats or revival eligibility. Plugin absence does not remove the vanilla written-book contents. The factory does not yet deliver books from a death event.
|
||||
|
||||
File-level atomic replacement is **not** atomicity with Minecraft inventory/player saves. This checkpoint does not implement cross-save reconciliation, an administrator lock, automatic overflow drops or equipment/session persistence. Ordinary equipment must still be implemented as actual native shulker contents, never an external-only reconstruction receipt.
|
||||
|
||||
## CI and publication
|
||||
|
||||
Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The operator has provisioned `RELEASE_TOKEN` and the bootstrap is pushed. Do not infer successful CI, publication or credential validity from the secret name alone; record actual exact-commit workflow and artifact results in the canonical wiki.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ val nativeRuntimeJars = files(fileTree(nativeRuntime) {
|
||||
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
|
||||
val nativeCompatibilityTest = tasks.register<Test>("nativeCompatibilityTest") {
|
||||
description = "Verifies packaged metadata against the actual Purpur runtime without starting a server"
|
||||
description = "Verifies packaged metadata and native item serialization without starting a server"
|
||||
dependsOn(tasks.jar)
|
||||
testClassesDirs = nativeTest.output.classesDirs
|
||||
classpath = nativeTest.runtimeClasspath
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.fantasystick.items;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Native readable keepsake; revival uses only its IDs to consult authoritative saved character data. */
|
||||
public final class TailBooks {
|
||||
private static final NamespacedKey VERSION = new NamespacedKey("fantasystick", "tail_version");
|
||||
private static final NamespacedKey CHARACTER = new NamespacedKey("fantasystick", "character_id");
|
||||
private static final NamespacedKey DEATH = new NamespacedKey("fantasystick", "death_id");
|
||||
|
||||
public record Identity(UUID characterId, UUID deathId) { }
|
||||
|
||||
public ItemStack create(CharacterRecord character) {
|
||||
if (!character.dead()) throw new IllegalArgumentException("A Tail book requires a dead character record");
|
||||
var item = new ItemStack(Material.WRITTEN_BOOK);
|
||||
var meta = (BookMeta) item.getItemMeta();
|
||||
String fullTitle = "Tail of " + character.name();
|
||||
int titleEnd = Math.min(32, fullTitle.length());
|
||||
if (titleEnd < fullTitle.length() && Character.isHighSurrogate(fullTitle.charAt(titleEnd - 1))) titleEnd--;
|
||||
if (!meta.setTitle(fullTitle.substring(0, titleEnd))) {
|
||||
throw new IllegalStateException("Native written-book title was rejected");
|
||||
}
|
||||
meta.setAuthor("Fantasy Stick");
|
||||
meta.setGeneration(BookMeta.Generation.ORIGINAL);
|
||||
meta.displayName(Component.text(fullTitle));
|
||||
List<Component> pages = new ArrayList<>();
|
||||
pages.add(Component.text(fullTitle));
|
||||
pages.add(Component.text("Level: " + character.level()));
|
||||
pages.add(Component.text("XP: " + character.experience()));
|
||||
pages.add(Component.text("Unspent points: " + character.unspentPoints()));
|
||||
for (Stat stat : Stat.values()) pages.add(Component.text(stat + ": " + character.stats().get(stat)));
|
||||
pages.add(Component.text("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
|
||||
character.learned().stream().sorted().forEach(ability -> pages.add(Component.text(ability)));
|
||||
pages.add(Component.text("Fell: " + character.diedAt()));
|
||||
meta.pages(pages);
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(VERSION, PersistentDataType.INTEGER, 1);
|
||||
data.set(CHARACTER, PersistentDataType.STRING, character.id().toString());
|
||||
data.set(DEATH, PersistentDataType.STRING, character.deathId().toString());
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
public Optional<Identity> identify(ItemStack item) {
|
||||
if (item == null || item.getType() != Material.WRITTEN_BOOK || !item.hasItemMeta()) return Optional.empty();
|
||||
var data = item.getItemMeta().getPersistentDataContainer();
|
||||
if (!data.has(VERSION, PersistentDataType.INTEGER)
|
||||
|| !Integer.valueOf(1).equals(data.get(VERSION, PersistentDataType.INTEGER))
|
||||
|| !data.has(CHARACTER, PersistentDataType.STRING) || !data.has(DEATH, PersistentDataType.STRING)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(new Identity(UUID.fromString(data.get(CHARACTER, PersistentDataType.STRING)),
|
||||
UUID.fromString(data.get(DEATH, PersistentDataType.STRING))));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Immutable character data, never a holder's ordinary equipment or session snapshot. */
|
||||
public record CharacterRecord(
|
||||
UUID id, String name, BigInteger revision, Map<Stat, BigInteger> stats,
|
||||
int level, BigInteger experience, BigInteger unspentPoints, BigDecimal health,
|
||||
Set<String> learned, List<String> loadout, Map<String, Instant> cooldowns,
|
||||
boolean dead, UUID deathId, Instant diedAt) {
|
||||
public CharacterRecord {
|
||||
Objects.requireNonNull(id);
|
||||
Objects.requireNonNull(name);
|
||||
Objects.requireNonNull(revision);
|
||||
Objects.requireNonNull(experience);
|
||||
Objects.requireNonNull(unspentPoints);
|
||||
Objects.requireNonNull(health);
|
||||
stats = Map.copyOf(stats);
|
||||
learned = Set.copyOf(learned);
|
||||
loadout = List.copyOf(loadout);
|
||||
cooldowns = Map.copyOf(cooldowns);
|
||||
if (revision.signum() < 0 || experience.signum() < 0 || unspentPoints.signum() < 0 || level < 1
|
||||
|| !stats.keySet().equals(EnumSet.allOf(Stat.class))
|
||||
|| stats.values().stream().anyMatch(value -> value.signum() < 0)
|
||||
|| health.signum() < 0
|
||||
|| health.compareTo(new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN))) > 0
|
||||
|| loadout.size() != 7 || learned.stream().anyMatch(String::isBlank)
|
||||
|| cooldowns.keySet().stream().anyMatch(String::isBlank)
|
||||
|| (deathId == null) != (diedAt == null) || dead && deathId == null) {
|
||||
throw new IllegalArgumentException("Invalid character record");
|
||||
}
|
||||
for (String ability : loadout) {
|
||||
if (!ability.isEmpty() && !learned.contains(ability)) {
|
||||
throw new IllegalArgumentException("Loadout references an unlearned ability");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BigDecimal maximumHealth() {
|
||||
return new BigDecimal(stats.get(Stat.CON).add(BigInteger.TEN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
|
||||
/** Asynchronous character persistence; this does not cast Revive or consume/deliver inventory items. */
|
||||
public final class CharacterStorage implements AutoCloseable {
|
||||
private final YamlCharacterRepository repository;
|
||||
private final Clock clock;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-character-persistence"));
|
||||
|
||||
public CharacterStorage(YamlCharacterRepository repository, Clock clock) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> create(CharacterRecord character) {
|
||||
return submit(() -> {
|
||||
repository.create(character);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> find(UUID id) {
|
||||
return submit(() -> repository.load(id));
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> recordDeath(UUID characterId, BigInteger expectedRevision) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(expectedRevision);
|
||||
Instant diedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
if (old.dead() || !old.revision().equals(expectedRevision)) return Optional.empty();
|
||||
var dead = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), java.math.BigDecimal.ZERO, old.learned(),
|
||||
old.loadout(), old.cooldowns(), true, UUID.randomUUID(), diedAt);
|
||||
repository.replace(dead, old.revision());
|
||||
return Optional.of(dead);
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> restoreDeathRecord(UUID characterId, UUID deathId) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(deathId);
|
||||
Instant selectedAt = clock.instant();
|
||||
return submit(() -> {
|
||||
Optional<CharacterRecord> found = repository.load(characterId);
|
||||
if (found.isEmpty() || !found.orElseThrow().dead()) return Optional.empty();
|
||||
CharacterRecord old = found.orElseThrow();
|
||||
Duration age = Duration.between(old.diedAt(), selectedAt);
|
||||
if (!deathId.equals(old.deathId()) || age.isNegative() || age.compareTo(Duration.ofHours(24)) >= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var restored = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
|
||||
old.level(), old.experience(), old.unspentPoints(), old.maximumHealth(), old.learned(),
|
||||
old.loadout(), old.cooldowns(), false, old.deathId(), old.diedAt());
|
||||
repository.replace(restored, old.revision());
|
||||
return Optional.of(restored);
|
||||
});
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DiskOperation<T> { T run() throws IOException; }
|
||||
|
||||
private <T> CompletableFuture<T> submit(DiskOperation<T> operation) {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return operation.run();
|
||||
} catch (IOException exception) {
|
||||
throw new CompletionException(exception);
|
||||
}
|
||||
}, worker);
|
||||
} catch (RejectedExecutionException exception) {
|
||||
return CompletableFuture.failedFuture(exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
|
||||
@Override
|
||||
public void close() {
|
||||
worker.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
|
||||
/** Plain values only: no Bukkit item deserialization or arbitrary Java YAML tags. */
|
||||
final class CharacterYaml {
|
||||
private CharacterYaml() { }
|
||||
|
||||
private static Yaml yaml() {
|
||||
var options = new LoaderOptions();
|
||||
options.setAllowDuplicateKeys(false);
|
||||
options.setMaxAliasesForCollections(0);
|
||||
options.setCodePointLimit(1_048_576);
|
||||
return new Yaml(new SafeConstructor(options));
|
||||
}
|
||||
|
||||
static String encode(CharacterRecord record) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("schema-version", 1);
|
||||
root.put("character-id", record.id().toString());
|
||||
root.put("name", record.name());
|
||||
root.put("revision", record.revision().toString());
|
||||
Map<String, String> stats = new LinkedHashMap<>();
|
||||
for (Stat stat : Stat.values()) stats.put(stat.name(), record.stats().get(stat).toString());
|
||||
root.put("stats", stats);
|
||||
root.put("level", record.level());
|
||||
root.put("experience", record.experience().toString());
|
||||
root.put("unspent-points", record.unspentPoints().toString());
|
||||
root.put("health", record.health().toString());
|
||||
root.put("learned-abilities", record.learned().stream().sorted().toList());
|
||||
root.put("loadout", record.loadout());
|
||||
List<Map<String, String>> cooldowns = new ArrayList<>();
|
||||
record.cooldowns().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry ->
|
||||
cooldowns.add(Map.of("ability", entry.getKey(), "ready-at", entry.getValue().toString())));
|
||||
root.put("cooldowns", cooldowns);
|
||||
root.put("dead", record.dead());
|
||||
if (record.deathId() != null) {
|
||||
root.put("death-id", record.deathId().toString());
|
||||
root.put("died-at", record.diedAt().toString());
|
||||
}
|
||||
return yaml().dump(root);
|
||||
}
|
||||
|
||||
static CharacterRecord decode(String input) {
|
||||
Map<?, ?> root = object(yaml().load(input));
|
||||
if (!number(root.get("schema-version")).equals(BigInteger.ONE)) {
|
||||
throw new IllegalArgumentException("Unsupported character schema");
|
||||
}
|
||||
Map<?, ?> storedStats = object(root.get("stats"));
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (var entry : storedStats.entrySet()) {
|
||||
stats.put(Stat.valueOf(text(entry.getKey())), number(entry.getValue()));
|
||||
}
|
||||
List<String> learnedList = strings(root.get("learned-abilities"));
|
||||
var learned = new LinkedHashSet<>(learnedList);
|
||||
if (learned.size() != learnedList.size()) throw new IllegalArgumentException("Duplicate learned ability");
|
||||
Map<String, Instant> cooldowns = new LinkedHashMap<>();
|
||||
for (Object value : list(root.get("cooldowns"))) {
|
||||
Map<?, ?> row = object(value);
|
||||
if (cooldowns.putIfAbsent(text(row.get("ability")), instant(row.get("ready-at"))) != null) {
|
||||
throw new IllegalArgumentException("Duplicate cooldown");
|
||||
}
|
||||
}
|
||||
if (!(root.get("dead") instanceof Boolean dead)) throw new IllegalArgumentException("Missing dead flag");
|
||||
return new CharacterRecord(UUID.fromString(text(root.get("character-id"))), text(root.get("name")),
|
||||
number(root.get("revision")), stats, number(root.get("level")).intValueExact(),
|
||||
number(root.get("experience")), number(root.get("unspent-points")),
|
||||
new BigDecimal(text(root.get("health"))), learned, strings(root.get("loadout")), cooldowns,
|
||||
dead, root.containsKey("death-id") ? UUID.fromString(text(root.get("death-id"))) : null,
|
||||
root.containsKey("died-at") ? instant(root.get("died-at")) : null);
|
||||
}
|
||||
|
||||
private static Map<?, ?> object(Object value) {
|
||||
if (value instanceof Map<?, ?> map) return map;
|
||||
throw new IllegalArgumentException("Expected YAML mapping");
|
||||
}
|
||||
|
||||
private static List<?> list(Object value) {
|
||||
if (value instanceof List<?> result) return result;
|
||||
throw new IllegalArgumentException("Expected YAML list");
|
||||
}
|
||||
|
||||
private static List<String> strings(Object value) {
|
||||
return list(value).stream().map(CharacterYaml::text).toList();
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof String string) return string;
|
||||
throw new IllegalArgumentException("Expected YAML string");
|
||||
}
|
||||
|
||||
private static BigInteger number(Object value) {
|
||||
if (value instanceof Integer || value instanceof Long || value instanceof BigInteger) {
|
||||
return new BigInteger(value.toString());
|
||||
}
|
||||
return new BigInteger(text(value));
|
||||
}
|
||||
|
||||
private static Instant instant(Object value) {
|
||||
String string = text(value);
|
||||
if (!string.endsWith("Z")) throw new IllegalArgumentException("Expected UTC timestamp");
|
||||
return Instant.parse(string);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Blocking disk adapter: callers must use the persistence worker, never the server tick thread. */
|
||||
public final class YamlCharacterRepository {
|
||||
private static final int MAX_FILE_BYTES = 1_048_576;
|
||||
private final Path directory;
|
||||
private final Replacement replacement;
|
||||
|
||||
@FunctionalInterface
|
||||
interface Replacement { void move(Path source, Path target) throws IOException; }
|
||||
@FunctionalInterface
|
||||
private interface Operation { void run() throws IOException; }
|
||||
|
||||
public YamlCharacterRepository(Path pluginDataDirectory) {
|
||||
this(pluginDataDirectory, (source, target) -> Files.move(source, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING));
|
||||
}
|
||||
|
||||
YamlCharacterRepository(Path pluginDataDirectory, Replacement replacement) {
|
||||
directory = pluginDataDirectory.resolve("characters").toAbsolutePath();
|
||||
this.replacement = Objects.requireNonNull(replacement);
|
||||
}
|
||||
|
||||
public void create(CharacterRecord character) throws IOException {
|
||||
if (character.revision().signum() != 0) throw new IOException("New character revision must be zero");
|
||||
withLock(character.id(), () -> {
|
||||
if (Files.exists(file(character.id()), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Character already exists: " + character.id());
|
||||
}
|
||||
write(character);
|
||||
});
|
||||
}
|
||||
|
||||
public void replace(CharacterRecord character, BigInteger expectedRevision) throws IOException {
|
||||
if (!character.revision().equals(expectedRevision.add(BigInteger.ONE))) {
|
||||
throw new IOException("Character update must advance exactly one revision");
|
||||
}
|
||||
withLock(character.id(), () -> {
|
||||
CharacterRecord current = load(character.id()).orElseThrow(() -> new IOException("Character not found"));
|
||||
if (!current.revision().equals(expectedRevision)) throw new IOException("Stale character revision");
|
||||
write(character);
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<CharacterRecord> load(UUID id) throws IOException {
|
||||
Path file = file(id);
|
||||
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return Optional.empty();
|
||||
try (var input = Files.newInputStream(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] bytes = input.readNBytes(MAX_FILE_BYTES + 1);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
|
||||
String text = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
|
||||
CharacterRecord record = CharacterYaml.decode(text);
|
||||
if (!record.id().equals(id)) throw new IllegalArgumentException("Character ID mismatch");
|
||||
return Optional.of(record);
|
||||
} catch (RuntimeException exception) {
|
||||
// Parser diagnostics may contain user-controlled data; preserve the file, not its text in logs.
|
||||
throw new IOException("Invalid character YAML for " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private void write(CharacterRecord character) throws IOException {
|
||||
byte[] bytes = CharacterYaml.encode(character).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Character YAML exceeds the safe file-size limit");
|
||||
Path temporary = Files.createTempFile(directory, ".character-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
// Never fall back to an in-place, partially written character record.
|
||||
replacement.move(temporary, file(character.id()));
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private void withLock(UUID id, Operation operation) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
Path lockPath = directory.resolve(id + ".lock");
|
||||
try (var channel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Character record busy; retry the operation");
|
||||
operation.run();
|
||||
} catch (OverlappingFileLockException exception) {
|
||||
throw new IOException("Character record busy; retry the operation");
|
||||
}
|
||||
}
|
||||
|
||||
private Path file(UUID id) {
|
||||
return directory.resolve(Objects.requireNonNull(id) + ".yml");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Vanilla registry/tag/component bootstrap, shared in approach with the other Purpur native suites. */
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() { }
|
||||
private static boolean ready;
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName("org.bukkit.Registry"); // Initialize the fixed API registry before loading data packs.
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.TailBooks;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.nbt.NbtOps;
|
||||
import net.minecraft.resources.RegistryOps;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BookMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TailBooksNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
private static CharacterRecord fallen() {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.CON, BigInteger.TEN);
|
||||
return new CharacterRecord(UUID.randomUUID(), "Ari", BigInteger.ONE, stats, 2,
|
||||
BigInteger.valueOf(100), BigInteger.valueOf(30), BigDecimal.ZERO,
|
||||
Set.of("second_wind"), List.of("second_wind", "", "", "", "", "", ""),
|
||||
Map.of(), true, UUID.randomUUID(), Instant.parse("2026-09-17T00:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void respectsTheVanillaTitleLimitWithoutLosingTheFullCharacterName() {
|
||||
var original = fallen();
|
||||
String name = "A".repeat(23) + "\uD83D\uDD25" + "Long Character Name";
|
||||
var saved = new CharacterRecord(original.id(), name, original.revision(), original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.cooldowns(), true, original.deathId(), original.diedAt());
|
||||
var book = new TailBooks().create(saved);
|
||||
var meta = (BookMeta) book.getItemMeta();
|
||||
assertNotNull(meta.getTitle(), "A long character name must not leave an unsigned/missing book title");
|
||||
assertFalse(meta.getTitle().isEmpty(), "The native title setter must not silently reject a long name");
|
||||
assertTrue(meta.getTitle().length() <= 32);
|
||||
assertFalse(Character.isHighSurrogate(meta.getTitle().charAt(meta.getTitle().length() - 1)));
|
||||
assertEquals(Component.text("Tail of " + name), meta.displayName());
|
||||
assertTrue(meta.pages().contains(Component.text("Tail of " + name)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityComesOnlyFromTypedMetadataNotEditablePagesOrANameOnlyLookalike() {
|
||||
var saved = fallen();
|
||||
var books = new TailBooks();
|
||||
var book = books.create(saved);
|
||||
var identity = books.identify(book).orElseThrow();
|
||||
var meta = (BookMeta) book.getItemMeta();
|
||||
meta.setTitle("Fake progression");
|
||||
meta.displayName(Component.text("Changed name"));
|
||||
meta.pages(List.of(Component.text("Level: 999999")));
|
||||
book.setItemMeta(meta);
|
||||
assertEquals(identity, books.identify(book).orElseThrow());
|
||||
assertTrue(books.identify(new ItemStack(Material.WRITTEN_BOOK)).isEmpty());
|
||||
assertTrue(books.identify(new ItemStack(Material.WRITABLE_BOOK)).isEmpty());
|
||||
meta.getPersistentDataContainer().set(new NamespacedKey("fantasystick", "death_id"), PersistentDataType.STRING, "not-a-uuid");
|
||||
book.setItemMeta(meta);
|
||||
assertTrue(books.identify(book).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void vanillaWrittenBookContentSurvivesNativeSerializationWithoutLoadingAPluginOrYaml() {
|
||||
var saved = fallen();
|
||||
var books = new TailBooks();
|
||||
var book = books.create(saved);
|
||||
assertEquals(Material.WRITTEN_BOOK, book.getType(), "Tail must be a signed vanilla book, not a writable book or paper");
|
||||
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
|
||||
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
|
||||
// Read using Minecraft's codec/components only; no Fantasy Stick lookup or plugin instance.
|
||||
var restored = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
|
||||
var content = restored.get(DataComponents.WRITTEN_BOOK_CONTENT);
|
||||
assertNotNull(content);
|
||||
assertEquals("Tail of Ari", content.title().raw());
|
||||
assertEquals("Fantasy Stick", content.author());
|
||||
String pages = content.pages().stream().map(page -> page.raw().getString()).reduce("", (a, b) -> a + "\n" + b);
|
||||
assertTrue(pages.contains("Ari"));
|
||||
assertTrue(pages.contains("Level: 2"));
|
||||
assertTrue(pages.contains("XP: 100"));
|
||||
assertTrue(pages.contains("CON: 10"));
|
||||
assertTrue(pages.contains("second_wind"));
|
||||
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), books.identify(CraftItemStack.asBukkitCopy(restored)).orElseThrow());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterStorageTest {
|
||||
@TempDir Path data;
|
||||
|
||||
@Test
|
||||
void performsFileWritesOffTheCallerThreadAndAcknowledgesOnlyAfterCommit() throws Exception {
|
||||
Thread caller = Thread.currentThread();
|
||||
var entered = new CountDownLatch(1);
|
||||
var allowCommit = new CountDownLatch(1);
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data, (source, target) -> {
|
||||
assertNotSame(caller, Thread.currentThread());
|
||||
entered.countDown();
|
||||
try {
|
||||
if (!allowCommit.await(5, TimeUnit.SECONDS)) throw new IOException("Test gate timed out");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted", exception);
|
||||
}
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
});
|
||||
try (var storage = new CharacterStorage(repository, Clock.systemUTC())) {
|
||||
var pending = storage.create(saved);
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone(), "No success acknowledgment before the file commit");
|
||||
} finally {
|
||||
allowCommit.countDown();
|
||||
}
|
||||
pending.join();
|
||||
assertEquals(saved, storage.find(saved.id()).join().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRevivalSaveLeavesDeadStateRetryableAndShutdownRejectsNewWork() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
var failing = new YamlCharacterRepository(data, (source, target) -> { throw new IOException("Simulated failure"); });
|
||||
try (var storage = new CharacterStorage(failing, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
assertThrows(CompletionException.class, () -> storage.restoreDeathRecord(saved.id(), saved.deathId()).join());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
var retry = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC));
|
||||
assertTrue(retry.restoreDeathRecord(saved.id(), saved.deathId()).join().isPresent());
|
||||
retry.close();
|
||||
assertThrows(CompletionException.class, () -> retry.find(saved.id()).join());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentRequestsAndAReopenedStoreCannotRestoreTheSameDeathTwice() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
var clock = Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var first = storage.restoreDeathRecord(saved.id(), saved.deathId());
|
||||
var second = storage.restoreDeathRecord(saved.id(), saved.deathId());
|
||||
assertTrue(first.join().isPresent());
|
||||
assertTrue(second.join().isEmpty());
|
||||
}
|
||||
try (var reopened = new CharacterStorage(new YamlCharacterRepository(data), clock)) {
|
||||
assertTrue(reopened.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterDeathDoesNotMakeThePreviousBookEligibleAgain() throws Exception {
|
||||
var original = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var clock = Clock.fixed(original.diedAt().plusSeconds(60), ZoneOffset.UTC);
|
||||
try (var storage = new CharacterStorage(repository, clock)) {
|
||||
var alive = storage.restoreDeathRecord(original.id(), original.deathId()).join().orElseThrow();
|
||||
var later = new CharacterRecord(alive.id(), "Saved current name", alive.revision().add(BigInteger.ONE),
|
||||
alive.stats(), alive.level(), alive.experience(), alive.unspentPoints(), java.math.BigDecimal.ZERO,
|
||||
alive.learned(), alive.loadout(), alive.cooldowns(), true, UUID.randomUUID(), original.diedAt().plusSeconds(30));
|
||||
repository.replace(later, alive.revision());
|
||||
assertTrue(storage.restoreDeathRecord(original.id(), original.deathId()).join().isEmpty());
|
||||
var restored = storage.restoreDeathRecord(later.id(), later.deathId()).join().orElseThrow();
|
||||
assertEquals("Saved current name", restored.name(), "Use saved state, not the old book's snapshot");
|
||||
assertFalse(restored.dead());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsEachDeathOnceWithANewIdentityAndNeverReplaysAnOldDefeat() throws Exception {
|
||||
var template = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var live = new CharacterRecord(template.id(), template.name(), BigInteger.ZERO, template.stats(),
|
||||
template.level(), template.experience(), template.unspentPoints(), template.maximumHealth(),
|
||||
template.learned(), template.loadout(), template.cooldowns(), false, null, null);
|
||||
var now = template.diedAt().plusSeconds(60);
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(now, ZoneOffset.UTC))) {
|
||||
storage.create(live).join();
|
||||
var first = storage.recordDeath(live.id(), live.revision()).join();
|
||||
assertTrue(first.isPresent(), "Death must persist the dead flag and current death identity");
|
||||
var dead = first.orElseThrow();
|
||||
assertTrue(dead.dead());
|
||||
assertEquals(java.math.BigDecimal.ZERO, dead.health());
|
||||
assertNotNull(dead.deathId());
|
||||
assertEquals(now, dead.diedAt());
|
||||
assertTrue(storage.recordDeath(live.id(), live.revision()).join().isEmpty());
|
||||
assertEquals(dead, storage.find(live.id()).join().orElseThrow());
|
||||
var revived = storage.restoreDeathRecord(live.id(), dead.deathId()).join().orElseThrow();
|
||||
assertTrue(storage.recordDeath(live.id(), live.revision()).join().isEmpty(), "An old callback cannot kill a revived life");
|
||||
var later = storage.recordDeath(live.id(), revived.revision()).join().orElseThrow();
|
||||
assertNotEquals(dead.deathId(), later.deathId());
|
||||
assertTrue(storage.restoreDeathRecord(live.id(), dead.deathId()).join().isEmpty());
|
||||
assertTrue(storage.restoreDeathRecord(live.id(), later.deathId()).join().isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAnOldOrUnknownDeathBookWithoutChangingTheSavedCharacter() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), UUID.randomUUID()).join().isEmpty());
|
||||
assertTrue(storage.restoreDeathRecord(UUID.randomUUID(), saved.deathId()).join().isEmpty());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresDeathAgeStrictlyBelowTwentyFourHoursAndNotInTheFuture() throws Exception {
|
||||
for (long seconds : new long[] {-1, 86400, 86401}) {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(seconds), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
assertEquals(saved, repository.load(saved.id()).orElseThrow());
|
||||
}
|
||||
}
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository,
|
||||
Clock.fixed(saved.diedAt().plusSeconds(86400).minusNanos(1), ZoneOffset.UTC))) {
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresOnlyFromTheSavedDeadCharacterAndPreservesProgressAndCooldowns() throws Exception {
|
||||
var saved = YamlCharacterRepositoryTest.sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(saved);
|
||||
try (var storage = new CharacterStorage(repository, Clock.fixed(saved.diedAt().plusSeconds(60), ZoneOffset.UTC))) {
|
||||
var result = storage.restoreDeathRecord(saved.id(), saved.deathId()).join();
|
||||
assertTrue(result.isPresent(), "A current, recently dead saved character must be restored");
|
||||
var restored = result.orElseThrow();
|
||||
assertFalse(restored.dead());
|
||||
assertEquals(saved.maximumHealth(), restored.health());
|
||||
assertEquals(saved.id(), restored.id());
|
||||
assertEquals(saved.name(), restored.name());
|
||||
assertEquals(saved.stats(), restored.stats());
|
||||
assertEquals(saved.level(), restored.level());
|
||||
assertEquals(saved.experience(), restored.experience());
|
||||
assertEquals(saved.unspentPoints(), restored.unspentPoints());
|
||||
assertEquals(saved.learned(), restored.learned());
|
||||
assertEquals(saved.loadout(), restored.loadout());
|
||||
assertEquals(saved.cooldowns(), restored.cooldowns());
|
||||
assertEquals(saved.deathId(), restored.deathId());
|
||||
assertEquals(saved.diedAt(), restored.diedAt());
|
||||
assertEquals(saved.revision().add(BigInteger.ONE), restored.revision());
|
||||
assertEquals(restored, new YamlCharacterRepository(data).load(saved.id()).orElseThrow());
|
||||
assertTrue(storage.restoreDeathRecord(saved.id(), saved.deathId()).join().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package games.dmg.fantasystick.persistence;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.progression.Stat;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class YamlCharacterRepositoryTest {
|
||||
@TempDir Path data;
|
||||
private static final Instant DEATH = Instant.parse("2026-09-17T00:00:00Z"); // synthetic test clock
|
||||
|
||||
static CharacterRecord sample(UUID id) {
|
||||
var stats = new EnumMap<Stat, BigInteger>(Stat.class);
|
||||
for (Stat stat : Stat.values()) stats.put(stat, BigInteger.ZERO);
|
||||
stats.put(Stat.STR, BigInteger.TEN.pow(100));
|
||||
stats.put(Stat.CON, BigInteger.valueOf(6));
|
||||
return new CharacterRecord(id, "Ari 'Ash' ☃", BigInteger.ZERO, stats, 7,
|
||||
BigInteger.valueOf(600), BigInteger.TEN.pow(99), BigDecimal.ZERO,
|
||||
Set.of("fireball", "revive", "future.unknown"),
|
||||
List.of("fireball", "", "", "future.unknown", "revive", "", ""),
|
||||
Map.of("revive", DEATH.plusSeconds(86400), "unlearned.ability", DEATH.plusSeconds(60)),
|
||||
true, UUID.randomUUID(), DEATH);
|
||||
}
|
||||
|
||||
static CharacterRecord rename(CharacterRecord original, String name, BigInteger revision) {
|
||||
return new CharacterRecord(original.id(), name, revision, original.stats(), original.level(),
|
||||
original.experience(), original.unspentPoints(), original.health(), original.learned(),
|
||||
original.loadout(), original.cooldowns(), original.dead(), original.deathId(), original.diedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesExactlyTheExpectedRevisionAndNeverOverwritesWithAStaleCopy() throws Exception {
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var original = sample(UUID.randomUUID());
|
||||
repository.create(original);
|
||||
var updated = rename(original, "New name", BigInteger.ONE);
|
||||
repository.replace(updated, BigInteger.ZERO);
|
||||
assertEquals(updated, repository.load(original.id()).orElseThrow());
|
||||
assertThrows(IOException.class, () -> repository.replace(rename(original, "Stale name", BigInteger.ONE), BigInteger.ZERO));
|
||||
assertThrows(IOException.class, () -> repository.create(original));
|
||||
assertEquals(updated, repository.load(original.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAtomicReplacementKeepsOriginalStateAndCleansTemporaryData() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var failing = new YamlCharacterRepository(data, (temporary, target) -> {
|
||||
assertTrue(Files.isRegularFile(temporary));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
throw new IOException("Simulated atomic-move failure");
|
||||
});
|
||||
assertThrows(IOException.class,
|
||||
() -> failing.replace(rename(original, "Unsaved", BigInteger.ONE), BigInteger.ZERO));
|
||||
assertEquals(original, repository.load(original.id()).orElseThrow());
|
||||
try (var paths = Files.list(data.resolve("characters"))) {
|
||||
assertFalse(paths.anyMatch(path -> path.getFileName().toString().endsWith(".tmp")));
|
||||
}
|
||||
// A normal retry succeeds; there is no persistent administrator-unlock flag.
|
||||
repository.replace(rename(original, "Retry", BigInteger.ONE), BigInteger.ZERO);
|
||||
assertEquals("Retry", repository.load(original.id()).orElseThrow().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void separateRepositoryInstancesCannotCommitTheSameRevisionTwice() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
new YamlCharacterRepository(data).create(original);
|
||||
Callable<Boolean> change = () -> {
|
||||
try {
|
||||
new YamlCharacterRepository(data).replace(rename(original, "Winner", BigInteger.ONE), BigInteger.ZERO);
|
||||
return true;
|
||||
} catch (IOException conflict) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
int successes = 0;
|
||||
for (var future : pool.invokeAll(Collections.nCopies(8, change))) if (future.get()) successes++;
|
||||
assertEquals(1, successes);
|
||||
}
|
||||
assertEquals(BigInteger.ONE, new YamlCharacterRepository(data).load(original.id()).orElseThrow().revision());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesExactDecimalHealthWithoutExpandingHugeScalesOnSave() throws Exception {
|
||||
var template = sample(UUID.randomUUID());
|
||||
var tiny = new CharacterRecord(template.id(), template.name(), template.revision(), template.stats(),
|
||||
template.level(), template.experience(), template.unspentPoints(), new BigDecimal("1E-10000"),
|
||||
template.learned(), template.loadout(), template.cooldowns(), template.dead(), template.deathId(), template.diedAt());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(tiny);
|
||||
assertTrue(Files.size(data.resolve("characters").resolve(tiny.id() + ".yml")) < 4096,
|
||||
"Persist decimal notation without allocating thousands of padding zeros");
|
||||
assertEquals(tiny, repository.load(tiny.id()).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundsFileReadsAndWritesWithoutReplacingExistingData() throws Exception {
|
||||
var original = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
Path file = data.resolve("characters").resolve(original.id() + ".yml");
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
String oversized = "x".repeat(1_048_577);
|
||||
assertThrows(IOException.class,
|
||||
() -> repository.replace(rename(original, oversized, BigInteger.ONE), BigInteger.ZERO));
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
Files.writeString(file, oversized);
|
||||
assertThrows(IOException.class, () -> repository.load(original.id()));
|
||||
assertEquals(oversized.length(), Files.size(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidCharacterDataWithoutChangingTheOriginalFile() throws Exception {
|
||||
var character = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(character);
|
||||
Path file = data.resolve("characters").resolve(character.id() + ".yml");
|
||||
String valid = Files.readString(file);
|
||||
for (String invalid : List.of(
|
||||
valid.replaceAll("(?m)^revision:.*$", "revision: '-1'"),
|
||||
valid.replaceAll("(?m)^health:.*$", "health: '17'"),
|
||||
valid.replaceAll("(?m)^level:.*$", "level: 0"),
|
||||
valid.replaceAll("(?m)^death-id:.*\\R", ""),
|
||||
valid.replaceAll("(?m)^died-at:.*\\R", ""),
|
||||
valid.replace("schema-version: 1", "schema-version: 2"),
|
||||
valid.replace("dead: true", "dead: true\ndead: false"),
|
||||
valid.replace(character.id().toString(), UUID.randomUUID().toString()),
|
||||
"!!java.net.URL ['https://invalid.example/']")) {
|
||||
Files.writeString(file, invalid);
|
||||
IOException error = assertThrows(IOException.class, () -> repository.load(character.id()));
|
||||
assertEquals("Invalid character YAML for " + character.id(), error.getMessage());
|
||||
assertEquals(invalid, Files.readString(file));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSymlinkedRecordsInsteadOfReadingOutsideTheCharacterDirectory() throws Exception {
|
||||
var character = sample(UUID.randomUUID());
|
||||
Files.createDirectories(data.resolve("characters"));
|
||||
Path other = data.resolve("outside.yml");
|
||||
Files.writeString(other, CharacterYaml.encode(character));
|
||||
Files.createSymbolicLink(data.resolve("characters").resolve(character.id() + ".yml"), other);
|
||||
assertThrows(IOException.class, () -> new YamlCharacterRepository(data).load(character.id()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesACompleteVersionedCharacterYamlAndReloadsItWithoutItemOrHolderState() throws Exception {
|
||||
CharacterRecord character = sample(UUID.randomUUID());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(character);
|
||||
Path file = data.resolve("characters").resolve(character.id() + ".yml");
|
||||
assertTrue(Files.isRegularFile(file), "Create must persist character YAML in the plugin data directory");
|
||||
String yaml = Files.readString(file);
|
||||
assertTrue(yaml.contains("schema-version: 1"));
|
||||
assertTrue(yaml.contains("dead: true"));
|
||||
assertTrue(yaml.contains("2026-09-17T00:00:00Z"));
|
||||
assertFalse(yaml.contains("!!"), "Use plain YAML data, never Java object tags");
|
||||
assertEquals(character, new YamlCharacterRepository(data).load(character.id()).orElseThrow());
|
||||
assertTrue(repository.load(UUID.randomUUID()).isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user