From b107c08dc305225380bce10adcaf6b23e58ad305 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Wed, 7 Oct 2026 00:00:11 -0400 Subject: [PATCH] feat(characters): publish saved-read audit events --- .../dmg/fantasystick/FantasyStickPlugin.java | 6 +- .../book/CharacterBookController.java | 178 ++++-- .../events/AdminActionEventBridge.java | 11 +- .../events/AdminCommandEntryObserver.java | 14 + .../events/SavedCharacterEventBridge.java | 143 +++++ .../session/EquipmentAdminCommand.java | 147 +++-- .../SavedCharacterEventsNativeTest.java | 539 ++++++++++++++++++ 7 files changed, 955 insertions(+), 83 deletions(-) create mode 100644 src/main/java/games/dmg/fantasystick/events/SavedCharacterEventBridge.java create mode 100644 src/nativeTest/java/games/dmg/fantasystick/SavedCharacterEventsNativeTest.java diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index 26d1465..7207d94 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -1138,10 +1138,14 @@ public final class FantasyStickPlugin extends JavaPlugin { var inspectionEvents = new games.dmg.fantasystick.events.ActiveInspectionEventBridge( input -> { inspectionLog.emit(input); }, Clock.systemUTC()); books.attachInspectionEvents(inspectionEvents); + var savedEvents = new games.dmg.fantasystick.events.SavedCharacterEventBridge( + input -> { inspectionLog.emit(input); }, Clock.systemUTC()); + admin.attachSavedEvents(savedEvents); + books.attachSavedEvents(savedEvents); admin.attachAdminAudit(adminActionEvents); eventCapture.excludeOwnedTeleport(adventureQuestCommands::ownsTeleport); eventCapture.excludeOwnedTeleport(dungeonCommands::ownsTeleport); - adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents); + adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents, savedEvents); getServer().getPluginManager().registerEvents(adminActionObserver, this); var replayAdminActions = adminActionEvents; adminActionDrain.whenComplete((ignored, failure) -> { diff --git a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java index 9f4ca51..e8dfcf2 100644 --- a/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java +++ b/src/main/java/games/dmg/fantasystick/book/CharacterBookController.java @@ -42,9 +42,13 @@ public final class CharacterBookController implements Consumer, Au private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents; private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents; private games.dmg.fantasystick.events.ActiveInspectionEventBridge inspectionEvents; + private games.dmg.fantasystick.events.SavedCharacterEventBridge savedEvents; private record AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action, - games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection) { - AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { this(action, null); } + games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection, + games.dmg.fantasystick.events.SavedCharacterEventBridge.Inspection saved) { + AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { this(action, null, null); } + AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action, + games.dmg.fantasystick.events.ActiveInspectionEventBridge.Request inspection) { this(action, inspection, null); } } private final Map adminPending = new ConcurrentHashMap<>(); private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit; @@ -72,7 +76,8 @@ public final class CharacterBookController implements Consumer, Au private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target, games.dmg.fantasystick.session.EquipmentSessions.Active active, int previous, int next) { } - private record SavedView(String nonce, UUID administrator, UUID character, int previous, int next) { } + private record SavedView(String nonce, UUID administrator, org.bukkit.entity.Player holder, + UUID character, int previous, int next) { } private record StatView(String nonce, ControlInput input, java.math.BigInteger revision, CharacterRecord before) { } private record AbilityView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set ids, CharacterRecord before) { } private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set ids, CharacterRecord before) { } @@ -173,6 +178,11 @@ public final class CharacterBookController implements Consumer, Au } private void retireAdminLoad(AdminLoad load, String reason) { + if (load.saved() != null) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, + reason.equals("superseded") ? "superseded" : "cancelled", reason); + return; + } activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", reason); } @@ -291,23 +301,70 @@ public final class CharacterBookController implements Consumer, Au }); } + public void attachSavedEvents(games.dmg.fantasystick.events.SavedCharacterEventBridge events) { + if (closed || savedEvents != null) throw new IllegalStateException("Saved events already attached or books closed"); + savedEvents = Objects.requireNonNull(events); + } + + private void savedInspectionResult(AdminLoad load, + games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason, + CharacterRecord record, int pages, int volumes, int presentedOffset, java.util.List sections) { + boolean active = record != null && sessions.activeCharacter(record.id()).filter(sessions::current).isPresent(); + if (load.saved() != null) savedEvents.inspectionResolved(load.saved(), outcome, reason, + record, active, pages, volumes, presentedOffset, sections); + if (load.saved() != null && adminAudit != null) { + var facts = new java.util.LinkedHashMap(); + facts.put("domain_event_id", load.saved().eventId()); + if (central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED && record != null) + facts.put("subject_id", record.id()); + String centralReason = switch (central) { + case COMPLETED -> "read_completed"; + case FAILED -> "operation_failed"; + case UNCERTAIN -> "operation_uncertain"; + default -> reason.equals("permission_changed") || reason.equals("sender_unsupported") + || reason.equals("invalid_arguments") || reason.equals("unauthorized") ? reason + : outcome.equals("not_found") ? "not_found" : "stale"; + }; + adminAudit.result(load.action(), central, centralReason, facts); + } else adminResult(load.action(), central, + central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED + ? "read_completed" : "owner_refused", record == null ? null : record.id()); + } + + private void savedInspectionResult(AdminLoad load, + games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, String outcome, String reason) { + savedInspectionResult(load, central, outcome, reason, null, 0, 0, 0, java.util.List.of()); + } + + public void refuseSavedInspection(String reason) { + var action = commandAction(); + var request = savedEvents == null ? null : savedEvents.inspection(action, null, 0, "command"); + savedInspectionResult(new AdminLoad(action, null, request), + games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "refused", reason); + } + public void openSavedReadOnly(org.bukkit.entity.Player administrator, UUID character) { Objects.requireNonNull(administrator); Objects.requireNonNull(character); - var action = commandAction(); - if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; } - if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) { - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "permission_changed", null); - if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission is required."); - return; - } - loadSavedReadOnly(administrator, character, 0, action); + loadSavedReadOnly(administrator, character, 0, commandAction(), "command"); } private void loadSavedReadOnly(org.bukkit.entity.Player administrator, UUID character, int offset, - games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { - if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; } + games.dmg.fantasystick.events.AdminActionEventBridge.Action action, String entryPoint) { + var request = savedEvents == null ? null : savedEvents.inspection(action, character, offset, entryPoint); + var load = new AdminLoad(action, null, request); + if (closed) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, + "cancelled", "plugin_shutdown"); return; + } + if (!administrator.hasPermission("fantasystick.admin")) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "refused", "permission_changed"); return; + } UUID administratorId = administrator.getUniqueId(); - var load = new AdminLoad(action); + if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "stale", "sender_lifetime_changed"); return; + } adminViews.remove(administratorId); savedViews.remove(administratorId); supersedeAdminLoad(administratorId, load); @@ -316,28 +373,49 @@ public final class CharacterBookController implements Consumer, Au try { nextTick.accept(() -> { if (closed || !adminPending.remove(administratorId, load)) return; - if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) { - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "permission_changed", null); + if (!administrator.hasPermission("fantasystick.admin")) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "refused", "permission_changed"); if (administrator.isOnline()) administrator.sendMessage("Saved character view refused: administrator permission changed."); return; } + if (!administrator.isOnline() || server.getPlayer(administratorId) != administrator) { + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "stale", "sender_lifetime_changed"); return; + } if (failure != null) { - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null); + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, + "unreadable", "read_failed"); report(new Failure(administratorId, character, failure)); - administrator.sendMessage("Saved character view could not be loaded; no state was changed."); - return; + administrator.sendMessage("Saved character view could not be loaded; no state was changed."); return; } if (record.isEmpty()) { - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "not_found", null); - administrator.sendMessage("Saved character view refused: character not found."); - return; + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "not_found", "not_found"); + administrator.sendMessage("Saved character view refused: character not found."); return; } try { - administrator.openBook(renderSavedReadOnly(record.orElseThrow(), administrator, offset)); - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED, - "read_completed", character); + var loaded = record.orElseThrow(); + var book = renderSavedReadOnly(loaded, administrator, offset); + int count = overviewPages(loaded, administrator, null, true).size(); + int presentedOffset = Math.min(offset, (count - 1) / VOLUME_SIZE * VOLUME_SIZE); + var sections = new java.util.ArrayList(); + if (presentedOffset == 0) sections.add("saved_identity"); + sections.add("overview"); + if (presentedOffset + VOLUME_SIZE >= count) { + sections.add("gear"); + if (!abilities.definitions().isEmpty()) sections.add("active_abilities"); + if (!passives.definitions().isEmpty()) sections.add("passive_abilities"); + if (fishing != null || rareInventory != null) sections.add("inventory"); + sections.add("party"); + } + administrator.openBook(book); + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED, + "opened", "native_open_acknowledged", loaded, ((BookMeta) book.getItemMeta()).getPageCount(), + (count + VOLUME_SIZE - 1) / VOLUME_SIZE, presentedOffset, sections); } catch (RuntimeException error) { - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null); + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, + "failed", "open_failed"); savedViews.remove(administratorId); report(new Failure(administratorId, character, error)); administrator.sendMessage("Saved character view could not be opened; no state was changed."); @@ -345,7 +423,8 @@ public final class CharacterBookController implements Consumer, Au }); } catch (RuntimeException rejected) { if (adminPending.remove(administratorId, load)) - adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null); + savedInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, + "failed", "scheduler_failed"); report(new Failure(administratorId, character, rejected)); } }); @@ -520,7 +599,7 @@ public final class CharacterBookController implements Consumer, Au if (view.next() >= 0) navigation = navigation.append(Component.newline()) .append(pageButton("Next pages", view, 1)); } else if (savedState) { - var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), record.id(), + var view = new SavedView(UUID.randomUUID().toString(), administrator.getUniqueId(), administrator, record.id(), start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1); savedViews.put(administrator.getUniqueId(), view); if (view.previous() >= 0) navigation = navigation.append(Component.newline()) @@ -847,15 +926,42 @@ public final class CharacterBookController implements Consumer, Au if (closed || !SAVED_PAGES.equals(event.getIdentifier()) || !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return; var administrator = connection.getPlayer(); + if (adminAudit == null) navigateSavedInspection(event, administrator, null); + else { + try (var invocation = adminAudit.openSavedInspectionNavigation(administrator)) { + navigateSavedInspection(event, administrator, invocation.action()); + } + } + } + + private void navigateSavedInspection(io.papermc.paper.event.player.PlayerCustomClickEvent event, + org.bukkit.entity.Player administrator, games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { + var refused = new AdminLoad(action, null, savedEvents == null ? null + : savedEvents.inspection(action, null, 0, "book_navigation")); + if (!administrator.hasPermission("fantasystick.admin")) { + savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "refused", "permission_changed"); return; + } + if (!administrator.isOnline() || server.getPlayer(administrator.getUniqueId()) != administrator) { + savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "stale", "sender_lifetime_changed"); return; + } var view = savedViews.get(administrator.getUniqueId()); - if (view == null || event.getTag() == null || !view.administrator().equals(administrator.getUniqueId())) 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 || !savedViews.remove(administrator.getUniqueId(), view)) return; - loadSavedReadOnly(administrator, view.character(), offset, null); + int offset = -1; + if (view != null && view.holder() == administrator && event.getTag() != null + && view.administrator().equals(administrator.getUniqueId())) { + String raw = event.getTag().string(); + if (raw.length() <= 64) { + var match = PAGE_PAYLOAD.matcher(raw); + if (match.matches() && view.nonce().equals(match.group(1))) + offset = match.group(2).equals("0") ? view.previous() : view.next(); + } + } + if (offset < 0 || !savedViews.remove(administrator.getUniqueId(), view)) { + savedInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "refused", "invalid_navigation"); return; + } + loadSavedReadOnly(administrator, view.character(), offset, action, "book_navigation"); } @org.bukkit.event.EventHandler diff --git a/src/main/java/games/dmg/fantasystick/events/AdminActionEventBridge.java b/src/main/java/games/dmg/fantasystick/events/AdminActionEventBridge.java index d222ee4..c2a1be8 100644 --- a/src/main/java/games/dmg/fantasystick/events/AdminActionEventBridge.java +++ b/src/main/java/games/dmg/fantasystick/events/AdminActionEventBridge.java @@ -56,6 +56,7 @@ public final class AdminActionEventBridge implements AutoCloseable { public String route() { return route; } public EventInput.Context context() { return context; } public String actorKind() { return actorKind; } + public Map commandLocation() { return Map.copyOf(location); } public boolean authorized() { return authorization.equals("authorized"); } } @@ -134,7 +135,15 @@ public final class AdminActionEventBridge implements AutoCloseable { /** An actual custom-click inspection attempt, not synthetic command dispatch or new permission authority. */ public Invocation openInspectionNavigation(Player administrator) { - var action = capture(administrator, new String[]{"character", "view"}, "book_navigation"); + return openInspectionNavigation(administrator, "view"); + } + + public Invocation openSavedInspectionNavigation(Player administrator) { + return openInspectionNavigation(administrator, "show"); + } + + private Invocation openInspectionNavigation(Player administrator, String route) { + var action = capture(administrator, new String[]{"character", route}, "book_navigation"); emit(action, "request", null, null, Map.of()); emit(action, "authorization", null, null, Map.of()); return new Invocation(action); diff --git a/src/main/java/games/dmg/fantasystick/events/AdminCommandEntryObserver.java b/src/main/java/games/dmg/fantasystick/events/AdminCommandEntryObserver.java index 6aae58f..7c9cfe8 100644 --- a/src/main/java/games/dmg/fantasystick/events/AdminCommandEntryObserver.java +++ b/src/main/java/games/dmg/fantasystick/events/AdminCommandEntryObserver.java @@ -12,12 +12,19 @@ public final class AdminCommandEntryObserver implements Listener { private final PluginCommand root; private final AdminActionEventBridge audit; private final ActiveInspectionEventBridge inspections; + private final SavedCharacterEventBridge saved; public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit) { this(root, audit, null); } public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit, ActiveInspectionEventBridge inspections) { + this(root, audit, inspections, null); + } + + public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit, + ActiveInspectionEventBridge inspections, SavedCharacterEventBridge saved) { + this.saved = saved; this.inspections = inspections; this.root = Objects.requireNonNull(root); this.audit = Objects.requireNonNull(audit); @@ -38,6 +45,13 @@ public final class AdminCommandEntryObserver implements Listener { if (inspections != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view")) inspections.resolved(inspections.request(action, null, 0, "command"), "refused", allowed ? "native_entry_cancelled" : "unauthorized", null, null, 0, 0); + if (saved != null && args.length >= 2 && args[0].equals("character")) { + var reason = allowed ? "native_entry_cancelled" : "unauthorized"; + if (args[1].equals("find")) saved.searchResolved(saved.search(action, ""), "refused", reason, + java.util.List.of(), 0, false); + if (args[1].equals("show")) saved.inspectionResolved(saved.inspection(action, null, 0, "command"), + "refused", reason, null, false, 0, 0, 0, java.util.List.of()); + } } // Otherwise the executor owns the request; observing here too would duplicate the action. } catch (RuntimeException unavailable) { /* Audit may not change native dispatch or authority. */ } diff --git a/src/main/java/games/dmg/fantasystick/events/SavedCharacterEventBridge.java b/src/main/java/games/dmg/fantasystick/events/SavedCharacterEventBridge.java new file mode 100644 index 0000000..e8835cb --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/events/SavedCharacterEventBridge.java @@ -0,0 +1,143 @@ +package games.dmg.fantasystick.events; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** Bounded saved-read outcomes only; no character records, books, native objects or mutation authority. */ +public final class SavedCharacterEventBridge { + public static final int SEARCH_PART_SIZE = 8; + public record Summary(UUID character, String name, int level, boolean alive, boolean active) { } + public static final class Search { + private final AdminActionEventBridge.Action action; + private final String query; + private final boolean truncated; + private final AtomicBoolean resolved = new AtomicBoolean(); + private Search(AdminActionEventBridge.Action action, String query) { + this.action = action; + var points = query.codePoints().toArray(); + truncated = points.length > 256; + var text = new StringBuilder(); + for (int i = 0; i < Math.min(points.length, 256); i++) + text.appendCodePoint(Character.isISOControl(points[i]) ? ' ' : points[i]); + this.query = text.toString(); + } + public UUID eventId() { return id(action.id(), "search", 0); } + } + public static final class Inspection { + private final AdminActionEventBridge.Action action; + private final UUID character; + private final int offset; + private final String entryPoint; + private final AtomicBoolean resolved = new AtomicBoolean(); + private Inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) { + this.action = action; this.character = character; this.offset = offset; this.entryPoint = entryPoint; + } + public UUID eventId() { return id(action.id(), "inspection", 0); } + } + public Inspection inspection(AdminActionEventBridge.Action action, UUID character, int offset, String entryPoint) { + if (action == null) return null; + if (offset < 0 || !(entryPoint.equals("command") || entryPoint.equals("book_navigation"))) + throw new IllegalArgumentException("Invalid saved inspection metadata"); + return new Inspection(action, action.authorized() ? character : null, offset, entryPoint); + } + public void inspectionResolved(Inspection request, String outcome, String reason, + games.dmg.fantasystick.persistence.CharacterRecord record, boolean active, + int pages, int volumes, int presentedOffset, List sections) { + if (request == null || !request.resolved.compareAndSet(false, true)) return; + boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized"); + var values = new LinkedHashMap(); + values.put("route", "character.show"); + values.put("actor_kind", request.action.actorKind()); + values.put("authorization", denied ? "denied" : "authorized"); + values.put("entry_point", request.entryPoint); + values.put("requested_offset", request.offset); + values.put("requested_volume", request.offset / 30 + 1); + values.put("outcome", outcome); values.put("reason", reason); + location(values, request.action); + if (!denied && !reason.equals("sender_unsupported") && request.character != null) + values.put("requested_character_id", request.character); + if (!denied && outcome.equals("opened") && record != null && record.id().equals(request.character)) { + values.put("subject_character_id", record.id()); values.put("subject_character_name", record.name()); + values.put("saved_revision", record.revision().toString()); + values.put("sheet_generation", record.sheetGeneration().toString()); + values.put("alive", !record.dead()); values.put("active", active); + values.put("presented_pages", pages); values.put("presented_volumes", volumes); + values.put("presented_offset", presentedOffset); values.put("exposed_sections", List.copyOf(sections)); + } + emit(new EventInput(request.eventId(), request.action.id(), clock.instant(), + "character.saved_inspection_resolved", request.action.context(), values)); + } + private final Consumer sink; + private final Clock clock; + public SavedCharacterEventBridge(Consumer sink, Clock clock) { + this.sink = Objects.requireNonNull(sink); + this.clock = Objects.requireNonNull(clock); + } + public Search search(AdminActionEventBridge.Action action, String query) { + return action == null ? null : new Search(action, Objects.requireNonNull(query)); + } + public void searchResolved(Search request, String outcome, String reason, + List results, int unreadable, boolean complete) { + if (request == null || !request.resolved.compareAndSet(false, true)) return; + boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized"); + boolean exposed = !denied && (outcome.equals("completed") || outcome.equals("incomplete") || outcome.equals("no_match")); + var visible = exposed ? List.copyOf(results) : List.of(); + int pages = (visible.size() + SEARCH_PART_SIZE - 1) / SEARCH_PART_SIZE; + int parts = Math.max(1, pages); + var time = clock.instant(); + for (int part = 0; part < parts; part++) { + var values = new LinkedHashMap(); + values.put("route", "character.find"); + location(values, request.action); + values.put("actor_kind", request.action.actorKind()); + values.put("authorization", denied ? "denied" : "authorized"); + values.put("outcome", outcome); + values.put("reason", reason); + values.put("resolution_id", request.eventId()); + values.put("part_index", part + 1); + values.put("part_count", parts); + if (!denied && !reason.equals("sender_unsupported")) { + values.put("query", request.query); + values.put("query_truncated", request.truncated); + } + if (exposed) { + values.put("match_count", visible.size()); + values.put("page_count", pages); + values.put("unreadable_count", unreadable); + values.put("catalog_complete", complete); + values.put("results", visible.subList(part * SEARCH_PART_SIZE, + Math.min(visible.size(), (part + 1) * SEARCH_PART_SIZE)).stream().map(summary -> { + var value = new LinkedHashMap(); + value.put("character_id", summary.character()); value.put("name", summary.name()); + value.put("level", Integer.toString(summary.level())); + value.put("alive", summary.alive()); value.put("active", summary.active()); + return value; + }).toList()); + } + emit(new EventInput(id(request.action.id(), "search", part), request.action.id(), time, + "character.saved_search_resolved", request.action.context(), values)); + } + } + private static void location(LinkedHashMap values, AdminActionEventBridge.Action action) { + if (action.context() != null) { + if (action.context().location() == null) values.put("location_status", "unavailable"); + } else if (!action.commandLocation().isEmpty()) { + values.put("location", action.commandLocation()); + values.put("location_status", "captured"); + } else values.put("location_status", action.actorKind().equals("command_block") ? "unavailable" : "not_applicable"); + } + private void emit(EventInput input) { + try { sink.accept(input); } + catch (RuntimeException unavailable) { /* Logging cannot change or replay a read. */ } + } + private static UUID id(UUID action, String kind, int part) { + return UUID.nameUUIDFromBytes(("fantasystick:saved-" + kind + ":" + action + ":" + part) + .getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java b/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java index 6ebea08..ae7c9f2 100644 --- a/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java +++ b/src/main/java/games/dmg/fantasystick/session/EquipmentAdminCommand.java @@ -21,7 +21,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { private final Consumer nextTick; private final Consumer diagnostics; private final java.util.LinkedHashMap savedCharacterCatalog = new java.util.LinkedHashMap<>(); - private final java.util.concurrent.ConcurrentHashMap savedSearches = new java.util.concurrent.ConcurrentHashMap<>(); + private record SavedSearch(games.dmg.fantasystick.events.AdminActionEventBridge.Action action, + games.dmg.fantasystick.events.SavedCharacterEventBridge.Search event) { } + private final java.util.concurrent.ConcurrentHashMap savedSearches = new java.util.concurrent.ConcurrentHashMap<>(); + private games.dmg.fantasystick.events.SavedCharacterEventBridge savedEvents; + public void attachSavedEvents(games.dmg.fantasystick.events.SavedCharacterEventBridge events) { + if (closed || savedEvents != null) throw new IllegalStateException("Saved events already attached or command closed"); + savedEvents = Objects.requireNonNull(events); + } + private void searchResult(SavedSearch request, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, + String outcome, String reason, + java.util.List matches, + int unreadable, boolean complete) { + if (request.event() != null) savedEvents.searchResolved(request.event(), outcome, reason, matches, unreadable, complete); + if (adminAudit != null && request.action() != null) adminAudit.result(request.action(), central, + central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED ? "read_completed" + : central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED ? "operation_failed" + : central == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN ? "operation_uncertain" + : reason.equals("permission_changed") ? reason : "stale", + request.event() == null ? java.util.Map.of() : java.util.Map.of("domain_event_id", request.event().eventId())); + } + private void searchResult(SavedSearch request, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome central, + String outcome, String reason) { + searchResult(request, central, outcome, reason, java.util.List.of(), 0, false); + } + private void refuseSavedSearch(String reason) { + var action = adminAudit == null ? null : adminAudit.current(); + if (savedEvents != null) savedEvents.searchResolved(savedEvents.search(action, ""), "refused", reason, + java.util.List.of(), 0, false); + } private static final int SAVED_CATALOG_COMPLETION_LIMIT = 256; private static final int SAVED_SEARCH_PAGE_SIZE = 8; private volatile boolean closed; @@ -562,6 +590,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { if (!sender.hasPermission(PERMISSION)) { if (characterBooks != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view")) characterBooks.refuseActiveInspection("unauthorized"); + if (args.length >= 2 && args[0].equals("character")) { + if (args[1].equals("find")) refuseSavedSearch("unauthorized"); + if (characterBooks != null && args[1].equals("show")) characterBooks.refuseSavedInspection("unauthorized"); + } if (sessionRecoveryEvents != null && args.length > 0 && (args[0].equals("inspect") || args[0].equals("recover"))) { var action = sessionRecoveryEvents.capture(sender, null); @@ -790,64 +822,86 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { private void characterFind(CommandSender sender, String[] args) { if (args.length < 3 || String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)).isBlank()) { + refuseSavedSearch("invalid_arguments"); adminInvalid(); sender.sendMessage("Usage: /fstickadmin character find "); return; } var findAction = adminAudit == null ? null : adminAudit.current(); String query = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)); String folded = query.toLowerCase(java.util.Locale.ROOT); - Object ticket = new Object(); + var ticket = new SavedSearch(findAction, savedEvents == null ? null : savedEvents.search(findAction, query)); Object requestKey = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : EquipmentAdminCommand.class; - savedSearches.put(requestKey, ticket); + var previous = savedSearches.put(requestKey, ticket); + if (previous != null) searchResult(previous, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, + "superseded", "superseded"); characters.catalog().whenComplete((catalog, failure) -> { if (closed || savedSearches.get(requestKey) != ticket) return; try { nextTick.accept(() -> { if (closed || !savedSearches.remove(requestKey, ticket)) return; - if (!sender.hasPermission(PERMISSION) - || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) { - adminResult(findAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, - "permission_changed",null,null); return; - } - if (failure != null) { - adminSaved(findAction,failure,false,null,null); - sender.sendMessage("Saved character search failed; no records were changed."); - return; - } - adminResult(findAction,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED, - "read_completed",null,null); - rememberSavedCharacters(catalog.records()); - var matches = catalog.records().stream() - .filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded)) - .sorted(java.util.Comparator.comparing( - (games.dmg.fantasystick.persistence.CharacterRecord record) -> - record.name().toLowerCase(java.util.Locale.ROOT)) - .thenComparing(record -> record.id().toString())) - .toList(); - if (matches.isEmpty()) { - sender.sendMessage(catalog.complete() - ? "No saved characters match '" + query + "'." - : "No readable saved characters match '" + query - + "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined."); - return; - } - int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE; - for (int page = 0; page < pages; page++) { - sender.sendMessage("Saved character results " + (page + 1) + "/" + pages - + (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)")); - int from = page * SAVED_SEARCH_PAGE_SIZE; - for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) { - boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent(); - var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id() - + " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive") - + " — " + (active ? "active" : "inactive")) - .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( - "/fstickadmin character show " + record.id())); - sender.sendMessage(result); + try { + if (!sender.hasPermission(PERMISSION)) { + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "refused", "permission_changed"); return; } + if (sender instanceof org.bukkit.entity.Player player + && (!player.isOnline() || server.getPlayer(player.getUniqueId()) != player)) { + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, + "stale", "sender_lifetime_changed"); return; + } + if (failure != null) { + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, + "failed", "read_failed"); + sender.sendMessage("Saved character search failed; no records were changed."); + return; + } + rememberSavedCharacters(catalog.records()); + var matches = catalog.records().stream() + .filter(record -> record.name().toLowerCase(java.util.Locale.ROOT).contains(folded)) + .sorted(java.util.Comparator.comparing( + (games.dmg.fantasystick.persistence.CharacterRecord record) -> + record.name().toLowerCase(java.util.Locale.ROOT)) + .thenComparing(record -> record.id().toString())) + .toList(); + if (matches.isEmpty()) { + sender.sendMessage(catalog.complete() + ? "No saved characters match '" + query + "'." + : "No readable saved characters match '" + query + + "'; results are incomplete because " + catalog.unreadable() + " record(s) could not be examined."); + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED, + catalog.complete() ? "no_match" : "incomplete", "catalog_read_acknowledged", + java.util.List.of(), catalog.unreadable(), catalog.complete()); + return; + } + var summaries = new java.util.ArrayList(); + int pages = (matches.size() + SAVED_SEARCH_PAGE_SIZE - 1) / SAVED_SEARCH_PAGE_SIZE; + for (int page = 0; page < pages; page++) { + sender.sendMessage("Saved character results " + (page + 1) + "/" + pages + + (catalog.complete() ? "" : " (incomplete: " + catalog.unreadable() + " unreadable)")); + int from = page * SAVED_SEARCH_PAGE_SIZE; + for (var record : matches.subList(from, Math.min(matches.size(), from + SAVED_SEARCH_PAGE_SIZE))) { + boolean active = sessions.activeCharacter(record.id()).filter(sessions::current).isPresent(); + summaries.add(new games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary( + record.id(), record.name(), record.level(), !record.dead(), active)); + var result = net.kyori.adventure.text.Component.text(record.name() + " — " + record.id() + + " — level " + record.level() + " — " + (record.dead() ? "dead" : "alive") + + " — " + (active ? "active" : "inactive")) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand( + "/fstickadmin character show " + record.id())); + sender.sendMessage(result); + } + } + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED, + catalog.complete() ? "completed" : "incomplete", "catalog_read_acknowledged", + summaries, catalog.unreadable(), catalog.complete()); + } catch (RuntimeException responseFailure) { + searchResult(ticket, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, + "failed", "response_failed"); + report(null, "character-find-response", responseFailure); } }); } catch (RuntimeException rejected) { - savedSearches.remove(requestKey, ticket); + if (savedSearches.remove(requestKey, ticket)) searchResult(ticket, + games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "failed", "scheduler_failed"); report(null, "character-find-response", rejected); } }); @@ -878,6 +932,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { private void characterShow(CommandSender sender, String[] args) { if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)) { + if (characterBooks != null) characterBooks.refuseSavedInspection("sender_unsupported"); adminRefused(); sender.sendMessage("Saved character books require an in-game administrator."); return; } @@ -887,6 +942,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { character = UUID.fromString(args[2]); if (!character.toString().equals(args[2])) throw new IllegalArgumentException("Noncanonical UUID"); } catch (IllegalArgumentException invalid) { + characterBooks.refuseSavedInspection("invalid_arguments"); adminInvalid(); sender.sendMessage("Usage: /fstickadmin character show "); return; } @@ -2960,5 +3016,6 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable { .distinct().sorted(java.util.Comparator.comparing((String value) -> value.toLowerCase(java.util.Locale.ROOT)) .thenComparing(java.util.Comparator.naturalOrder())).toList(); } - @Override public void close() { closed = true; savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); if(characterCoins!=null)characterCoins.close(); } + @Override public void close() { closed = true; savedSearches.values().forEach(request -> searchResult(request, + games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "cancelled", "plugin_shutdown")); savedSearches.clear(); if (characterIssuer != null) characterIssuer.close(); if(adminRevival!=null)adminRevival.close(); if(characterRecovery!=null)characterRecovery.close(); if(experienceGrant!=null)experienceGrant.close(); if(characterCoins!=null)characterCoins.close(); } } diff --git a/src/nativeTest/java/games/dmg/fantasystick/SavedCharacterEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/SavedCharacterEventsNativeTest.java new file mode 100644 index 0000000..62cecd2 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/SavedCharacterEventsNativeTest.java @@ -0,0 +1,539 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import games.dmg.fantasystick.persistence.CharacterStorage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Registered saved-read commands -> authoritative reads -> bounded, correlated JSONL. */ +class SavedCharacterEventsNativeTest { + @TempDir Path data; + @BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); } + + @ParameterizedTest @ValueSource(strings = {"player", "console", "command_block"}) + void registeredSearchExportsOnlyExposedUnicodeSummaryWithoutMutation(String kind) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, kind, true); + var record = storage(f).renameCharacter(fixture.actor.core.record.id(), "Éowyn 雪 — Hero") + .get(5, TimeUnit.SECONDS).orElseThrow(); + var path = data.resolve("characters").resolve(record.id() + ".yml"); + var saved = Files.readAllBytes(path); + var before = fixture.actor.core.snapshot(); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character find ÉOWYN 雪")); + reads(f); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var rows = rows("character.saved_search_resolved"); + assertEquals(1, rows.size(), "an authoritative saved search must produce its feature-owned resolution"); + var values = rows.getFirst().getAsJsonObject("data"); + assertEquals("completed", values.get("outcome").getAsString()); + assertEquals("ÉOWYN 雪", values.get("query").getAsString()); + assertEquals(kind, values.get("actor_kind").getAsString()); + if (kind.equals("command_block")) { + var place = values.getAsJsonObject("location"); + assertNotNull(place, "physical command-block origin must be captured, not mislabeled as absent"); + assertEquals(fixture.actor.world.getUID().toString(), place.get("world_id").getAsString()); + assertEquals(12, place.get("x").getAsDouble()); + assertEquals("captured", values.get("location_status").getAsString()); + } else if (kind.equals("console")) assertEquals("not_applicable", values.get("location_status").getAsString()); + else assertTrue(values.has("location")); + assertEquals(1, values.get("match_count").getAsInt()); + assertEquals(1, values.get("page_count").getAsInt()); + assertTrue(values.get("catalog_complete").getAsBoolean()); + assertEquals(0, values.get("unreadable_count").getAsInt()); + var match = values.getAsJsonArray("results").get(0).getAsJsonObject(); + assertEquals(record.id().toString(), match.get("character_id").getAsString()); + assertEquals(record.name(), match.get("name").getAsString()); + assertEquals(Integer.toString(record.level()), match.get("level").getAsString()); + assertTrue(match.get("alive").getAsBoolean()); + assertFalse(match.get("active").getAsBoolean()); + assertEquals(5, match.size()); + assertFalse(values.has("stats") || values.has("record") || values.has("inventory")); + var central = AdminActionEntryNativeTest.rows(data); + var terminal = central.stream().filter(row -> row.getAsJsonObject("data").has("outcome") + && !row.getAsJsonObject("data").get("outcome").getAsString().equals("pending")).toList(); + assertEquals(1, terminal.size()); + assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id")); + assertEquals(rows.getFirst().get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id")); + assertArrayEquals(saved, Files.readAllBytes(path)); + assertArrayEquals(before, fixture.actor.core.snapshot()); + assertTrue(fixture.actor.books.isEmpty()); + entry.map.tabComplete(entry.sender, "fstickadmin character find É"); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(1, rows("character.saved_search_resolved").size()); + } + } + + @ParameterizedTest @ValueSource(strings = {"inactive", "dead", "active"}) + void exactUuidInspectionExportsOnlySavedIdentityAndActualOpenedView(String state) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + f.players.put(target.core.id, target.player); + if (state.equals("active")) { f.activate(target); target.core.health = 3; } + if (state.equals("dead")) storage(f).recordDeath(target.core.record.id()).get(5, TimeUnit.SECONDS); + var record = storage(f).inspect(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); + var path = data.resolve("characters").resolve(record.id() + ".yml"); + var saved = Files.readAllBytes(path); + var adminBefore = fixture.actor.core.snapshot(); + var targetBefore = target.core.snapshot(); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id())); + assertTrue(fixture.actor.books.isEmpty()); + reads(f); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var rows = rows("character.saved_inspection_resolved"); + assertEquals(1, rows.size(), "exact-UUID saved-state read must emit its own inspection outcome"); + var values = rows.getFirst().getAsJsonObject("data"); + assertEquals("opened", values.get("outcome").getAsString()); + assertEquals("command", values.get("entry_point").getAsString()); + assertEquals(record.id().toString(), values.get("subject_character_id").getAsString()); + assertEquals(record.name(), values.get("subject_character_name").getAsString()); + assertEquals(record.revision().toString(), values.get("saved_revision").getAsString()); + assertEquals(record.sheetGeneration().toString(), values.get("sheet_generation").getAsString()); + assertEquals(!record.dead(), values.get("alive").getAsBoolean()); + assertEquals(state.equals("active"), values.get("active").getAsBoolean()); + assertEquals(0, values.get("requested_offset").getAsInt()); + assertEquals(1, values.get("requested_volume").getAsInt()); + assertEquals(((org.bukkit.inventory.meta.BookMeta) fixture.actor.books.getLast().getItemMeta()).getPageCount(), + values.get("presented_pages").getAsInt()); + assertTrue(values.getAsJsonArray("exposed_sections").toString().contains("saved_identity")); + assertFalse(values.has("stats") || values.has("record") || values.has("inventory") || values.has("pages")); + var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").has("outcome") + && row.getAsJsonObject("data").get("outcome").getAsString().equals("completed")).toList(); + assertEquals(1, terminal.size()); + assertEquals(rows.getFirst().get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id")); + assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id")); + assertArrayEquals(saved, Files.readAllBytes(path)); + assertArrayEquals(adminBefore, fixture.actor.core.snapshot()); + assertArrayEquals(targetBefore, target.core.snapshot()); + assertTrue(target.books.isEmpty()); + var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(fixture.actor.books.getLast()) + .get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); + String text = content.pages().stream().map(page -> page.raw().getString()) + .collect(java.util.stream.Collectors.joining()).replace("\n", ""); + assertTrue(text.contains("Saved state")); + assertTrue(text.contains("Saved health: " + record.health().stripTrailingZeros().toPlainString())); + } + } + + @ParameterizedTest @ValueSource(strings = {"find", "show"}) + void nativePermissionGateProducesSafeFeatureRefusalWithoutProtectedArguments(String route) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", false); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + storage(fixture.f).renameCharacter(target.core.record.id(), "Protected Name").get(5, TimeUnit.SECONDS); + assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character " + route + " " + + (route.equals("find") ? "Protected Name" : target.core.record.id()))); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f); + var events = rows("character.saved_" + (route.equals("find") ? "search" : "inspection") + "_resolved"); + assertEquals(1, events.size()); + var values = events.getFirst().getAsJsonObject("data"); + assertEquals("refused", values.get("outcome").getAsString()); + assertEquals("denied", values.get("authorization").getAsString()); + assertFalse(events.toString().contains(target.core.record.id().toString())); + assertFalse(events.toString().contains("Protected Name")); + assertFalse(values.has("query") || values.has("results") || values.has("subject_character_id")); + assertTrue(fixture.actor.books.isEmpty()); + assertEquals(3, AdminActionEntryNativeTest.rows(data).size()); + } + } + + @Test void nonceNavigationRereadsSavedRevisionAndRefusesReplayWithoutRecordDump() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + var record = storage(f).inspect(fixture.actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); + var stats = new java.util.EnumMap<>(record.stats()); + String privateDigits = "1234567890".repeat(4_000); + stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(privateDigits)); + var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data); + repo.replace(withStats(record, stats), record.revision()); + entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id()); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(1, rows("character.saved_inspection_resolved").size()); + var link = next(fixture.actor); + assertNotNull(link); + var renamed = storage(f).renameCharacter(record.id(), "Current Unicode 雪").get(5, TimeUnit.SECONDS).orElseThrow(); + var path = data.resolve("characters").resolve(record.id() + ".yml"); + var saved = Files.readAllBytes(path); + var before = fixture.actor.core.snapshot(); + PartyBookNativeTest.click(f, fixture.actor, link); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_inspection_resolved"); + assertEquals(2, events.size(), "each actual nonce-bound reread owns a fresh correlated outcome"); + var values = events.getLast().getAsJsonObject("data"); + assertEquals("opened", values.get("outcome").getAsString()); + assertEquals("book_navigation", values.get("entry_point").getAsString()); + assertEquals(30, values.get("requested_offset").getAsInt()); + assertEquals(2, values.get("requested_volume").getAsInt()); + assertEquals(30, values.get("presented_offset").getAsInt()); + assertTrue(values.get("presented_volumes").getAsInt() > 1); + assertEquals(renamed.revision().toString(), values.get("saved_revision").getAsString()); + assertEquals(renamed.name(), values.get("subject_character_name").getAsString()); + assertEquals(List.of("overview"), values.getAsJsonArray("exposed_sections").asList().stream() + .map(com.google.gson.JsonElement::getAsString).toList()); + PartyBookNativeTest.click(f, fixture.actor, link); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + events = rows("character.saved_inspection_resolved"); + assertEquals(3, events.size()); + assertEquals(2, fixture.actor.books.size()); + var refusal = events.getLast().getAsJsonObject("data"); + assertEquals("refused", refusal.get("outcome").getAsString()); + assertFalse(refusal.has("subject_character_id") || refusal.has("saved_revision")); + assertFalse(events.toString().contains(privateDigits.substring(0, 100))); + assertEquals(3, events.stream().map(row -> row.get("id")).distinct().count()); + assertEquals(3, events.stream().map(row -> row.getAsJsonObject("data").get("action_id")).distinct().count()); + assertArrayEquals(saved, Files.readAllBytes(path)); + assertArrayEquals(before, fixture.actor.core.snapshot()); + for (var row : events) assertCorrelated(row); + } + } + + @Test void multipartSearchPreservesEveryDuplicateSummaryAndCatalogIncompleteness() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + var expected = new java.util.ArrayList(); + for (int n = 0; n < 18; n++) { + var target = new FantasyStickLifecycleNativeTest.Actor(data); + var record = storage(f).renameCharacter(target.core.record.id(), "雪 Duplicate").get(5, TimeUnit.SECONDS).orElseThrow(); + expected.add(record.id()); + if (n == 0) { + target.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(record)); + f.players.put(target.core.id, target.player); f.activate(target); + } + if (n == 1) storage(f).recordDeath(record.id()).get(5, TimeUnit.SECONDS); + } + UUID corrupt = UUID.randomUUID(); + Files.writeString(data.resolve("characters").resolve(corrupt + ".yml"), "schema-version: malformed\n"); + entry.map.dispatch(entry.sender, "fstickadmin character find 雪 Duplicate"); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_search_resolved"); + assertEquals(3, events.size()); + var exposed = new java.util.ArrayList(); + for (int n = 0; n < events.size(); n++) { + var v = events.get(n).getAsJsonObject("data"); + assertEquals("incomplete", v.get("outcome").getAsString()); + assertEquals(18, v.get("match_count").getAsInt()); + assertEquals(3, v.get("page_count").getAsInt()); + assertEquals(n + 1, v.get("part_index").getAsInt()); + assertEquals(3, v.get("part_count").getAsInt()); + assertEquals(1, v.get("unreadable_count").getAsInt()); + assertFalse(v.get("catalog_complete").getAsBoolean()); + assertEquals(events.getFirst().get("id"), v.get("resolution_id")); + assertTrue(v.getAsJsonArray("results").size() <= 8); + for (var item : v.getAsJsonArray("results")) { + var summary = item.getAsJsonObject(); + assertEquals(5, summary.size()); + exposed.add(summary.get("character_id").getAsString()); + } + assertCorrelated(events.get(n)); + } + assertEquals(expected.stream().map(UUID::toString).sorted().toList(), exposed); + assertEquals(3, events.stream().map(row -> row.get("id")).distinct().count()); + assertEquals(1, events.stream().map(row -> row.getAsJsonObject("data").get("action_id")).distinct().count()); + assertFalse(events.toString().contains(corrupt.toString())); + assertEquals(1, events.stream().flatMap(row -> row.getAsJsonObject("data").getAsJsonArray("results").asList().stream()) + .filter(item -> item.getAsJsonObject().get("active").getAsBoolean()).count()); + assertEquals(1, events.stream().flatMap(row -> row.getAsJsonObject("data").getAsJsonArray("results").asList().stream()) + .filter(item -> !item.getAsJsonObject().get("alive").getAsBoolean()).count()); + } + } + + @ParameterizedTest @ValueSource(strings = {"find_permission", "find_disconnect", "find_replacement", "find_superseded", "find_shutdown", + "show_permission", "show_disconnect", "show_replacement", "show_superseded", "show_shutdown"}) + void pendingRequestsResolveOnceWithoutStaleSavedMetadata(String variant) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + var record = storage(f).renameCharacter(target.core.record.id(), "Protected Pending 雪").get(5, TimeUnit.SECONDS).orElseThrow(); + boolean search = variant.startsWith("find_"); + String command = "fstickadmin character " + (search ? "find Protected Pending" : "show " + record.id()); + entry.map.dispatch(entry.sender, command); + storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS); + String race = variant.substring(variant.indexOf('_') + 1); + switch (race) { + case "permission" -> fixture.actor.allowed = false; + case "disconnect" -> fixture.actor.core.online = false; + case "replacement" -> f.players.put(fixture.actor.core.id, new FantasyStickLifecycleNativeTest.Actor(data).player); + case "superseded" -> entry.map.dispatch(entry.sender, command); + case "shutdown" -> f.plugin.setEnabled(false); + default -> fail(variant); + } + reads(f); + if (race.equals("shutdown")) WitchAuditTestSupport.drain(f, "adminActionDrain"); + else FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_" + (search ? "search" : "inspection") + "_resolved"); + assertEquals(race.equals("superseded") ? 2 : 1, events.size()); + assertEquals(race.equals("superseded") && !search ? 1 : 0, fixture.actor.books.size()); + for (var row : events) { + var v = row.getAsJsonObject("data"); + if (List.of("opened", "completed").contains(v.get("outcome").getAsString())) continue; + assertFalse(v.has("results") || v.has("subject_character_name") || v.has("saved_revision")); + assertFalse(row.toString().contains(record.name())); + if (race.equals("permission")) { + assertEquals("denied", v.get("authorization").getAsString()); + assertFalse(v.has("requested_character_id") || v.has("query")); + } + } + assertEquals(events.size(), events.stream().map(row -> row.get("id")).distinct().count()); + for (var row : events) assertCorrelated(row); + } + } + + @ParameterizedTest @ValueSource(strings = {"missing", "corrupt", "open_failed", "console", "command_block", "malformed"}) + void savedInspectionFailuresRemainSafeAndValidRetryReadsCurrentState(String failure) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, + failure.equals("console") || failure.equals("command_block") ? failure : "player", true); + var target = new FantasyStickLifecycleNativeTest.Actor(data); + var path = data.resolve("characters").resolve(target.core.record.id() + ".yml"); + var valid = Files.readAllBytes(path); + if (failure.equals("missing")) Files.delete(path); + if (failure.equals("corrupt")) Files.writeString(path, "schema-version: malformed\n"); + if (failure.equals("open_failed")) org.mockito.Mockito.doThrow(new IllegalStateException("controlled native open failure")) + .when(fixture.actor.player).openBook(org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class)); + entry.map.dispatch(entry.sender, "fstickadmin character show " + target.core.record.id() + + (failure.equals("malformed") ? " SECRET=opaque" : "")); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_inspection_resolved"); + assertEquals(1, events.size()); + var v = events.getFirst().getAsJsonObject("data"); + assertEquals(switch (failure) { + case "missing" -> "not_found"; case "corrupt" -> "unreadable"; case "open_failed" -> "failed"; + default -> "refused"; + }, v.get("outcome").getAsString()); + assertFalse(v.has("subject_character_id") || v.has("subject_character_name") || v.has("saved_revision") || v.has("exposed_sections")); + assertFalse(events.toString().contains("opaque")); + assertTrue(fixture.actor.books.isEmpty()); + if (failure.equals("console") || failure.equals("command_block") || failure.equals("malformed")) + assertFalse(events.toString().contains(target.core.record.id().toString())); + assertCorrelated(events.getFirst()); + if (failure.equals("missing") || failure.equals("corrupt")) { + Files.write(path, valid); + entry.map.dispatch(entry.sender, "fstickadmin character show " + target.core.record.id()); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals("opened", rows("character.saved_inspection_resolved").getLast().getAsJsonObject("data").get("outcome").getAsString()); + assertArrayEquals(valid, Files.readAllBytes(path)); + assertEquals(2, rows("character.saved_inspection_resolved").stream().map(row -> row.get("id")).distinct().count()); + } + } + } + + @ParameterizedTest @ValueSource(strings = {"no_match", "incomplete", "read_failed"}) + void searchNoMatchAndUnreadableStateAreTruthfulAndRetryDoesNotReplay(String failure) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "console", true); + var directory = data.resolve("characters"); + var parked = data.resolve("characters-held"); + if (failure.equals("incomplete")) Files.writeString(directory.resolve(UUID.randomUUID() + ".yml"), "schema-version: malformed\n"); + if (failure.equals("read_failed")) { Files.move(directory, parked); Files.writeString(directory, "not a directory"); } + try { + entry.map.dispatch(entry.sender, "fstickadmin character find Definitely no match"); + if (failure.equals("read_failed")) { + assertTrue(storage(f).catalog().handle((v, error) -> error != null).get(5, TimeUnit.SECONDS)); + Runnable task; while ((task = f.tasks.poll()) != null) task.run(); + } else reads(f); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_search_resolved"); + assertEquals(1, events.size()); + var v = events.getFirst().getAsJsonObject("data"); + assertEquals(failure.equals("read_failed") ? "failed" : failure, v.get("outcome").getAsString()); + if (failure.equals("read_failed")) assertFalse(v.has("results") || v.has("catalog_complete")); + else { + assertEquals(0, v.get("match_count").getAsInt()); + assertEquals(0, v.get("page_count").getAsInt()); + assertEquals(failure.equals("incomplete") ? 1 : 0, v.get("unreadable_count").getAsInt()); + assertEquals(failure.equals("no_match"), v.get("catalog_complete").getAsBoolean()); + } + assertCorrelated(events.getFirst()); + } finally { + if (failure.equals("read_failed")) { Files.delete(directory); Files.move(parked, directory); } + } + entry.map.dispatch(entry.sender, "fstickadmin character find Definitely no match"); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(2, rows("character.saved_search_resolved").size()); + assertEquals(2, rows("character.saved_search_resolved").stream().map(row -> row.get("id")).distinct().count()); + } + } + + @ParameterizedTest @ValueSource(strings = {"forged", "permission", "reconnect", "missing", "open_failed"}) + void navigationRejectsForeignOrUnavailableAuthorityWithoutExposingSavedState(String failure) throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + var record = storage(f).inspect(fixture.actor.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow(); + var stats = new java.util.EnumMap<>(record.stats()); + stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger("1234567890".repeat(4_000))); + new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(withStats(record, stats), record.revision()); + entry.map.dispatch(entry.sender, "fstickadmin character show " + record.id()); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var link = next(fixture.actor); assertNotNull(link); + var holder = fixture.actor; + switch (failure) { + case "permission" -> fixture.actor.allowed = false; + case "reconnect" -> { + holder = new FantasyStickLifecycleNativeTest.Actor(data); + org.mockito.Mockito.doReturn(fixture.actor.core.id).when(holder.player).getUniqueId(); + f.players.put(fixture.actor.core.id, holder.player); + } + case "missing" -> Files.delete(data.resolve("characters").resolve(record.id() + ".yml")); + case "open_failed" -> org.mockito.Mockito.doThrow(new IllegalStateException("controlled navigation open failure")) + .when(fixture.actor.player).openBook(org.mockito.ArgumentMatchers.any(org.bukkit.inventory.ItemStack.class)); + default -> { } + } + if (failure.equals("forged")) { + var connection = f.own(org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class)); + org.mockito.Mockito.when(connection.getPlayer()).thenReturn(holder.player); + f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent( + net.kyori.adventure.key.Key.key("fantasystick", "saved_character_pages"), connection, + net.minecraft.nbt.StringTag.valueOf("\"" + UUID.randomUUID() + ":1\""))); + } else PartyBookNativeTest.click(f, holder, link); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_inspection_resolved"); + assertEquals(2, events.size()); + assertEquals(1, fixture.actor.books.size()); + if (holder != fixture.actor) assertTrue(holder.books.isEmpty(), "an old account nonce must not authorize a replacement native holder"); + var v = events.getLast().getAsJsonObject("data"); + assertEquals(failure.equals("missing") ? "not_found" : failure.equals("open_failed") ? "failed" : "refused", + v.get("outcome").getAsString()); + assertEquals("book_navigation", v.get("entry_point").getAsString()); + assertFalse(v.has("subject_character_id") || v.has("saved_revision") || v.has("exposed_sections")); + if (failure.equals("permission") || failure.equals("forged") || failure.equals("reconnect")) + assertFalse(v.has("requested_character_id")); + assertCorrelated(events.getLast()); + } + } + + @Test void failedNativeSearchPresentationResolvesWithoutEscapingOrInventingExposedResults() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + storage(f).renameCharacter(fixture.actor.core.record.id(), "Controlled Search").get(5, TimeUnit.SECONDS); + org.mockito.Mockito.doThrow(new IllegalStateException("controlled native search response failure")) + .when(fixture.actor.player).sendMessage(org.mockito.ArgumentMatchers.anyString()); + entry.map.dispatch(entry.sender, "fstickadmin character find Controlled Search"); + assertDoesNotThrow(() -> reads(f), "an asynchronous native response failure must resolve, not escape the owner"); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_search_resolved"); + assertEquals(1, events.size()); + var v = events.getFirst().getAsJsonObject("data"); + assertEquals("failed", v.get("outcome").getAsString()); + assertEquals("response_failed", v.get("reason").getAsString()); + assertFalse(v.has("results") || v.has("catalog_complete")); + assertCorrelated(events.getFirst()); + } + } + + @Test void restartKeepsSavedOutcomesUnchangedAndNewExplicitReadsUseNewIdentities() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + UUID id = fixture.actor.core.record.id(); + var path = data.resolve("characters").resolve(id + ".yml"); + var before = Files.readAllBytes(path); + entry.map.dispatch(entry.sender, "fstickadmin character show " + id); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var original = rows("character.saved_inspection_resolved").getFirst().deepCopy(); + f.plugin.setEnabled(false); WitchAuditTestSupport.drain(f, "adminActionDrain"); + f.plugin.setEnabled(true); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(List.of(original), rows("character.saved_inspection_resolved"), "startup must not replay a user read or re-render a book"); + entry.map.dispatch(entry.sender, "fstickadmin character show " + id); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = rows("character.saved_inspection_resolved"); + assertEquals(2, events.size()); + assertEquals(original, events.stream().filter(row -> row.get("id").equals(original.get("id"))).findFirst().orElseThrow()); + var fresh = events.stream().filter(row -> !row.get("id").equals(original.get("id"))).findFirst().orElseThrow(); + assertNotEquals(original.getAsJsonObject("data").get("action_id"), fresh.getAsJsonObject("data").get("action_id")); + assertEquals("opened", fresh.getAsJsonObject("data").get("outcome").getAsString()); + assertArrayEquals(before, Files.readAllBytes(path)); + assertCorrelated(fresh); + } + } + + @Test void activeAdministratorReadAndAutocompleteCreateNoGenericOrRenderingSuccess() throws Exception { + try (var fixture = new QuestTrackingAuditNativeFixture(data)) { + var f = fixture.f; + var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true); + f.activate(fixture.actor); + FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var baseline = ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet()); + var before = fixture.actor.core.snapshot(); + entry.map.dispatch(entry.sender, "fstickadmin character show " + fixture.actor.core.record.id()); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + var events = ReskillEventsNativeTest.records(data).stream().filter(row -> !baseline.contains(row.get("id"))).toList(); + assertEquals(5, events.size(), "one inspection plus request, authorization, async pending and terminal admin action only"); + var central = events.stream().filter(row -> row.get("type").getAsString().endsWith(".admin.action")) + .map(row -> row.getAsJsonObject("data")).toList(); + assertEquals(List.of("request", "authorization", "result", "result"), + central.stream().map(row -> row.get("stage").getAsString()).toList()); + assertEquals(List.of("pending", "completed"), central.stream().filter(row -> row.has("outcome")) + .map(row -> row.get("outcome").getAsString()).toList()); + assertEquals(java.util.Set.of("games.dmg.minecraft.fantasystick.character.saved_inspection_resolved", + "games.dmg.minecraft.fantasystick.admin.action"), + events.stream().map(row -> row.get("type").getAsString()).collect(java.util.stream.Collectors.toSet())); + var ids = ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet()); + entry.map.tabComplete(entry.sender, "fstickadmin character show "); + entry.map.tabComplete(entry.sender, "fstickadmin character find "); + reads(f); FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f); + assertEquals(ids, ReskillEventsNativeTest.records(data).stream().map(row -> row.get("id")).collect(java.util.stream.Collectors.toSet())); + assertArrayEquals(before, fixture.actor.core.snapshot()); + } + } + + private void assertCorrelated(JsonObject event) throws Exception { + var v = event.getAsJsonObject("data"); + var terminal = AdminActionEntryNativeTest.rows(data).stream().filter(row -> row.getAsJsonObject("data").has("outcome") + && !row.getAsJsonObject("data").get("outcome").getAsString().equals("pending") + && row.getAsJsonObject("data").get("action_id").equals(v.get("action_id"))).toList(); + assertEquals(1, terminal.size()); + var central = terminal.getFirst().getAsJsonObject("data"); + if (v.get("authorization").getAsString().equals("denied")) assertEquals("refused", central.get("outcome").getAsString()); + if (central.get("authorization").getAsString().equals("denied")) { + assertFalse(central.has("domain_event_id") || central.has("subject_id"), "entry-denied facts must remain redacted"); + } else if (v.has("resolution_id")) assertEquals(v.get("resolution_id"), central.get("domain_event_id")); + else assertEquals(event.get("id"), central.get("domain_event_id")); + } + private static games.dmg.fantasystick.persistence.CharacterRecord withStats( + games.dmg.fantasystick.persistence.CharacterRecord r, java.util.Map stats) { + return new games.dmg.fantasystick.persistence.CharacterRecord(r.id(), r.name(), r.revision().add(java.math.BigInteger.ONE), + stats, r.level(), r.experience(), r.unspentPoints(), r.health(), r.learned(), r.loadout(), r.passives(), r.cooldowns(), + r.restrictedKills(), r.dead(), r.deathId(), r.diedAt(), r.coins(), r.coinsInitialized(), r.rewardReceipts(), r.pvpCoinEscrow(), r.gear(), r.sheetGeneration()); + } + private static net.minecraft.network.chat.ClickEvent.Custom next(FantasyStickLifecycleNativeTest.Actor actor) { + var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast()) + .get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); + assertNotNull(content); + for (var page : content.pages()) { var link = PartyBookNativeTest.find(page.raw(), "[Next pages]"); if (link != null) return link; } + return null; + } + + private List rows(String suffix) throws Exception { + return ReskillEventsNativeTest.records(data).stream() + .filter(row -> row.get("type").getAsString().equals("games.dmg.minecraft.fantasystick." + suffix)).toList(); + } + private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) { + return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"); + } + private static void reads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception { + if (f.plugin.isEnabled()) storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS); + Runnable callback; + while ((callback = f.tasks.poll()) != null) callback.run(); + } +} -- 2.54.0