Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 539900eb14 fix(tests): synchronize health saves before reentry and transfer
Release / release (push) Successful in 8m26s
CI / build (push) Successful in 4m19s
2026-09-19 14:32:35 -04:00
dmg 9828590009 fix(sheets): wrap character instructions into three lore lines
Release / release (push) Successful in 8m5s
CI / build (push) Successful in 4m36s
2026-09-19 14:11:14 -04:00
6 changed files with 57 additions and 27 deletions

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ Thirteen craft-persistence tests cover these backend boundaries, including worke
## Paper sheet and recipe definition
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and the exact offhand instruction. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterSheets` renders one Paper sheet from a saved live record, with its current name and three separate lore lines: `Character sheet:`, `Place in off hand`, `to enter fantasy world.` Recipe previews and acknowledged crafted sheets use the same layout; existing items are not bulk-rewritten. `fantasystick:sheet_version` (integer 1) and `character_id` (UUID string) identify the character, with no owner lock or duplicated progress snapshot. Identification is only a reference: it does not authorize entry or replace saved-state/session validation. Rendering from a dead record is refused.
`CharacterFactory` allocates a fresh UUID and randomized two-part character name when explicitly called. It reuses the progression initializer for zero stats, level 1/zero XP, configured unspent points and full base health, with empty abilities/loadout/cooldowns and no prior death. Name collisions are allowed; identity is independent. Construction and recipe previews do not invoke allocation. Unit tests check fresh defaults/configured arbitrary-precision points, request-time allocation, seeded name variation and same-name/different-ID characters. A native composition test prepares a planned batch through YAML and verifies individually named sheets/IDs after native serialization without consuming or delivering inventory items. The assembled craft-event controller now invokes this allocator only for accepted current attempts.
@@ -16,7 +16,8 @@ public final class CharacterSheets {
public static final NamespacedKey RECIPE = 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 String INSTRUCTION = "Character sheet: place in off hand to enter fantasy world.";
private static final List<Component> INSTRUCTION = List.of(Component.text("Character sheet:"),
Component.text("Place in off hand"), Component.text("to enter fantasy world."));
public ItemStack create(CharacterRecord character) {
if (character.dead()) throw new IllegalArgumentException("Dead characters require authoritative revival before sheet creation");
@@ -40,7 +41,7 @@ public final class CharacterSheets {
var item = new ItemStack(Material.PAPER);
var meta = item.getItemMeta();
meta.displayName(Component.text(name));
meta.lore(List.of(Component.text(INSTRUCTION)));
meta.lore(INSTRUCTION);
item.setItemMeta(meta);
return item;
}
@@ -166,7 +166,7 @@ class CharacterBookNativeTest {
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player); f.activate(a);
click(f, a); drainReads(f); // Old holder has a queued result, not a delivered book.
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
var renamed = characters(f).renameCharacter(a.core.record.id(), "Traded 雪 character").get(5, TimeUnit.SECONDS).orElseThrow();
runQueued(f); assertTrue(a.books.isEmpty());
b.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
@@ -84,6 +84,8 @@ class CharacterSheetsNativeTest {
assertEquals(1, preview.getAmount());
assertTrue(sheets.identify(preview).isEmpty());
assertTrue(preview.getItemMeta().getPersistentDataContainer().isEmpty());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), preview.getItemMeta().lore());
}
}
@@ -104,7 +106,8 @@ class CharacterSheetsNativeTest {
assertEquals(Material.PAPER, sheet.getType());
assertEquals(1, sheet.getAmount());
assertEquals(Component.text(record.name()), sheet.getItemMeta().displayName());
assertEquals(List.of(Component.text("Character sheet: place in off hand to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(List.of(Component.text("Character sheet:"), Component.text("Place in off hand"),
Component.text("to enter fantasy world.")), sheet.getItemMeta().lore());
assertEquals(record.id(), sheets.identify(sheet).orElseThrow());
assertEquals(Set.of(new NamespacedKey("fantasystick", "sheet_version"), new NamespacedKey("fantasystick", "character_id")),
sheet.getItemMeta().getPersistentDataContainer().getKeys(), "No player owner, equipment or progress snapshot belongs on the sheet");
@@ -178,6 +178,9 @@ class FantasyStickLifecycleNativeTest {
assertTrue(actor.fixture.view.getCursor().isEmpty());
var complete = fixture.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
assertEquals(List.of(net.kyori.adventure.text.Component.text("Character sheet:"),
net.kyori.adventure.text.Component.text("Place in off hand"),
net.kyori.adventure.text.Component.text("to enter fantasy world.")), actor.fixture.view.getCursor().getItemMeta().lore());
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new games.dmg.fantasystick.persistence.YamlCharacterRepository(directory).load(id).orElseThrow();
assertEquals(new java.math.BigInteger(points), saved.unspentPoints());
@@ -718,6 +721,7 @@ class FantasyStickLifecycleNativeTest {
assertNull(saved.pending()); assertNull(saved.lastExit());
} else {
f.activate(a); sessions.exit(a.player);
f.awaitCharacterAndReceipt(a);
assertEquals(org.bukkit.GameMode.ADVENTURE, a.player.getGameMode());
var saved = f.receipts().find(a.core.id).toCompletableFuture().get().orElseThrow();
assertNotNull(saved.pending()); assertNotNull(saved.lastExit());
@@ -787,7 +791,7 @@ class FantasyStickLifecycleNativeTest {
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
sessions.exit(a.player);
assertEquals(org.bukkit.GameMode.CREATIVE, a.player.getGameMode());
f.receipts().find(a.core.id).toCompletableFuture().get();
f.awaitCharacterAndReceipt(a);
var b = new Actor(data); f.players.put(b.core.id, b.player);
b.core.gameMode = org.bukkit.GameMode.SURVIVAL;
f.activate(b);
@@ -804,27 +808,43 @@ class FantasyStickLifecycleNativeTest {
if (!value.equals("default")) java.nio.file.Files.writeString(directory.resolve("config.yml"), "character-swap-cooldown-seconds: " + value + "\n");
try (var f = new Fixture(directory)) {
f.plugin.setEnabled(true); var a = new Actor(directory); f.players.put(a.core.id, a.player); f.activate(a);
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
worker.execute(() -> {
entered.countDown();
try { release.await(); }
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
});
try {
assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS));
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(a.player, (net.kyori.adventure.text.Component) null));
assertNotNull(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending(),
"Quit restores native state but must retain ownership until health persistence completes");
release.countDown();
f.awaitCharacterAndReceipt(a);
var exit = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().lastExit();
assertEquals(java.time.Duration.ofSeconds(value.equals("default") ? 900 : Long.parseLong(value)),
java.time.Duration.between(exit.exitedAt(), exit.deadline()));
assertEquals(a.core.record.id(), exit.characterId());
var other = new Actor(directory);
a.core.inventory.setItemInOffHand(other.core.inventory.getItemInOffHand().clone());
var before = a.core.snapshot();
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
f.tick(); f.tick(); // Join receipt reconciliation, then actual coordinator eligibility.
if (value.equals("0")) {
f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"), "Configured zero reaches entry, not only persisted arithmetic");
} else {
assertArrayEquals(before, a.core.snapshot()); assertTrue(a.core.marker().endsWith("|RESTORED"));
assertTrue(a.messages.getLast().matches("(?s).*Wait [1-9][0-9]* seconds.*same character may be resumed now.*"), a.messages::toString);
var state = f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow();
assertNull(state.pending()); assertEquals(exit, state.lastExit());
a.core.inventory.setItemInOffHand(new CharacterSheets().create(a.core.record));
f.dispatcher.accept(new org.bukkit.event.player.PlayerJoinEvent(a.player, (net.kyori.adventure.text.Component) null));
f.tick(); f.tick(); f.tick(); assertTrue(a.core.marker().endsWith("|PACKED"));
}
} finally { release.countDown(); }
}
}
}
@@ -1141,6 +1161,12 @@ class FantasyStickLifecycleNativeTest {
games.dmg.fantasystick.persistence.EquipmentSessionStore receipts() {
return (games.dmg.fantasystick.persistence.EquipmentSessionStore) field(plugin, "receipts");
}
void awaitCharacterAndReceipt(Actor actor) throws Exception {
// A receipt read alone cannot await work submitted later by the character-save callback.
((CharacterStorage) field(plugin, "characters")).find(actor.core.record.id())
.get(5, java.util.concurrent.TimeUnit.SECONDS);
receipts().find(actor.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
}
void tick() throws Exception {
var task = tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(task, "Missing deferred main-thread callback"); task.run();
@@ -234,7 +234,7 @@ class PartyBookNativeTest {
open(f, a); var oldDisband = button(a, "[Disband party]");
var sheet = a.core.inventory.getItemInOffHand().clone();
a.core.inventory.setItemInOffHand(null); f.changed(a);
f.receipts().find(a.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
f.awaitCharacterAndReceipt(a);
b.core.inventory.setItemInOffHand(sheet); f.activate(b);
open(f, b);
assertTrue(text(b).contains(a.core.record.name()));