feat(fantasy-stick): audit active character inspections

This commit is contained in:
dmg committed 2026-10-06 15:47:00 -04:00
1 parent a040115275
commit 496b20a3a4
7 files changed
+545 -34

No files matched your search

@@ -1126,8 +1126,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
adminActionEvents = new games.dmg.fantasystick.events.AdminActionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Admin action event intent failed", failure));
var inspectionLog = eventLog;
var inspectionEvents = new games.dmg.fantasystick.events.ActiveInspectionEventBridge(
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
books.attachInspectionEvents(inspectionEvents);
admin.attachAdminAudit(adminActionEvents);
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents);
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents);
getServer().getPluginManager().registerEvents(adminActionObserver, this);
var replayAdminActions = adminActionEvents;
adminActionDrain.whenComplete((ignored, failure) -> {
@@ -41,7 +41,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private games.dmg.fantasystick.events.ProgressionEventBridge progressionEvents;
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private record AdminLoad(games.dmg.fantasystick.events.AdminActionEventBridge.Action action) { }
private games.dmg.fantasystick.events.ActiveInspectionEventBridge inspectionEvents;
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); }
}
private final Map<UUID, AdminLoad> adminPending = new ConcurrentHashMap<>();
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
private final Map<UUID, AdminView> adminViews = new ConcurrentHashMap<>();
@@ -125,6 +129,11 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
public void attachFishingInventory(games.dmg.fantasystick.adventure.FishingDirectory fishing){if(closed||this.fishing!=null)throw new IllegalStateException("Fishing inventory already attached or books closed");this.fishing=Objects.requireNonNull(fishing);}
public void attachRareInventory(games.dmg.fantasystick.shop.RareInventoryDirectory inventory,games.dmg.fantasystick.shop.RarePotionService potions,games.dmg.fantasystick.shop.RareReskillService reskills,games.dmg.fantasystick.shop.RareMagicCoreService magicCores){if(closed||rareInventory!=null)throw new IllegalStateException("Rare inventory already attached or books closed");rareInventory=Objects.requireNonNull(inventory);rarePotions=Objects.requireNonNull(potions);rareReskills=Objects.requireNonNull(reskills);rareMagicCores=Objects.requireNonNull(magicCores);}
public void attachInspectionEvents(games.dmg.fantasystick.events.ActiveInspectionEventBridge events) {
if (closed || inspectionEvents != null) throw new IllegalStateException("Inspection events already attached or books closed");
inspectionEvents = Objects.requireNonNull(events);
}
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge audit){if(closed||adminAudit!=null)throw new IllegalStateException("Admin audit already attached or controller closed");adminAudit=Objects.requireNonNull(audit);}
private games.dmg.fantasystick.events.AdminActionEventBridge.Action commandAction() {
@@ -140,8 +149,44 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private void supersedeAdminLoad(UUID administrator, AdminLoad load) {
var previous = adminPending.put(administrator, load);
if (previous != null) adminResult(previous.action(), games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
"operation_uncertain", null);
if (previous != null) retireAdminLoad(previous, "superseded");
}
private void activeInspectionResult(AdminLoad load,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome, String centralReason,
String reason, CharacterRecord record, games.dmg.fantasystick.session.EquipmentSessions.Active active,
int pages, int volumes) {
if (load.inspection() != null) inspectionEvents.resolved(load.inspection(),
outcome.name().toLowerCase(java.util.Locale.ROOT), reason, record, active, pages, volumes);
if (load.inspection() != null && adminAudit != null) {
var facts = new java.util.LinkedHashMap<String, Object>();
facts.put("domain_event_id", load.inspection().eventId());
if (record != null && outcome == games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED)
facts.put("subject_id", record.id());
adminAudit.result(load.action(), outcome, centralReason, facts);
} else adminResult(load.action(), outcome, centralReason, record == null ? null : record.id());
}
private void activeInspectionResult(AdminLoad load,
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome, String centralReason, String reason) {
activeInspectionResult(load, outcome, centralReason, reason, null, null, 0, 0);
}
private void retireAdminLoad(AdminLoad load, String reason) {
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN,
"operation_uncertain", reason);
}
/** Command owner refuses before resolving protected character/session data. */
public void refuseActiveInspection(String reason) {
var action = commandAction();
var request = inspectionEvents == null ? null : inspectionEvents.request(action, null, 0, "command");
String centralReason = switch (reason) {
case "unauthorized", "sender_unsupported", "invalid_arguments", "permission_changed" -> reason;
default -> "not_found";
};
activeInspectionResult(new AdminLoad(action, request),
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, centralReason, reason);
}
public void attachEvents(games.dmg.fantasystick.events.BookEventBridge events){if(closed||bookEvents!=null)throw new IllegalStateException("Book events already attached or controller closed");bookEvents=Objects.requireNonNull(events);}
@@ -156,14 +201,14 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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);
refuseActiveInspection("permission_changed");
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission is required.");
return;
}
UUID targetId = target.getUniqueId();
var active = sessions.active(targetId).filter(sessions::current).orElse(null);
if (active == null) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "not_found", null);
refuseActiveInspection("target_inactive");
administrator.sendMessage("Character view refused: the target has no current active character.");
return;
}
@@ -173,10 +218,17 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset,
games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
loadReadOnly(administrator, target, active, offset, action, "command");
}
private void loadReadOnly(org.bukkit.entity.Player administrator, org.bukkit.entity.Player target,
games.dmg.fantasystick.session.EquipmentSessions.Active active, int offset,
games.dmg.fantasystick.events.AdminActionEventBridge.Action action, String entryPoint) {
if (closed) { adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null); return; }
UUID administratorId = administrator.getUniqueId();
UUID targetId = target.getUniqueId();
var load = new AdminLoad(action);
var inspection = inspectionEvents == null ? null : inspectionEvents.request(action, targetId, offset, entryPoint);
var load = new AdminLoad(action, inspection);
adminViews.remove(administratorId);
savedViews.remove(administratorId);
supersedeAdminLoad(administratorId, load);
@@ -189,32 +241,42 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
var current = currentTarget == null ? null : sessions.active(targetId)
.filter(sessions::current).orElse(null);
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "permission_changed", null);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"permission_changed", "permission_changed");
if (administrator.isOnline()) administrator.sendMessage("Character view refused: administrator permission changed.");
return;
}
if (currentTarget != target || !target.isOnline() || !active.equals(current)) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "stale", null);
if (server.getPlayer(administratorId) != administrator
|| currentTarget != target || !target.isOnline() || !active.equals(current)) {
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"stale", "stale_session");
administrator.sendMessage("Character view refused: the target character session changed.");
return;
}
if (failure != null) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"operation_failed", "load_failed");
report(new Failure(administratorId, active.character(), failure));
administrator.sendMessage("Character view could not be loaded; no state was changed.");
return;
}
if (record.isEmpty() || record.orElseThrow().dead()) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED, "not_found", null);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"not_found", "character_unavailable");
administrator.sendMessage("Character view refused: the target character is unavailable.");
return;
}
try {
administrator.openBook(renderReadOnly(record.orElseThrow(), administrator, currentTarget, active, offset));
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
"read_completed", active.character());
var loaded = record.orElseThrow();
var book = renderReadOnly(loaded, administrator, currentTarget, active, offset);
int pages = ((BookMeta) book.getItemMeta()).getPageCount();
int volumes = (overviewPages(loaded, currentTarget, null, false).size() + VOLUME_SIZE - 1) / VOLUME_SIZE;
administrator.openBook(book);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,
"read_completed", "native_open_acknowledged", loaded, active, pages, volumes);
} catch (RuntimeException error) {
adminResult(action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED, "operation_failed", null);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"operation_failed", "open_failed");
adminViews.remove(administratorId);
report(new Failure(administratorId, active.character(), error));
administrator.sendMessage("Character view could not be opened; no state was changed.");
@@ -222,7 +284,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);
activeInspectionResult(load, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED,
"operation_failed", "scheduler_failed");
report(new Failure(administratorId, active.character(), rejected));
}
});
@@ -800,15 +863,40 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
if (closed || !ADMIN_PAGES.equals(event.getIdentifier())
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)) return;
var administrator = connection.getPlayer();
if (adminAudit == null) {
navigateActiveInspection(event, administrator, null);
} else {
try (var invocation = adminAudit.openInspectionNavigation(administrator)) {
navigateActiveInspection(event, administrator, invocation.action());
}
}
}
private void navigateActiveInspection(io.papermc.paper.event.player.PlayerCustomClickEvent event,
org.bukkit.entity.Player administrator, games.dmg.fantasystick.events.AdminActionEventBridge.Action action) {
var refused = new AdminLoad(action, inspectionEvents == null ? null
: inspectionEvents.request(action, null, 0, "book_navigation"));
if (!administrator.isOnline() || !administrator.hasPermission("fantasystick.admin")) {
activeInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"permission_changed", "permission_changed");
return;
}
var view = adminViews.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 || !adminViews.remove(administrator.getUniqueId(), view)) return;
loadReadOnly(administrator, view.target(), view.active(), offset, null);
int offset = -1;
if (view != null && 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 || !adminViews.remove(administrator.getUniqueId(), view)) {
activeInspectionResult(refused, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED,
"owner_refused", "invalid_navigation");
return;
}
loadReadOnly(administrator, view.target(), view.active(), offset, action, "book_navigation");
}
@org.bukkit.event.EventHandler
@@ -1016,8 +1104,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
pending.remove(owner);
var bookRequest=pendingBookEvents.remove(owner);if(bookRequest!=null)bookEvents.refused(bookRequest,"player_quit");
var adminLoad = adminPending.remove(owner);
if (adminLoad != null) adminResult(adminLoad.action(),
games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null);
if (adminLoad != null) retireAdminLoad(adminLoad, "administrator_quit");
adminViews.remove(owner);
savedViews.remove(owner);
views.remove(owner);
@@ -1030,6 +1117,6 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
}
@Override public void close() {
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.values().forEach(load -> adminResult(load.action(), games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain", null)); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
closed = true; pending.clear();pendingBookEvents.values().forEach(request->bookEvents.refused(request,"plugin_shutdown"));pendingBookEvents.clear(); adminPending.values().forEach(load -> retireAdminLoad(load, "plugin_shutdown")); adminPending.clear(); adminViews.clear(); savedViews.clear(); views.clear(); statViews.clear(); abilityViews.clear(); passiveViews.clear(); rareViews.clear(); reskillViews.clear(); partyControls.close();
}
}
@@ -0,0 +1,73 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/** One bounded read outcome per captured administrator action; never serializes book/record contents. */
public final class ActiveInspectionEventBridge {
public static final class Request {
private final AdminActionEventBridge.Action action;
private final UUID target;
private final int offset;
private final String entryPoint;
private final AtomicBoolean resolved = new AtomicBoolean();
private Request(AdminActionEventBridge.Action action, UUID target, int offset, String entryPoint) {
this.action = action;
this.target = action.authorized() ? target : null;
this.offset = offset;
this.entryPoint = entryPoint;
}
public UUID eventId() {
return UUID.nameUUIDFromBytes(("fantasystick:active-inspection:" + action.id())
.getBytes(StandardCharsets.UTF_8));
}
}
private final Consumer<EventInput> sink;
private final Clock clock;
public ActiveInspectionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Request request(AdminActionEventBridge.Action action, UUID target, int offset, String entryPoint) {
if (action == null) return null;
if (offset < 0 || !(entryPoint.equals("command") || entryPoint.equals("book_navigation")))
throw new IllegalArgumentException("Invalid inspection metadata");
return new Request(action, target, offset, entryPoint);
}
/** Protected resolved character metadata is supplied only after native open and session revalidation. */
public void resolved(Request request, String outcome, String reason, CharacterRecord record,
EquipmentSessions.Active active, int pages, int volumes) {
if (request == null || !request.resolved.compareAndSet(false, true)) return;
var data = new LinkedHashMap<String, Object>();
boolean denied = !request.action.authorized() || reason.equals("permission_changed") || reason.equals("unauthorized");
data.put("route", "character.view");
data.put("actor_kind", request.action.actorKind());
data.put("authorization", denied ? "denied" : "authorized");
data.put("entry_point", request.entryPoint);
data.put("requested_offset", request.offset);
data.put("requested_volume", request.offset / 30 + 1);
data.put("outcome", outcome);
data.put("reason", reason);
if (!denied && request.target != null) data.put("target_player_id", request.target);
if (!denied && outcome.equals("completed") && record != null && active != null) {
data.put("target_character_id", record.id());
data.put("target_session_id", active.session());
data.put("target_revision", record.revision().toString());
data.put("presented_pages", pages);
data.put("presented_volumes", volumes);
}
try {
sink.accept(new EventInput(request.eventId(), request.action.id(), clock.instant(),
"character.active_inspection_resolved", request.action.context(), data));
} catch (RuntimeException ignored) { /* Audit delivery cannot authorize, fail or replay inspection. */ }
}
}
@@ -35,7 +35,7 @@ public final class AdminActionEventBridge implements AutoCloseable {
/** Contains only captured immutable facts; never a sender, live session, command or argument array. */
public static final class Action {
private final UUID id;
private final String route, actorKind, authorization;
private final String route, actorKind, authorization, entryPoint;
private final EventInput.Context context;
private final Map<String, Object> location;
private final Map<String, Object> arguments;
@@ -44,13 +44,16 @@ public final class AdminActionEventBridge implements AutoCloseable {
private Action(UUID id, String route, String actorKind, String authorization,
EventInput.Context context, Map<String, Object> location,
Map<String, Object> arguments, boolean invalidShape) {
Map<String, Object> arguments, boolean invalidShape, String entryPoint) {
this.id = id; this.route = route; this.actorKind = actorKind; this.authorization = authorization;
this.context = context; this.location = location; this.arguments = arguments;
this.invalidShape = invalidShape;
this.entryPoint = entryPoint;
}
public UUID id() { return id; }
public String route() { return route; }
public EventInput.Context context() { return context; }
public String actorKind() { return actorKind; }
public boolean authorized() { return authorization.equals("authorized"); }
}
@@ -119,14 +122,27 @@ public final class AdminActionEventBridge implements AutoCloseable {
}
/** Only for a final native entry observation where the plugin executor will not run. */
public void nativeEntryRefused(CommandSender sender, String[] args, String reason) {
public Action nativeEntryRefused(CommandSender sender, String[] args, String reason) {
var action = capture(sender, args);
emit(action, "request", null, null, Map.of());
emit(action, "authorization", null, null, Map.of());
result(action, Outcome.REFUSED, reason, Map.of());
return action;
}
/** 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");
emit(action, "request", null, null, Map.of());
emit(action, "authorization", null, null, Map.of());
return new Invocation(action);
}
private Action capture(CommandSender sender, String[] args) {
return capture(sender, args, "command");
}
private Action capture(CommandSender sender, String[] args, String entryPoint) {
boolean permitted = sender.hasPermission("fantasystick.admin");
String route = args != null && args.length > 0 && ROUTES.contains(args[0]) ? args[0] : "unknown";
var player = sender instanceof Player value ? value : null;
@@ -165,7 +181,7 @@ public final class AdminActionEventBridge implements AutoCloseable {
safeArgs = Map.copyOf(typed);
}
var action = new Action(UUID.randomUUID(), route, kind, permitted ? "authorized" : "denied",
context, place, safeArgs, !valid);
context, place, safeArgs, !valid, entryPoint);
synchronized (recent) { recent.put(action.id, action); }
return action;
}
@@ -268,7 +284,7 @@ public final class AdminActionEventBridge implements AutoCloseable {
var data = new LinkedHashMap<String, Object>();
data.put("stage", stage);
data.put("route", action.route);
data.put("entry_point", "command");
data.put("entry_point", action.entryPoint);
data.put("actor_kind", action.actorKind);
data.put("authorization", action.authorization);
if (action.context == null) data.put("location_status", action.actorKind.equals("command_block")
@@ -11,8 +11,14 @@ import org.purpurmc.purpur.event.ExecuteCommandEvent;
public final class AdminCommandEntryObserver implements Listener {
private final PluginCommand root;
private final AdminActionEventBridge audit;
private final ActiveInspectionEventBridge inspections;
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit) {
this(root, audit, null);
}
public AdminCommandEntryObserver(PluginCommand root, AdminActionEventBridge audit, ActiveInspectionEventBridge inspections) {
this.inspections = inspections;
this.root = Objects.requireNonNull(root);
this.audit = Objects.requireNonNull(audit);
}
@@ -26,8 +32,12 @@ public final class AdminCommandEntryObserver implements Listener {
boolean allowed = sender.hasPermission("fantasystick.admin");
if (!allowed || event.isCancelled()) {
// Describes only the state at this observation, not a claim about later monitor listeners.
audit.nativeEntryRefused(sender, event.getArgs(), allowed
var action = audit.nativeEntryRefused(sender, event.getArgs(), allowed
? "native_entry_cancelled_at_observation" : "native_entry_unauthorized_at_observation");
var args = event.getArgs();
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);
}
// Otherwise the executor owns the request; observing here too would duplicate the action.
} catch (RuntimeException unavailable) { /* Audit may not change native dispatch or authority. */ }
@@ -554,6 +554,8 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private boolean dispatchCommand(CommandSender sender, Command command, String label, String[] args) {
if (closed) return true;
if (!sender.hasPermission(PERMISSION)) {
if (characterBooks != null && args.length >= 2 && args[0].equals("character") && args[1].equals("view"))
characterBooks.refuseActiveInspection("unauthorized");
if (sessionRecoveryEvents != null && args.length > 0
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
var action = sessionRecoveryEvents.capture(sender, null);
@@ -740,11 +742,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
if (args.length == 3 && args[1].equals("view")
&& !(sender instanceof org.bukkit.entity.Player)) {
if (characterBooks != null) characterBooks.refuseActiveInspection("sender_unsupported");
adminRefused(); sender.sendMessage("Character view requires an in-game administrator.");
return;
}
if (characterBooks == null || !(sender instanceof org.bukkit.entity.Player administrator)
|| args.length != 3 || !args[1].equals("view") || args[2].isBlank()) {
if (characterBooks != null && args.length >= 2 && args[1].equals("view"))
characterBooks.refuseActiveInspection("invalid_arguments");
adminInvalid(); sender.sendMessage("Usage: /fstickadmin character <find <name...>|show <character-uuid>|recover <character-uuid> <online-player>|view <online-player>>");
return;
}
@@ -753,6 +758,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
.filter(player -> player.getName().equals(args[2]))
.toList();
if (matches.size() != 1) {
characterBooks.refuseActiveInspection("target_unavailable");
adminRefused(); sender.sendMessage("Character view refused: target must exactly name one online player.");
return;
}
@@ -0,0 +1,315 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
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 command -> authoritative read -> native open -> privacy-bounded JSONL. */
class ActiveInspectionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredOtherAccountReadExportsOnlyRevalidatedIdentityAndViewMetadata() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
fixture.actor.accountName = "Administrator";
var target = new FantasyStickLifecycleNativeTest.Actor(data);
target.accountName = "Target";
f.players.put(target.core.id, target.player);
f.activate(target);
target.core.health = 7;
var active = sessions(f).active(target.core.id).orElseThrow();
var record = storage(f).find(active.character()).get(5, TimeUnit.SECONDS).orElseThrow();
var path = data.resolve("characters").resolve(record.id() + ".yml");
var saved = Files.readAllBytes(path);
var before = target.core.snapshot();
var adminBefore = fixture.actor.core.snapshot();
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin character view Target"));
assertTrue(fixture.actor.books.isEmpty());
assertTrue(rows(data).isEmpty(), "dispatch and queued load must not claim an inspection");
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> fixture.actor.books.size() == 1);
awaitRows(f, 1);
var rows = rows(data);
assertEquals(1, rows.size());
var row = rows.getFirst();
var values = row.getAsJsonObject("data");
assertEquals(fixture.actor.core.id.toString(), row.get("playerid").getAsString());
assertEquals(target.core.id.toString(), values.get("target_player_id").getAsString());
assertEquals(record.id().toString(), values.get("target_character_id").getAsString());
assertEquals(active.session().toString(), values.get("target_session_id").getAsString());
assertEquals(record.revision().toString(), values.get("target_revision").getAsString());
assertEquals("completed", values.get("outcome").getAsString());
assertEquals("native_open_acknowledged", values.get("reason").getAsString());
assertEquals(0, values.get("requested_offset").getAsInt());
assertEquals(((org.bukkit.inventory.meta.BookMeta) fixture.actor.books.getLast().getItemMeta()).getPageCount(),
values.get("presented_pages").getAsInt());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var central = AdminActionEntryNativeTest.rows(data);
var terminal = central.stream().filter(v -> v.getAsJsonObject("data").has("outcome")
&& v.getAsJsonObject("data").get("outcome").getAsString().equals("completed")).toList();
assertEquals(1, terminal.size());
assertEquals(values.get("action_id"), terminal.getFirst().getAsJsonObject("data").get("action_id"));
assertEquals(row.get("id"), terminal.getFirst().getAsJsonObject("data").get("domain_event_id"));
assertFalse(rows.toString().contains(record.name()));
assertFalse(values.has("stats") || values.has("pages") || values.has("inventory") || values.has("record"));
assertArrayEquals(saved, Files.readAllBytes(path));
assertArrayEquals(before, target.core.snapshot());
assertArrayEquals(adminBefore, fixture.actor.core.snapshot());
assertTrue(target.books.isEmpty());
entry.map.tabComplete(entry.sender, "fstickadmin character view T");
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(1, rows(data).size());
}
}
@Test void nonceBoundNavigationGetsANewCorrelatedReadWithoutRenderingOrRepeatSuccess() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var target = actor(f, "LargeTarget");
var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repo.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
String privateDigits = "1234567890".repeat(4_000);
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger(privateDigits));
repo.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
false, null, null), old.revision());
f.activate(target);
var path = data.resolve("characters").resolve(old.id() + ".yml");
var persisted = Files.readAllBytes(path);
entry.map.dispatch(entry.sender, "fstickadmin character view LargeTarget");
reads(f); awaitRows(f, 1);
var link = next(fixture.actor);
assertNotNull(link);
int before = fixture.actor.books.size();
PartyBookNativeTest.click(f, fixture.actor, link);
reads(f); awaitRows(f, 2);
assertEquals(before + 1, fixture.actor.books.size());
var initial = rows(data).getFirst().getAsJsonObject("data");
var navigation = rows(data).get(1).getAsJsonObject("data");
assertNotEquals(initial.get("action_id"), navigation.get("action_id"));
assertEquals("book_navigation", navigation.get("entry_point").getAsString());
assertEquals(30, navigation.get("requested_offset").getAsInt());
assertEquals("completed", navigation.get("outcome").getAsString());
assertEquals(initial.get("target_character_id"), navigation.get("target_character_id"));
PartyBookNativeTest.click(f, fixture.actor, link);
reads(f); awaitRows(f, 3);
assertEquals(before + 1, fixture.actor.books.size());
assertEquals("refused", rows(data).getLast().getAsJsonObject("data").get("outcome").getAsString());
assertFalse(rows(data).getLast().getAsJsonObject("data").has("target_character_id"));
var connection = org.mockito.Mockito.mock(io.papermc.paper.connection.PlayerGameConnection.class);
org.mockito.Mockito.when(connection.getPlayer()).thenReturn(fixture.actor.player);
f.dispatcher.accept(new io.papermc.paper.event.player.PaperPlayerCustomClickEvent(
net.kyori.adventure.key.Key.key("fantasystick", "admin_character_pages"), connection,
net.minecraft.nbt.StringTag.valueOf("\"" + java.util.UUID.randomUUID() + ":1\"")));
awaitRows(f, 4);
assertEquals(before + 1, fixture.actor.books.size());
assertFalse(rows(data).getLast().getAsJsonObject("data").has("target_player_id"));
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var central = AdminActionEntryNativeTest.rows(data);
for (var row : rows(data)) {
var id = row.getAsJsonObject("data").get("action_id");
assertEquals(1, central.stream().filter(v -> v.getAsJsonObject("data").has("outcome")
&& !v.getAsJsonObject("data").get("outcome").getAsString().equals("pending")
&& v.getAsJsonObject("data").get("action_id").equals(id)).count());
}
assertEquals(2, rows(data).stream().filter(v -> v.getAsJsonObject("data").get("outcome")
.getAsString().equals("completed")).count());
assertFalse(rows(data).toString().contains(privateDigits.substring(0, 100)));
assertArrayEquals(persisted, Files.readAllBytes(path));
}
}
@ParameterizedTest @ValueSource(strings = {"permission", "admin_disconnect", "admin_replacement", "target_disconnect", "target_exit", "replacement", "shutdown", "superseded"})
void pendingReadsResolveOnceWithoutProtectedStaleIdentity(String race) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var target = actor(f, "RaceTarget"); f.activate(target);
entry.map.dispatch(entry.sender, "fstickadmin character view RaceTarget");
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
switch (race) {
case "permission" -> fixture.actor.allowed = false;
case "admin_disconnect" -> fixture.actor.core.online = false;
case "admin_replacement" -> { var replacement = actor(f, "ReconnectedAdmin"); f.players.put(fixture.actor.core.id, replacement.player); }
case "target_disconnect" -> target.core.online = false;
case "target_exit" -> sessions(f).exit(target.player);
case "replacement" -> { var replacement = actor(f, "Replacement"); f.players.put(target.core.id, replacement.player); }
case "shutdown" -> f.plugin.setEnabled(false);
case "superseded" -> entry.map.dispatch(entry.sender, "fstickadmin character view RaceTarget");
default -> fail(race);
}
reads(f);
awaitRows(f, race.equals("superseded") ? 2 : 1);
var rows = rows(data);
assertEquals(race.equals("superseded") ? 2 : 1, rows.size());
assertEquals(race.equals("superseded") ? 1 : 0, fixture.actor.books.size());
for (var row : rows) {
var v = row.getAsJsonObject("data");
if (v.get("outcome").getAsString().equals("completed")) continue;
assertFalse(v.has("target_character_id") || v.has("target_session_id") || v.has("target_revision"));
if (race.equals("permission")) {
assertEquals("denied", v.get("authorization").getAsString());
assertFalse(v.has("target_player_id"));
}
}
assertEquals(rows.size(), rows.stream().map(v -> v.getAsJsonObject("data").get("action_id")).distinct().count());
}
}
@ParameterizedTest @ValueSource(strings = {"denied", "console", "command_block", "offline", "inactive", "malformed", "dead", "missing", "failed", "open_failed"})
void refusalsAndFailedReadsAreSafeAndValidRetriesRemainIndependent(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", !failure.equals("denied"));
var target = actor(f, "ProtectedTarget");
if (!failure.equals("inactive")) f.activate(target);
var path = data.resolve("characters").resolve(target.core.record.id() + ".yml");
var valid = Files.readAllBytes(path);
switch (failure) {
case "offline" -> target.core.online = false;
case "dead" -> {
var old = storage(f).find(target.core.record.id()).get(5, TimeUnit.SECONDS).orElseThrow();
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(
new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), old.stats(), old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
true, java.util.UUID.randomUUID(), java.time.Instant.now()), old.revision());
}
case "missing" -> Files.delete(path);
case "failed" -> Files.writeString(path, "schema-version: malformed\n");
case "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));
default -> { }
}
entry.map.dispatch(entry.sender, "fstickadmin character view ProtectedTarget" + (failure.equals("malformed") ? " SECRET=opaque" : ""));
reads(f); awaitRows(f, 1);
var v = rows(data).getFirst().getAsJsonObject("data");
assertEquals(failure.equals("failed") || failure.equals("open_failed") ? "failed" : "refused", v.get("outcome").getAsString());
assertFalse(v.has("target_character_id") || v.has("target_revision") || v.has("pages") || v.has("record"));
assertFalse(rows(data).toString().contains("opaque"));
assertFalse(rows(data).toString().contains(target.core.record.name()));
assertTrue(fixture.actor.books.isEmpty());
if (failure.equals("denied")) assertFalse(v.has("target_player_id"));
if (failure.equals("failed")) {
Files.write(path, valid);
entry.map.dispatch(entry.sender, "fstickadmin character view ProtectedTarget");
reads(f); awaitRows(f, 2);
assertEquals("completed", rows(data).getLast().getAsJsonObject("data").get("outcome").getAsString());
assertNotEquals(v.get("action_id"), rows(data).getLast().getAsJsonObject("data").get("action_id"));
assertArrayEquals(valid, Files.readAllBytes(path));
}
}
}
@Test void selfAndDuplicateNamedCharactersKeepActingAndInspectedIdentitiesSeparate() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
fixture.actor.accountName = "Admin";
var other = actor(f, "Other");
for (var actor : List.of(fixture.actor, other)) {
var renamed = storage(f).renameCharacter(actor.core.record.id(), "Same Fantasy Name").get(5, TimeUnit.SECONDS).orElseThrow();
actor.core.inventory.setItemInOffHand(new games.dmg.fantasystick.items.CharacterSheets().create(renamed));
f.activate(actor);
}
for (String name : List.of("Admin", "Other")) {
entry.map.dispatch(entry.sender, "fstickadmin character view " + name); reads(f);
}
awaitRows(f, 2);
var actorSession = sessions(f).active(fixture.actor.core.id).orElseThrow();
for (var row : rows(data)) {
assertEquals(actorSession.character().toString(), row.get("characterid").getAsString());
assertEquals(fixture.actor.core.id.toString(), row.get("playerid").getAsString());
}
assertEquals(actorSession.character().toString(), rows(data).getFirst().getAsJsonObject("data").get("target_character_id").getAsString());
assertEquals(other.core.record.id().toString(), rows(data).getLast().getAsJsonObject("data").get("target_character_id").getAsString());
}
}
@ParameterizedTest @ValueSource(strings = {"permission", "target_exit", "administrator_quit"})
void acceptedNavigationRevalidatesTheCapturedReaderAndTargetBeforeOpening(String race) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f;
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var target = actor(f, "LargeTarget");
var repo = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repo.load(target.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, new java.math.BigInteger("1234567890".repeat(4_000)));
repo.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.cooldowns(), old.restrictedKills(),
false, null, null), old.revision());
f.activate(target);
entry.map.dispatch(entry.sender, "fstickadmin character view LargeTarget");
reads(f); awaitRows(f, 1);
PartyBookNativeTest.click(f, fixture.actor, java.util.Objects.requireNonNull(next(fixture.actor)));
storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
if (race.equals("permission")) fixture.actor.allowed = false;
else if (race.equals("target_exit")) sessions(f).exit(target.player);
else {
fixture.actor.core.online = false;
// This reader never fished; supply only the native quit cleanup's absent-hook boundary.
org.mockito.Mockito.doReturn(null).when(fixture.actor.player).getFishHook();
f.dispatcher.accept(new org.bukkit.event.player.PlayerQuitEvent(fixture.actor.player,
net.kyori.adventure.text.Component.empty()));
}
reads(f); awaitRows(f, 2);
assertEquals(1, fixture.actor.books.size());
assertEquals(2, rows(data).size());
var result = rows(data).getLast().getAsJsonObject("data");
assertEquals("book_navigation", result.get("entry_point").getAsString());
assertNotEquals("completed", result.get("outcome").getAsString());
assertFalse(result.has("target_character_id") || result.has("target_session_id") || result.has("target_revision"));
}
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, String name) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data); actor.accountName = name;
f.players.put(actor.core.id, actor.player); return actor;
}
private static void reads(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
if (f.plugin.isEnabled()) storage(f).find(java.util.UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable task; while ((task = f.tasks.poll()) != null) task.run();
}
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;
}
static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
}
static List<JsonObject> rows(Path data) throws Exception {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
.equals("games.dmg.minecraft.fantasystick.character.active_inspection_resolved")).toList();
}
void awaitRows(FantasyStickLifecycleNativeTest.Fixture f, int count) throws Exception {
ReskillEventsNativeTest.await(f, () -> { try { return rows(data).size() >= count; }
catch (Exception failure) { throw new AssertionError(failure); } });
}
}