feat(fantasy-stick): audit active character inspections
This commit is contained in:
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); } });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user