feat(events): audit bound book volume navigation
Release / release (push) Failing after 7m17s

This commit is contained in:
dmg committed 2026-09-25 10:44:46 -04:00
1 parent 3f104cb0e2
commit 2cc053e6ff
3 files changed
+98 -11

No files matched your search

@@ -61,7 +61,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
private static final java.util.regex.Pattern RARE_CONFIRM_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36})\"");
private record View(String nonce, ControlInput input, int previous, int next) { }
private record View(String nonce, ControlInput input, int start, int navigationPage,
int previous, int next) { }
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active,
int previous, int next) { }
@@ -244,6 +245,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
private void load(ControlInput input, int offset, boolean userRequested) {
load(input, offset, userRequested, null);
}
private void load(ControlInput input, int offset, boolean userRequested, View from) {
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|| !input.stillCurrent()) return;
var owner = input.session().player();
@@ -255,7 +260,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
rareViews.remove(owner);
reskillViews.remove(owner);
var current=sessions.currentRecord(input.session()).orElse(null);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset):null;
var navigation=from==null?null:new games.dmg.fantasystick.events.BookEventBridge.Navigation(
from.start(),from.navigationPage(),offset);
var audit=userRequested&&bookEvents!=null&&current!=null?bookEvents.request(input,current,offset,navigation):null;
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
if(audit!=null)pendingBookEvents.put(owner,audit);
Object ticket = new Object();
@@ -398,8 +405,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (allOverview.size() > VOLUME_SIZE) {
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
if (interactive) {
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
end < allOverview.size() ? end : -1);
var view = new View(UUID.randomUUID().toString(), input, start, pages.size() + 1,
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
views.put(input.session().player(), view);
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
.append(pageButton("Previous pages", view, 0));
@@ -775,7 +782,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
int offset = match.group(2).equals("0") ? view.previous() : view.next();
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
// Reread authoritative data, rather than retaining a stale giant character snapshot.
load(view.input(), offset, true);
load(view.input(), offset, true, view);
}
private void refusePage(org.bukkit.entity.Player player,String reason){
@@ -14,8 +14,16 @@ import org.bukkit.Location;
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
public final class BookEventBridge {
public record Navigation(int sourceVolumeStart, int sourceNavigationPage, int destinationVolumeStart) {
public Navigation {
if (sourceVolumeStart < 0 || sourceNavigationPage < 1 || sourceNavigationPage > 100
|| destinationVolumeStart < 0 || sourceVolumeStart == destinationVolumeStart)
throw new IllegalArgumentException("Invalid book volume navigation");
}
}
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
int offset, BigInteger requestedRevision) {
int offset, BigInteger requestedRevision, Navigation navigation) {
public Request {
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
@@ -32,10 +40,15 @@ public final class BookEventBridge {
}
public Request request(ControlInput input, CharacterRecord current, int offset) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
return request(input, current, offset, null);
}
public Request request(ControlInput input, CharacterRecord current, int offset, Navigation navigation) {
Objects.requireNonNull(input); Objects.requireNonNull(current);
var request = new Request(UUID.randomUUID(), context(input, current.name()),
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision());
navigation == null ? "character_book_open" : "character_book_navigate", "active", offset,
current.revision(), navigation);
emit("action.observed", request, "observed", null, null, null, null);
return request;
}
@@ -47,7 +60,7 @@ public final class BookEventBridge {
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
CharacterRecord current, String actionKind, String reason) {
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision(),null);
emit("action.observed",request,"observed",null,null,null,null);
refused(request,reason);
}
@@ -60,7 +73,7 @@ public final class BookEventBridge {
Objects.requireNonNull(loaded);
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
presentedVolumes);
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
emit(request.navigation() == null ? "character_book.opened" : "character_book.navigated", request,
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
}
@@ -68,7 +81,18 @@ public final class BookEventBridge {
Integer pages, Integer volumes) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
data.put("section", request.navigation() == null ? "overview" : "volume_navigation");
data.put("control_kind", "book"); data.put("control_slot", "0");
if (request.navigation() != null) {
var navigation = request.navigation();
data.put("source_volume_start", Integer.toString(navigation.sourceVolumeStart()));
data.put("source_navigation_page", navigation.sourceNavigationPage());
data.put("destination_volume_start", Integer.toString(navigation.destinationVolumeStart()));
data.put("destination_page", "1");
data.put("destination_section", navigation.destinationVolumeStart() == 0 ? "overview" : "volume_start");
data.put("volume_direction", navigation.destinationVolumeStart() > navigation.sourceVolumeStart()
? "next" : "previous");
}
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
@@ -232,6 +232,52 @@ class CharacterBookNativeTest {
}
}
@Test void previousVolumeReturnsToOverviewWithoutInventingAnotherBookOpen() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
f.players.put(actor.core.id, actor.player); f.activate(actor);
var old = actor.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("1234567890".repeat(4000)));
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
click(f, actor); drainReads(f); runQueued(f);
var next = PartyBookNativeTest.button(actor, "[Next pages]");
PartyBookNativeTest.click(f, actor, next); drainReads(f); runQueued(f);
assertTrue(actor.books.size() >= 2);
var previous = PartyBookNativeTest.button(actor, "[Previous pages]");
PartyBookNativeTest.click(f, actor, previous); drainReads(f); runQueued(f);
assertEquals(3, actor.books.size());
PartyBookNativeTest.click(f, actor, previous);
assertEquals(3, actor.books.size(), "Replayed nonce cannot reopen a volume");
}
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var opened = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.opened"))
.map(e -> e.get("id").getAsString()).distinct().toList();
assertEquals(1, opened.size());
var navigations = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.navigated"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(first, duplicate) -> first, java.util.LinkedHashMap::new));
assertEquals(2, navigations.size());
var previous = navigations.values().stream().map(e -> e.getAsJsonObject("data"))
.filter(d -> d.get("volume_direction").getAsString().equals("previous")).findFirst().orElseThrow();
assertEquals("30", previous.get("source_volume_start").getAsString());
assertEquals("0", previous.get("destination_volume_start").getAsString());
assertEquals("overview", previous.get("destination_section").getAsString());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("reason")
&& e.getAsJsonObject("data").get("reason").getAsString().equals("stale_or_forged_payload")));
}
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
int expectedNavigations;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
@@ -279,6 +325,16 @@ class CharacterBookNativeTest {
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
var navigation=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("0",navigation.get("source_volume_start").getAsString());
assertEquals("30",navigation.get("destination_volume_start").getAsString());
assertEquals("next",navigation.get("volume_direction").getAsString());
assertTrue(navigation.get("source_navigation_page").getAsInt()>0);
assertEquals("1",navigation.get("destination_page").getAsString());
assertEquals("volume_start",navigation.get("destination_section").getAsString());
assertFalse(navigation.has("ability_id"));assertFalse(navigation.has("passive_id"));
assertTrue(navigation.has("location"));
}
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {