Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 6f42082383 feat(book): complete paginated character inspection
Release / release (push) Successful in 8m20s
CI / build (push) Successful in 4m24s
2026-09-19 11:12:19 -04:00
dmg 9ddc139544 test(inventory): verify complete guarded control journey
CI / build (push) Successful in 5m52s
Release / release (push) Successful in 5m54s
2026-09-19 11:00:03 -04:00
5 changed files with 283 additions and 9 deletions

No files matched your search

+9 -1
View File
@@ -1,6 +1,6 @@
# Fantasy Stick
**Transferable characters and standalone names (US-001), equipment restoration (US-002), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
**Transferable characters and standalone names (US-001), equipment restoration (US-002), inventory safety (US-003), paginated character inspection (US-005), book parties/native damage protection (US-012), and switch delay (US-023) have automated implementation coverage. The full RPG remains in development and is not deployment-ready.**
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. Crafting, equipment-only offhand sessions, read-only character books and book-managed parties with native damage protection are assembled; fantasy health/nutrition, stat/ability book actions, combat progression and spells remain incomplete. 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. Saved-sheet crafting is wired; anvil-name persistence now observes native accepted takes; fantasy health and spell interactions remain incomplete.
@@ -164,6 +164,14 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
## Character inspection and inventory safety
The first-hotbar-slot book reads the active character's authoritative name, six stats, health, level, XP/MAX, points and learned identities. Inspection does not change character data or inventory. Plain-text pages wrap conservatively for the vanilla font without splitting Unicode code points. Large supported records use volumes of at most 80 inspection pages, leaving room for navigation and party controls within the native book limit. `[Next pages]` / `[Previous pages]` reread current data; unknown ability IDs and arbitrarily large saved numbers are not silently discarded.
Deferred reads and single-use navigation validate the player, session, character and held control. Superseded requests, ended sessions, quit/disable and invalid client tokens cannot open a stale view. Read failures preserve data and allow reopening. Native tests cover overflow, multiple volumes, rename, transfer, reload and unchanged inventory/data; actual client rendering and custom resource-pack fonts are not claimed as tested.
All nine hotbar controls remain bound, with seven selectable placeholders on a new character. Native containment tests cover export/consumption, hidden swaps, recipe autofill, armor/cosmetics, cursor custody and sorting relocation. The packaged inventory journey proves that these guards still permit the real book action and that removing the transferable offhand sheet restores ordinary equipment. Future attack/ability consumers must retain these same safeguards.
## Character-book parties and combat protection
Use the slot-1 book to create a party, invite online players, accept invitations, leave, kick members or disband. Parties hold up to six consenting players. Only the creator/leader may invite, kick or disband; a leader leaving disbands rather than transferring leadership. Invitations last 60 real seconds and reserve no capacity. Book actions are single-use and bound to the current player/session/view, with authority and capacity rechecked on execution. Membership belongs to the player, not a traded character sheet, and grants no XP by itself.
@@ -0,0 +1,37 @@
package games.dmg.fantasystick.book;
import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
/** Plain native book pages, conservatively bounded for the vanilla font (no styled/bold text). */
public final class BookPagination {
private BookPagination() { }
public static List<Component> pages(String text) {
var lines = new ArrayList<String>();
var line = new StringBuilder();
int width = 0;
for (int offset = 0; offset < text.length();) {
int codePoint = text.codePointAt(offset);
offset += Character.charCount(codePoint);
if (codePoint == '\n') {
lines.add(line.toString()); line.setLength(0); width = 0;
continue;
}
// ASCII is at most six pixels; vanilla's Unicode fallback is at most nine.
// Leave six pixels of horizontal slack and one line of vertical slack.
int advance = codePoint < 128 ? 6 : 9;
if (width + advance > 108) {
lines.add(line.toString()); line.setLength(0); width = 0;
}
line.appendCodePoint(codePoint); width += advance;
}
lines.add(line.toString());
var pages = new ArrayList<Component>();
for (int start = 0; start < lines.size(); start += 13) {
pages.add(Component.text(String.join("\n", lines.subList(start, Math.min(start + 13, lines.size())))));
}
return List.copyOf(pages);
}
}
@@ -6,7 +6,6 @@ import games.dmg.fantasystick.progression.ProgressionRules;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.ControlInput;
import games.dmg.fantasystick.session.NativeHealthLimit;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@@ -27,6 +26,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private final Consumer<Failure> failures;
private final Map<UUID, Object> pending = new ConcurrentHashMap<>();
private volatile boolean closed;
private static final int VOLUME_SIZE = 80; // Leave room for navigation and the bounded party pages.
private static final net.kyori.adventure.key.Key PAGES = net.kyori.adventure.key.Key.key("fantasystick", "character_pages");
private static final java.util.regex.Pattern PAGE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([01])\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private final Map<UUID, View> views = new ConcurrentHashMap<>();
/** Scheduler must always defer to the server thread. Diagnostics must not access players or throw. */
public CharacterBookController(CharacterStorage characters, ProgressionRules rules,
@@ -39,11 +43,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
this.partyControls = new PartyBookControls(parties, server, names, this::accept, this::report);
}
@Override public void accept(ControlInput input) {
@Override public void accept(ControlInput input) { load(input, 0); }
private void load(ControlInput input, int offset) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
partyControls.forget(owner);
views.remove(owner);
Object ticket = new Object();
pending.put(owner, ticket);
characters.find(input.session().character()).whenComplete((record, failure) -> {
@@ -60,8 +67,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
input.player().sendMessage("This character is no longer available.");
return;
}
try { input.player().openBook(render(record.orElseThrow(), input)); }
catch (RuntimeException error) { partyControls.forget(owner); report(input, error); }
try { input.player().openBook(render(record.orElseThrow(), input, offset)); }
catch (RuntimeException error) { partyControls.forget(owner); views.remove(owner); report(input, error); }
});
} catch (RuntimeException rejected) {
pending.remove(owner, ticket);
@@ -70,7 +77,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
});
}
private ItemStack render(CharacterRecord record, ControlInput input) {
private ItemStack render(CharacterRecord record, ControlInput input, int offset) {
var first = new StringBuilder(record.name()).append("\n\n");
for (var stat : Stat.values()) first.append(stat).append(": ").append(record.stats().get(stat)).append('\n');
var maximum = record.maximumHealth().min(NativeHealthLimit.read());
@@ -84,13 +91,48 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var meta = (BookMeta) item.getItemMeta();
meta.setTitle("Character");
meta.setAuthor("Fantasy Stick");
var pages = new java.util.ArrayList<Component>(List.of(Component.text(first.toString()), Component.text(second)));
var all = BookPagination.pages(first + "\n" + second);
int start = Math.min(offset, (all.size() - 1) / VOLUME_SIZE * VOLUME_SIZE);
int end = Math.min(start + VOLUME_SIZE, all.size());
var pages = new java.util.ArrayList<Component>(all.subList(start, end));
if (all.size() > VOLUME_SIZE) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < all.size() ? end : -1);
views.put(input.session().player(), view);
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + all.size());
if (view.previous() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Previous pages", view, 0));
if (view.next() >= 0) navigation = navigation.append(Component.newline()).append(pageButton("Next pages", view, 1));
pages.add(navigation);
}
pages.addAll(partyControls.pages(input));
meta.pages(pages);
item.setItemMeta(meta);
return item;
}
private static Component pageButton(String label, View view, int direction) {
return Component.text("[" + label + "]", net.kyori.adventure.text.format.NamedTextColor.DARK_BLUE)
.clickEvent(net.kyori.adventure.text.event.ClickEvent.custom(PAGES,
net.kyori.adventure.nbt.api.BinaryTagHolder.binaryTagHolder("\"" + view.nonce() + ":" + direction + "\"")));
}
@org.bukkit.event.EventHandler
public void pageAction(io.papermc.paper.event.player.PlayerCustomClickEvent event) {
if (closed || !PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var player = connection.getPlayer();
var view = views.get(player.getUniqueId());
if (view == null || view.input().player() != player || event.getTag() == null) return;
String raw = event.getTag().string();
if (raw.length() > 64) return;
var match = PAGE_PAYLOAD.matcher(raw);
if (!match.matches() || !view.nonce().equals(match.group(1))) return;
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) return;
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset);
}
private void report(ControlInput input, Throwable failure) {
try { failures.accept(new Failure(input.session().player(), input.session().character(), failure)); }
catch (RuntimeException ignored) { /* A diagnostic failure must not replay a book request. */ }
@@ -103,8 +145,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
var owner = event.getPlayer().getUniqueId();
pending.remove(owner);
views.remove(owner);
partyControls.forget(owner);
}
@Override public void close() { closed = true; pending.clear(); partyControls.close(); }
@Override public void close() { closed = true; pending.clear(); views.clear(); partyControls.close(); }
}
@@ -70,13 +70,127 @@ class CharacterBookNativeTest {
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size());
String rendered = text(a.books.getFirst());
String rendered = text(a.books.getFirst()).replace("\n", "");
for (String expected : java.util.List.of("Level: 30", "XP: MAX", "Unspent points: 9", "STR: " + strength,
"revive", "unavailable_saved_id")) assertTrue(rendered.contains(expected), expected);
assertEquals(saved, characters(f).find(old.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void nativePagesKeepLargeNumbersUnicodeNamesAndUnknownAbilitiesReadableAndComplete() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
var old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats());
String digits = "1234567890".repeat(400);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
String name = "雪🐈".repeat(80);
var learned = java.util.stream.IntStream.range(0, 50).mapToObj(i -> "unknown_ability_" + i).collect(java.util.stream.Collectors.toSet());
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), name, old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), learned, old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
var before = a.core.snapshot();
click(f, a); drainReads(f); runQueued(f);
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content);
for (var page : content.pages()) {
String text = page.raw().getString();
assertTrue(text.split("\\n", -1).length <= 13, "Native page exceeds visible line count");
for (String line : text.split("\\n", -1)) assertTrue(line.codePoints().map(cp -> cp < 128 ? 6 : 9).sum() <= 108,
"Native line exceeds conservative visible width: " + line.length());
}
String complete = text(a.books.getLast()).replace("\n", "");
assertTrue(complete.contains(name)); assertTrue(complete.contains(digits));
for (String ability : learned) assertTrue(complete.contains(ability), ability);
assertArrayEquals(before, a.core.snapshot());
assertEquals(saved, characters(f).find(saved.id()).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@Test void valuesBeyondOneNativeBookRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player); f.activate(a);
String digits = "1234567890".repeat(4000);
var old = a.core.record;
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(digits));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(), old.revision().add(java.math.BigInteger.ONE),
stats, old.level(), old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
byte[] bytes = Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml"));
click(f, a); drainReads(f); runQueued(f);
assertEquals(1, a.books.size(), "Large but supported records still open a native book");
StringBuilder all = new StringBuilder();
int volumes = 0;
while (true) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);
assertNotNull(content); assertTrue(content.pages().size() <= 100, "Never exceed the native book page limit");
for (var page : content.pages()) if (!hasClick(page.raw())) all.append(page.raw().getString().replace("\n", ""));
if (!text(a.books.getLast()).contains("[Next pages]")) break;
assertTrue(++volumes < 10, "Navigation must terminate, not repeat a truncated volume");
var next = PartyBookNativeTest.button(a, "[Next pages]");
if (volumes == 1) {
var stranger = new FantasyStickLifecycleNativeTest.Actor(data);
PartyBookNativeTest.click(f, stranger, next);
for (var payload : java.util.List.of(net.minecraft.nbt.StringTag.valueOf("x".repeat(1000)),
net.minecraft.nbt.StringTag.valueOf("not-a-token"), new net.minecraft.nbt.CompoundTag())) {
PartyBookNativeTest.click(f, a, new net.minecraft.network.chat.ClickEvent.Custom(next.id(), java.util.Optional.of(payload)));
}
assertTrue(stranger.books.isEmpty());
}
PartyBookNativeTest.click(f, a, next); PartyBookNativeTest.click(f, a, next);
drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "One native action cannot open two volumes");
}
assertTrue(volumes > 0); assertTrue(all.toString().contains(digits), "Every digit remains reachable in order");
var previous = PartyBookNativeTest.button(a, "[Previous pages]");
a.core.inventory.getInventory().setSelectedSlot(1);
PartyBookNativeTest.click(f, a, previous); drainReads(f); runQueued(f);
assertEquals(volumes + 1, a.books.size(), "Stale held-slot navigation has no authority");
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
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);
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));
f.activate(b); click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
assertTrue(a.books.isEmpty());
f.plugin.setEnabled(false); f.awaitWorkers();
f.players.clear(); f.plugin.setEnabled(true);
f.players.put(b.core.id, b.player); f.reenter(b);
var before = b.core.snapshot();
var path = data.resolve("characters").resolve(renamed.id() + ".yml");
byte[] saved = Files.readAllBytes(path);
click(f, b); drainReads(f); runQueued(f);
assertTrue(text(b.books.getLast()).replace("\n", "").contains(renamed.name()));
var meta = (org.bukkit.inventory.meta.BookMeta) b.books.getLast().getItemMeta();
assertEquals("Character", meta.getTitle()); assertEquals("Fantasy Stick", meta.getAuthor());
assertArrayEquals(saved, Files.readAllBytes(path)); assertArrayEquals(before, b.core.snapshot());
}
}
private static boolean hasClick(net.minecraft.network.chat.Component component) {
return component.getStyle().getClickEvent() != null || component.getSiblings().stream().anyMatch(CharacterBookNativeTest::hasClick);
}
@Test void malformedRecordReadLeavesDataAndInventoryUntouchedAndCanBeRetriedAfterRepair() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -0,0 +1,72 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.items.SessionControls;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Item;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Supported offhand entry -> guarded delivered controls -> actual book -> ordinary exit. */
class InventoryJourneyNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void allNineDeliveredControlsStayContainedWhileTheActualBookWorksAndTheSheetCanLeave() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(a.core.id, a.player);
var ordinary = a.core.snapshot();
var sheet = a.core.inventory.getItemInOffHand().clone();
f.activate(a);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
var active = sessions.active(a.core.id).orElseThrow();
var delivered = a.core.snapshot();
for (int slot = 0; slot < 9; slot++) {
var item = a.core.inventory.getItem(slot);
var identity = new SessionControls().identify(item).orElseThrow();
assertEquals(slot, identity.slot());
assertEquals(active.player(), identity.owner());
assertEquals(active.session(), identity.session());
assertEquals(slot == 0 ? Material.BOOK : slot == 1 ? Material.STICK : Material.GRAY_DYE, item.getType());
var entity = mock(Item.class); when(entity.getItemStack()).thenReturn(item);
var drop = new PlayerDropItemEvent(a.player, entity);
f.dispatcher.accept(drop); assertTrue(drop.isCancelled());
var consume = new PlayerItemConsumeEvent(a.player, item, EquipmentSlot.HAND);
f.dispatcher.accept(consume); assertTrue(consume.isCancelled());
var transfer = new InventoryMoveItemEvent(a.core.inventory, item, a.core.view.getTopInventory(), true);
f.dispatcher.accept(transfer); assertTrue(transfer.isCancelled());
a.core.inventory.getInventory().setSelectedSlot(slot);
var use = new PlayerInteractEvent(a.player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, EquipmentSlot.HAND);
f.dispatcher.accept(use); assertEquals(Event.Result.DENY, use.useItemInHand());
var characters = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
characters.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
assertEquals(1, a.books.size(), "Only slot 1 opens the actual native book; other delivered controls are inert");
}
assertArrayEquals(delivered, a.core.snapshot(), "Containment and inspection never consume/export inventory");
assertEquals(sheet, a.core.inventory.getItemInOffHand());
a.core.inventory.setItemInOffHand(null); f.changed(a);
assertTrue(sessions.active(a.core.id).isEmpty(), "Transferable sheet removal exits without a combat lock");
ordinary[40] = null;
assertArrayEquals(ordinary, a.core.snapshot(), "Ordinary gear is restored without replacing the removed sheet");
}
}
}