Compare commits

...
2 Commits
Author SHA1 Message Date
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
dmg c85b5e91cc fix(sessions): preserve client life and group Tail pages
Release / release (push) Successful in 8m2s
CI / build (push) Successful in 4m49s
2026-09-19 14:04:57 -04:00
8 changed files with 178 additions and 17 deletions

No files matched your search

+3 -3
View File
@@ -9,7 +9,7 @@ Fantasy Stick is the proposed transferable-character RPG plugin for Social Minec
- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1.
- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`.
- Node.js 20 or later and npm for the TypeScript release-tool tests.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled.
- ProtocolLib **5.4.0** is required at runtime and declared as a compile-only dependency; it is not bundled. Install the official [server distribution](https://github.com/dmulloy2/ProtocolLib/releases/tag/5.4.0), not the Maven library JAR (which lacks bundled runtime dependencies).
```sh
./gradlew clean check jar
@@ -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.
@@ -172,7 +172,7 @@ Offhand entry applies actual native maximum health `min(10 + CON, native health
Ordinary health, food, saturation, exhaustion and health-display state belong to the holder/session, not the character sheet. The session receipt acknowledges this snapshot before native mutation; versioned player PDC records its application/restoration phase. Voluntary exit restores ordinary gear/vitals at the current location, with ordinary health capped by the current legitimate maximum. Character damage is saved on the worker, and the character claim/receipt cannot complete ahead of that acknowledgment. Quit and disable perform the same best-effort cleanup. Failed or uncertain saves retain the receipt and acknowledged character record instead of permitting a stale-health reentry. Missing/ambiguous native proof never authorizes a snapshot heal; process-owned maximum overrides are still retired independently of equipment/client-write failures.
An intercepted fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
An intercepted fantasy defeat records authoritative death once, removes session state, restores ordinary health to its current maximum and attempts return to the entry location. Ordinary health is restored before health-scale setters can send or queue client health packets: cancelled defeat must not deliver zero health and strand an otherwise alive player on a death screen. Refused/failed return leaves the player at the current location with a warning, not an alternate destination or invulnerability. A public red burst marks the defeat location. After the death acknowledgment, guarded conversion replaces the unchanged sheet stack with one signed Tail book. New Tails group ordinary-sized name/level/XP/points/stats on a summary page, then give each learned ability its own section with saved ID, assigned ability slots and cooldown deadline. Unknown IDs, saved unlearned cooldowns, empty abilities and death time stay explicit; long content continues onto additional pages without truncation. No ability descriptions are invented and existing books are not rewritten. Static Unicode-safe Tail pagination survives native serialization without a plugin; signed books use a different codec from the 100-page writable-book format. Stale or ambiguous conversion never overwrites another item or automatically reissues a book.
Interrupted recovery requires matching player/session receipt and native proof. It preserves current native modifier ownership and does not resurrect expired effects. Repeated/stale recovery cannot replay ordinary health or recreate an already released claim. This is **not crash-atomic** across player, inventory and plugin files; unresolved native setter phases and unacknowledged progress may require inspection. Legacy equipment-only receipts remain readable but do not grant health-restoration authority. Older binaries do not understand the new optional ordinary-vitals receipt field; do not downgrade across unresolved newer receipts.
@@ -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;
}
@@ -1,5 +1,6 @@
package games.dmg.fantasystick.items;
import games.dmg.fantasystick.book.BookPagination;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.progression.Stat;
import java.util.ArrayList;
@@ -35,14 +36,26 @@ public final class TailBooks {
meta.setGeneration(BookMeta.Generation.ORIGINAL);
meta.displayName(Component.text(fullTitle));
List<Component> pages = new ArrayList<>();
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages(fullTitle));
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages("Level: " + character.level()));
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages("XP: " + character.experience()));
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages("Unspent points: " + character.unspentPoints()));
for (Stat stat : Stat.values()) pages.addAll(games.dmg.fantasystick.book.BookPagination.pages(stat + ": " + character.stats().get(stat)));
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages("Abilities: " + (character.learned().isEmpty() ? "None" : "")));
character.learned().stream().sorted().forEach(ability -> pages.addAll(games.dmg.fantasystick.book.BookPagination.pages(ability)));
pages.addAll(games.dmg.fantasystick.book.BookPagination.pages("Fell: " + character.diedAt()));
var summary = new StringBuilder(fullTitle).append("\nLevel: ").append(character.level())
.append("\nXP: ").append(character.experience()).append("\nUnspent points: ").append(character.unspentPoints());
for (Stat stat : Stat.values()) summary.append('\n').append(stat).append(": ").append(character.stats().get(stat));
pages.addAll(BookPagination.pages(summary.toString()));
if (character.learned().isEmpty()) pages.addAll(BookPagination.pages("Abilities: None"));
character.learned().stream().sorted().forEach(ability -> {
var slots = new ArrayList<String>();
for (int slot = 0; slot < character.loadout().size(); slot++) {
if (character.loadout().get(slot).equals(ability)) slots.add(Integer.toString(slot + 1));
}
var section = new StringBuilder("Ability: ").append(ability)
.append("\nSlots: ").append(slots.isEmpty() ? "none" : String.join(", ", slots));
var deadline = character.cooldowns().get(ability);
section.append(deadline == null ? "\nCooldown: none" : "\nCooldown until:\n" + deadline);
pages.addAll(BookPagination.pages(section.toString()));
});
// Unlearned/unknown saved cooldowns remain readable without pretending they are learned abilities.
character.cooldowns().keySet().stream().filter(id -> !character.learned().contains(id)).sorted().forEach(id ->
pages.addAll(BookPagination.pages("Saved cooldown\n" + id + "\nUntil:\n" + character.cooldowns().get(id))));
pages.addAll(BookPagination.pages("Fell:\n" + character.diedAt()));
meta.pages(pages);
var data = meta.getPersistentDataContainer();
data.set(VERSION, PersistentDataType.INTEGER, 1);
@@ -93,9 +93,12 @@ public final class SessionVitals implements AutoCloseable {
finally { releaseMaximum(player, session); }
// On restart there is no hook: native attribute serialization retained ordinary
// base/permanent modifiers, and the native effect lifecycle owns potion expiry/removal.
restoreScale(player, mark.maximum(), ordinary);
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
// CraftPlayer scale setters send health packets immediately (or queue one for damage
// completion). Restore positive health first: a zero-health packet opens a client death
// screen even when Paper cancels the death and the server-side player survives.
player.setHealth(Math.min(ordinary.health(), Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue()));
player.setFoodLevel(ordinary.food()); player.setSaturation(ordinary.saturation()); player.setExhaustion(ordinary.exhaustion());
restoreScale(player, mark.maximum(), ordinary);
write(player, new Mark(session, Phase.RELEASED, mark.maximum(), ordinary));
return true;
}
@@ -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");
@@ -0,0 +1,86 @@
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.ArrayList;
import java.util.List;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSetHealthPacket;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DefeatHealthDeliveryNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void zeroHealthDefeatNeverDeliversADeathScreenHealthUpdateAndTailArrivesWithoutReconnect() throws Exception {
for (boolean queued : List.of(false, true)) try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("queued-" + queued))) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data.resolve("queued-" + queued));
f.players.put(a.core.id, a.player);
var sent = nativeHealthDelivery(a);
var entry = a.player.getLocation();
f.activate(a);
a.core.location.add(20, 0, 20);
a.core.health = 0; // The real death callback begins after native health reaches zero.
sent.clear();
a.nativePlayer.queueHealthUpdatePacket = queued;
var death = death(a);
f.dispatcher.accept(death);
if (a.nativePlayer.queuedHealthUpdatePacket != null) sent.add(a.nativePlayer.queuedHealthUpdatePacket);
a.nativePlayer.queueHealthUpdatePacket = false;
PartyBookNativeTest.drain(f);
assertTrue(death.isCancelled());
assertEquals(a.maximumHealth.getValue(), a.player.getHealth());
assertEquals(entry, a.player.getLocation());
assertFalse(a.player.isHealthScaled());
var updates = sent.stream().filter(ClientboundSetHealthPacket.class::isInstance)
.map(ClientboundSetHealthPacket.class::cast).toList();
assertFalse(updates.isEmpty(), "Exercise actual CraftPlayer scale/health delivery, not a mocked setter");
assertTrue(updates.stream().allMatch(p -> p.getHealth() > 0),
() -> "Cancelled fantasy defeat must never deliver zero health: " + updates.stream().map(ClientboundSetHealthPacket::getHealth).toList());
var tail = a.core.inventory.getItemInOffHand().clone();
assertEquals(org.bukkit.Material.WRITTEN_BOOK, tail.getType()); assertEquals(1, tail.getAmount());
f.dispatcher.accept(death); PartyBookNativeTest.drain(f);
assertEquals(tail, a.core.inventory.getItemInOffHand());
// A subsequent ordinary death is not protected by a blanket zero-health packet filter.
a.core.health = 0;
var ordinaryDeath = death(a); f.dispatcher.accept(ordinaryDeath);
assertFalse(ordinaryDeath.isCancelled());
a.player.sendHealthUpdate();
assertEquals(0, ((ClientboundSetHealthPacket) sent.getLast()).getHealth());
}
}
private static org.bukkit.event.entity.PlayerDeathEvent death(FantasyStickLifecycleNativeTest.Actor a) {
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
return new org.bukkit.event.entity.PlayerDeathEvent(a.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
}
private static List<Packet<?>> nativeHealthDelivery(FantasyStickLifecycleNativeTest.Actor a) {
var sent = new ArrayList<Packet<?>>();
var connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
doAnswer(call -> { sent.add(call.getArgument(0)); return null; }).when(connection).send(any(Packet.class));
a.nativePlayer.connection = connection;
when(a.nativePlayer.getFoodData()).thenReturn(a.food);
// Transport and tracked entity storage are explicit ports; scaling and packet creation run natively.
when(a.nativePlayer.getEntityData()).thenReturn(mock(net.minecraft.network.syncher.SynchedEntityData.class));
doCallRealMethod().when(a.player).setHealthScale(anyDouble());
doCallRealMethod().when(a.player).setHealthScaled(anyBoolean());
doCallRealMethod().when(a.player).getHealthScale();
doCallRealMethod().when(a.player).isHealthScaled();
doCallRealMethod().when(a.player).getScaledHealth();
doCallRealMethod().when(a.player).getScaledMaxHealth();
doCallRealMethod().when(a.player).updateScaledHealth();
doCallRealMethod().when(a.player).updateScaledHealth(anyBoolean());
doCallRealMethod().when(a.player).sendHealthUpdate();
a.player.setHealthScale(20); a.player.setHealthScaled(false);
sent.clear(); return sent;
}
}
@@ -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());
@@ -84,6 +84,58 @@ class TailBooksNativeTest {
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(CraftItemStack.asBukkitCopy(decoded)).orElseThrow());
}
@Test void ordinaryStatsShareOneSummaryAndEachSavedAbilityHasItsOwnReadableSection() {
var old = fallen();
var expiry = old.diedAt().plusSeconds(3600);
var saved = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of("second_wind", "unknown:雪"),
List.of("second_wind", "unknown:雪", "second_wind", "", "", "", ""),
Map.of("second_wind", expiry, "retired_ability", expiry.plusSeconds(60)), true, old.deathId(), old.diedAt());
var book = new TailBooks().create(saved);
var pages = nativePages(book);
String summary = pages.getFirst().replace("\n", "");
for (String expected : List.of("Tail of Ari", "Level: 2", "XP: 100", "Unspent points: 30",
"STR: 0", "DEX: 0", "CON: 10", "INT: 0", "WIS: 0", "CHR: 0")) assertTrue(summary.contains(expected), expected);
String wind = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: second_wind")).findFirst().orElseThrow();
assertTrue(wind.contains("Slots: 1, 3")); assertTrue(wind.contains(expiry.toString()));
assertFalse(wind.contains("unknown:雪"), "Each learned ability starts its own page/section");
String unknown = pages.stream().map(p -> p.replace("\n", "")).filter(p -> p.contains("Ability: unknown:雪")).findFirst().orElseThrow();
assertTrue(unknown.contains("Slots: 2")); assertTrue(unknown.contains("Cooldown: none"));
String all = String.join("", pages).replace("\n", "");
assertTrue(all.contains("Saved cooldown")); assertTrue(all.contains("retired_ability"));
assertTrue(all.contains(expiry.plusSeconds(60).toString())); assertTrue(all.contains(old.diedAt().toString()));
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), new TailBooks().identify(book).orElseThrow());
}
@Test void emptyAbilitiesAreExplicitAndLongUnknownAbilityPagesRetainEveryCodePoint() {
var old = fallen();
var empty = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var legacyBook = new TailBooks().create(empty);
var original = legacyBook.clone();
assertTrue(nativePages(legacyBook).stream().anyMatch(p -> p.replace("\n", "").contains("Abilities: None")));
String id = "unknown:雪🐈".repeat(4000);
var longAbility = new CharacterRecord(old.id(), old.name(), old.revision(), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), Set.of(id), List.of("", "", "", "", "", "", ""), Map.of(), true, old.deathId(), old.diedAt());
var pages = nativePages(new TailBooks().create(longAbility));
assertTrue(pages.size() > 100);
assertTrue(String.join("", pages).replace("\n", "").contains("Ability: " + id));
assertTrue(String.join("", pages).replace("\n", "").contains("Slots: none"));
for (String page : pages) {
assertTrue(page.split("\n", -1).length <= 13);
for (String line : page.split("\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108);
}
assertEquals(original, legacyBook, "Rendering another record must not rewrite any existing signed keepsake");
}
private static List<String> nativePages(ItemStack book) {
var ops = RegistryOps.create(NbtOps.INSTANCE, CraftRegistry.getMinecraftRegistry());
var encoded = net.minecraft.world.item.ItemStack.CODEC.encodeStart(ops, CraftItemStack.asNMSCopy(book)).getOrThrow();
var decoded = net.minecraft.world.item.ItemStack.CODEC.parse(ops, encoded).getOrThrow();
return java.util.Objects.requireNonNull(decoded.get(DataComponents.WRITTEN_BOOK_CONTENT)).pages().stream()
.map(p -> p.raw().getString()).toList();
}
@Test
void identityComesOnlyFromTypedMetadataNotEditablePagesOrANameOnlyLookalike() {
var saved = fallen();
@@ -122,7 +174,7 @@ class TailBooksNativeTest {
assertTrue(pages.contains("Level: 2"));
assertTrue(pages.contains("XP: 100"));
assertTrue(pages.contains("CON: 10"));
assertTrue(pages.contains("second_wind"));
assertTrue(pages.replace("\n", "").contains("second_wind"), "Labeled ability IDs remain complete across readable line wrapping");
assertEquals(new TailBooks.Identity(saved.id(), saved.deathId()), books.identify(CraftItemStack.asBukkitCopy(restored)).orElseThrow());
}
}