feat(characters): publish saved-read audit events
CI / build (pull_request) Failing after 14m2s
CI / build (push) Failing after 14m21s

This commit is contained in:
dmg committed 2026-10-07 00:00:11 -04:00
1 parent efd64f4f8b
commit b107c08dc3
7 files changed
+955 -83

No files matched your search

@@ -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) -> {
@@ -42,9 +42,13 @@ public final class CharacterBookController implements Consumer<ControlInput>, 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<UUID, AdminLoad> adminPending = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
@@ -72,7 +76,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, 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<String> ids, CharacterRecord before) { }
private record PassiveView(String nonce, ControlInput input, java.math.BigInteger revision, java.util.Set<String> ids, CharacterRecord before) { }
@@ -173,6 +178,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, 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<ControlInput>, 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<String> 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<String, Object>();
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<ControlInput>, 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<String>();
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<ControlInput>, 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<ControlInput>, 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<ControlInput>, 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
@@ -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<String, Object> 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);
@@ -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. */ }
@@ -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<String> 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<String, Object>();
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<EventInput> sink;
private final Clock clock;
public SavedCharacterEventBridge(Consumer<EventInput> 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<Summary> 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.<Summary>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<String, Object>();
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<String, Object>();
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<String, Object> 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));
}
}
@@ -21,7 +21,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private final Consumer<Runnable> nextTick;
private final Consumer<Failure> diagnostics;
private final java.util.LinkedHashMap<UUID, String> savedCharacterCatalog = new java.util.LinkedHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<Object, Object> 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<Object, SavedSearch> 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<games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary> 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 <name...>");
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<games.dmg.fantasystick.events.SavedCharacterEventBridge.Summary>();
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 <full-character-uuid>");
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(); }
}
@@ -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<UUID>();
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<String>();
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<games.dmg.fantasystick.progression.Stat, java.math.BigInteger> 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<JsonObject> 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();
}
}