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