feat(adventure): audit administrator teleport outcomes
This commit is contained in:
1 parent
7dc23161a3
commit
441037f5ff
7 files changed
+640
-51
No files matched your search
@@ -1139,6 +1139,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
|
||||
books.attachInspectionEvents(inspectionEvents);
|
||||
admin.attachAdminAudit(adminActionEvents);
|
||||
eventCapture.excludeOwnedTeleport(adventureQuestCommands::ownsTeleport);
|
||||
eventCapture.excludeOwnedTeleport(dungeonCommands::ownsTeleport);
|
||||
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents);
|
||||
getServer().getPluginManager().registerEvents(adminActionObserver, this);
|
||||
var replayAdminActions = adminActionEvents;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package games.dmg.fantasystick.adventure;
|
||||
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge;
|
||||
import games.dmg.fantasystick.events.AdminActionEventBridge.Outcome;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
@@ -11,68 +18,181 @@ import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/** Shared asynchronous, revalidated administrator teleport for durable adventure locations. */
|
||||
public final class AdventureAdminTeleport {
|
||||
private static final String PERMISSION="fantasystick.admin";
|
||||
public final class AdventureAdminTeleport implements Listener {
|
||||
private static final String PERMISSION = "fantasystick.admin";
|
||||
private final Plugin plugin;
|
||||
private final Server server;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final AdventureLocationPresentation locations;
|
||||
private AdminActionEventBridge adminAudit;
|
||||
private final Map<UUID, Move> nativeMoves = new ConcurrentHashMap<>();
|
||||
private final Map<PlayerTeleportEvent, Move> observed = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private final ThreadLocal<Move> dispatching = new ThreadLocal<>();
|
||||
|
||||
public AdventureAdminTeleport(Plugin plugin,Consumer<Runnable> nextTick){
|
||||
this.server=Objects.requireNonNull(plugin).getServer();
|
||||
this.nextTick=Objects.requireNonNull(nextTick);
|
||||
this.locations=new AdventureLocationPresentation(server);
|
||||
public AdventureAdminTeleport(Plugin plugin, Consumer<Runnable> nextTick) {
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
server = plugin.getServer();
|
||||
this.nextTick = Objects.requireNonNull(nextTick);
|
||||
locations = new AdventureLocationPresentation(server);
|
||||
}
|
||||
|
||||
public record Target(UUID id,String name,String type,UUID world,double x,double y,double z,float yaw,float pitch,boolean preserveFacing){
|
||||
public Target{Objects.requireNonNull(id);Objects.requireNonNull(name);Objects.requireNonNull(type);Objects.requireNonNull(world);}
|
||||
public void attachAdminAudit(AdminActionEventBridge bridge) {
|
||||
if (adminAudit == null) server.getPluginManager().registerEvents(this, plugin);
|
||||
adminAudit = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current){
|
||||
teleport(sender,route,expected,current,status->{});
|
||||
public record Target(UUID id, String name, String type, UUID world, double x, double y, double z,
|
||||
float yaw, float pitch, boolean preserveFacing) {
|
||||
public Target { Objects.requireNonNull(id); Objects.requireNonNull(name); Objects.requireNonNull(type); Objects.requireNonNull(world); }
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender,String route,Target expected,Supplier<Optional<Target>> current,
|
||||
Consumer<games.dmg.fantasystick.events.AdminActionEventBridge.Outcome> resolved){
|
||||
Objects.requireNonNull(resolved);
|
||||
Objects.requireNonNull(sender);Objects.requireNonNull(route);Objects.requireNonNull(expected);Objects.requireNonNull(current);
|
||||
if(!(sender instanceof Player player)){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED);sender.sendMessage(route+" teleport requires a player.");return;}
|
||||
World world=server.getWorld(expected.world());
|
||||
if(world==null){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED);sender.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
/** Live native identity stays on the native thread; future callbacks use only copied maps. */
|
||||
private static final class Move {
|
||||
final Player player;
|
||||
final Map<String, Object> from, requested;
|
||||
volatile Map<String, Object> completed = Map.of();
|
||||
volatile boolean cancelled;
|
||||
Move(Player player, Map<String, Object> from, Map<String, Object> requested) {
|
||||
this.player = player; this.from = from; this.requested = requested;
|
||||
}
|
||||
}
|
||||
|
||||
/** Match only this helper's exact player/from/destination native operation, not arbitrary plugin teleports. */
|
||||
public boolean owns(PlayerTeleportEvent event) {
|
||||
try {
|
||||
if (observed.containsKey(event)) return true;
|
||||
if (event.getCause() != PlayerTeleportEvent.TeleportCause.PLUGIN || event.getTo() == null) return false;
|
||||
var candidates = nativeMoves.values().stream().filter(move -> move.player == event.getPlayer()
|
||||
&& samePosition(move.from, event.getFrom()) && samePosition(move.requested, event.getTo())).toList();
|
||||
var direct = dispatching.get();
|
||||
var match = direct != null && candidates.contains(direct) ? direct : candidates.size() == 1 ? candidates.getFirst() : null;
|
||||
if (match == null) return false; // No invented authority for an ambiguous native observation.
|
||||
observed.put(event, match);
|
||||
return true;
|
||||
} catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
|
||||
public void observe(PlayerTeleportEvent event) { owns(event); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void resolved(PlayerTeleportEvent event) {
|
||||
var move = observed.remove(event);
|
||||
if (move == null) return;
|
||||
move.cancelled = event.isCancelled();
|
||||
move.completed = snapshot(event.getTo(), "native_event");
|
||||
}
|
||||
|
||||
private static boolean samePosition(Map<String, Object> expected, Location actual) {
|
||||
if (actual == null || actual.getWorld() == null || expected.isEmpty()) return false;
|
||||
return Objects.equals(expected.get("world_id"), actual.getWorld().getUID().toString())
|
||||
&& Objects.equals(expected.get("x"), actual.getX()) && Objects.equals(expected.get("y"), actual.getY())
|
||||
&& Objects.equals(expected.get("z"), actual.getZ());
|
||||
}
|
||||
|
||||
private static Map<String, Object> snapshot(Location location, String quality) {
|
||||
try {
|
||||
var data = new LinkedHashMap<>(AdminActionEventBridge.teleportLocation(location));
|
||||
data.put("quality", quality);
|
||||
return Map.copyOf(data);
|
||||
} catch (RuntimeException unavailable) { return Map.of(); }
|
||||
}
|
||||
|
||||
private static void mark(AdminActionEventBridge audit, AdminActionEventBridge.Action action,
|
||||
String stage, String reason, String nativeOutcome, Map<String, Object> place) {
|
||||
if (audit == null) return;
|
||||
try { audit.teleportObservation(action, stage, reason, nativeOutcome, place); }
|
||||
catch (RuntimeException ignored) { /* Optional audit failure cannot stop a native operation. */ }
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender, String route, Target expected, Supplier<Optional<Target>> current) {
|
||||
teleport(sender, route, expected, current, status -> {});
|
||||
}
|
||||
|
||||
public void teleport(CommandSender sender, String route, Target expected, Supplier<Optional<Target>> current,
|
||||
Consumer<Outcome> resolved) {
|
||||
Objects.requireNonNull(resolved); Objects.requireNonNull(sender); Objects.requireNonNull(route);
|
||||
Objects.requireNonNull(expected); Objects.requireNonNull(current);
|
||||
var audit = adminAudit;
|
||||
var action = audit == null ? null : audit.current();
|
||||
if (!(sender instanceof Player player)) {
|
||||
mark(audit, action, "validation", "sender_unsupported", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); sender.sendMessage(route + " teleport requires a player."); return;
|
||||
}
|
||||
if (audit != null) try { audit.teleportTarget(action, expected); } catch (RuntimeException ignored) { }
|
||||
mark(audit, action, "world_lookup", "world_unavailable", "not_requested", Map.of());
|
||||
World world = server.getWorld(expected.world());
|
||||
if (world == null) {
|
||||
resolved.accept(Outcome.REFUSED); sender.sendMessage(route + " teleport refused: destination world is unavailable."); return;
|
||||
}
|
||||
CompletableFuture<?> loading;
|
||||
try{loading=world.getChunkAtAsync(section(expected.x()),section(expected.z()));}
|
||||
catch(RuntimeException failure){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED);sender.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
loading.whenComplete((chunk,loadFailure)->schedule(()->{
|
||||
if(loadFailure!=null){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED);player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(!currentPlayer(player)){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED);player.sendMessage(route+" teleport refused: permission or player session changed.");return;}
|
||||
var fresh=current.get();
|
||||
if(fresh.isEmpty()||!fresh.orElseThrow().equals(expected)){
|
||||
resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED);player.sendMessage(route+" teleport refused: destination changed or is no longer registered.");return;
|
||||
mark(audit, action, "chunk_request", "chunk_load_failure", "not_requested", Map.of());
|
||||
try { loading = world.getChunkAtAsync(section(expected.x()), section(expected.z())); }
|
||||
catch (RuntimeException failure) {
|
||||
resolved.accept(Outcome.FAILED); sender.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
}
|
||||
loading.whenComplete((chunk, loadFailure) -> schedule(() -> {
|
||||
if (loadFailure != null) {
|
||||
mark(audit, action, "chunk_loading", "chunk_load_failure", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.FAILED); player.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
}
|
||||
World currentWorld=server.getWorld(expected.world());
|
||||
if(currentWorld==null||currentWorld!=world){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.REFUSED);player.sendMessage(route+" teleport refused: destination world is unavailable.");return;}
|
||||
var facing=player.getLocation();
|
||||
var destination=new Location(world,expected.x(),expected.y(),expected.z(),
|
||||
expected.preserveFacing()?facing.getYaw():expected.yaw(),expected.preserveFacing()?facing.getPitch():expected.pitch());
|
||||
if (!currentPlayer(player)) {
|
||||
mark(audit, action, "revalidation", "permission_or_session_changed", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: permission or player session changed."); return;
|
||||
}
|
||||
var fresh = current.get();
|
||||
if (fresh.isEmpty() || !fresh.orElseThrow().equals(expected)) {
|
||||
mark(audit, action, "revalidation", "registration_changed", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: destination changed or is no longer registered."); return;
|
||||
}
|
||||
World currentWorld = server.getWorld(expected.world());
|
||||
if (currentWorld == null || currentWorld != world) {
|
||||
mark(audit, action, "revalidation", "world_unavailable", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.REFUSED); player.sendMessage(route + " teleport refused: destination world is unavailable."); return;
|
||||
}
|
||||
var facing = player.getLocation();
|
||||
var destination = new Location(world, expected.x(), expected.y(), expected.z(),
|
||||
expected.preserveFacing() ? facing.getYaw() : expected.yaw(), expected.preserveFacing() ? facing.getPitch() : expected.pitch());
|
||||
var move = new Move(player, snapshot(facing, "native_request"), snapshot(destination, "requested"));
|
||||
mark(audit, action, "native_request", "native_failure", "pending", move.requested);
|
||||
CompletableFuture<Boolean> moving;
|
||||
try{moving=player.teleportAsync(destination);}
|
||||
catch(RuntimeException failure){resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED);player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
moving.whenComplete((moved,moveFailure)->{
|
||||
boolean completed=moveFailure==null&&Boolean.TRUE.equals(moved);
|
||||
// The captured immutable action resolves from the native future, not feedback delivery.
|
||||
resolved.accept(completed?games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED:games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED);
|
||||
schedule(()->{
|
||||
if(!completed){player.sendMessage(route+" teleport failed; your location is unchanged.");return;}
|
||||
if(player.isOnline())player.sendMessage("Teleported to "+expected.name()+" ("+expected.type()+") at "+locations.format(expected.world(),expected.x(),expected.y(),expected.z())+".");
|
||||
if (action != null) nativeMoves.put(action.id(), move);
|
||||
dispatching.set(move);
|
||||
try { moving = player.teleportAsync(destination); }
|
||||
catch (RuntimeException failure) {
|
||||
if (action != null) nativeMoves.remove(action.id(), move);
|
||||
mark(audit, action, "native_request", "native_failure", "failed", move.requested);
|
||||
resolved.accept(Outcome.FAILED); player.sendMessage(route + " teleport failed; your location is unchanged."); return;
|
||||
} finally { dispatching.remove(); }
|
||||
moving.whenComplete((moved, moveFailure) -> {
|
||||
if (action != null) nativeMoves.remove(action.id(), move);
|
||||
boolean completed = moveFailure == null && Boolean.TRUE.equals(moved);
|
||||
String reason = completed ? "native_completed" : moveFailure != null ? "native_failure" : move.cancelled ? "native_cancelled" : "native_refused";
|
||||
mark(audit, action, "native_completion", reason, completed ? "completed" : moveFailure != null ? "failed" : move.cancelled ? "cancelled" : "refused",
|
||||
move.completed.isEmpty() ? move.requested : move.completed);
|
||||
// Future completion is authoritative; no native getters, feedback or scheduler acknowledgment needed.
|
||||
resolved.accept(completed ? Outcome.COMPLETED : Outcome.FAILED);
|
||||
schedule(() -> {
|
||||
if (!completed) { player.sendMessage(route + " teleport failed; your location is unchanged."); return; }
|
||||
if (player.isOnline()) player.sendMessage("Teleported to " + expected.name() + " (" + expected.type() + ") at "
|
||||
+ locations.format(expected.world(), expected.x(), expected.y(), expected.z()) + ".");
|
||||
});
|
||||
});
|
||||
},()->resolved.accept(games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.FAILED)));
|
||||
}, () -> {
|
||||
mark(audit, action, "main_handoff", "scheduler_unavailable", "not_requested", Map.of());
|
||||
resolved.accept(Outcome.FAILED);
|
||||
}));
|
||||
}
|
||||
|
||||
private boolean currentPlayer(Player player){return player.isOnline()&&player.hasPermission(PERMISSION)&&server.getPlayer(player.getUniqueId())==player;}
|
||||
private void schedule(Runnable task){schedule(task,()->{});}
|
||||
private void schedule(Runnable task,Runnable rejected){try{nextTick.accept(task);}catch(RuntimeException unavailable){rejected.run();}}
|
||||
private static int section(double coordinate){return((int)Math.floor(coordinate))>>4;}
|
||||
private boolean currentPlayer(Player player) { return player.isOnline() && player.hasPermission(PERMISSION) && server.getPlayer(player.getUniqueId()) == player; }
|
||||
private void schedule(Runnable task) { schedule(task, () -> {}); }
|
||||
private void schedule(Runnable task, Runnable rejected) { try { nextTick.accept(task); } catch (RuntimeException unavailable) { rejected.run(); } }
|
||||
private static int section(double coordinate) { return ((int) Math.floor(coordinate)) >> 4; }
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public final class AdventureQuestCommands {
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
// Warning requests are only linked to an explicit confirmation by the same live sender.
|
||||
private final Map<CommandSender,games.dmg.fantasystick.events.AdminActionEventBridge.Action> rerollWarnings=new WeakHashMap<>();
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);}
|
||||
public boolean ownsTeleport(org.bukkit.event.player.PlayerTeleportEvent event){return teleports.owns(event);}
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);teleports.attachAdminAudit(bridge);}
|
||||
private void completedRead(games.dmg.fantasystick.events.AdminActionEventBridge.Action action){if(adminAudit!=null)adminAudit.result(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"read_completed",Map.of());}
|
||||
private void outcome(games.dmg.fantasystick.events.AdminActionEventBridge.Action action, games.dmg.fantasystick.events.AdminActionEventBridge.Outcome status, String reason, UUID operation, UUID subject){
|
||||
if(adminAudit==null)return;
|
||||
|
||||
@@ -21,7 +21,8 @@ public final class DungeonCommands {
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick){this(plugin,directory,controller,adventures,nextTick,id->CompletableFuture.completedFuture(Optional.empty()),id->false,id->false);}
|
||||
public DungeonCommands(Plugin plugin,DungeonDirectory directory,DungeonController controller,games.dmg.fantasystick.adventure.AdventureQuestDirectory adventures,Consumer<Runnable> nextTick,Function<UUID,CompletableFuture<Optional<CharacterRecord>>> characters,Predicate<UUID> activeCharacter,Predicate<UUID> partyCharacter){this.plugin=Objects.requireNonNull(plugin);this.directory=Objects.requireNonNull(directory);this.controller=Objects.requireNonNull(controller);this.adventures=Objects.requireNonNull(adventures);this.nextTick=Objects.requireNonNull(nextTick);this.characters=Objects.requireNonNull(characters);this.activeCharacter=Objects.requireNonNull(activeCharacter);this.partyCharacter=Objects.requireNonNull(partyCharacter);locations=new games.dmg.fantasystick.adventure.AdventureLocationPresentation(plugin.getServer());teleports=new AdventureAdminTeleport(plugin,nextTick);}
|
||||
private games.dmg.fantasystick.events.AdminActionEventBridge adminAudit;
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);}
|
||||
public boolean ownsTeleport(org.bukkit.event.player.PlayerTeleportEvent event){return teleports.owns(event);}
|
||||
public void attachAdminAudit(games.dmg.fantasystick.events.AdminActionEventBridge bridge){adminAudit=Objects.requireNonNull(bridge);teleports.attachAdminAudit(bridge);}
|
||||
private void completedRead(games.dmg.fantasystick.events.AdminActionEventBridge.Action action){adminResult(action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome.COMPLETED,"read_completed",null,null);}
|
||||
private void adminResult(games.dmg.fantasystick.events.AdminActionEventBridge.Action action,games.dmg.fantasystick.events.AdminActionEventBridge.Outcome outcome,String reason,UUID operation,String type){
|
||||
if(adminAudit==null||action==null)return;
|
||||
|
||||
@@ -41,14 +41,16 @@ public final class AdminActionEventBridge implements AutoCloseable {
|
||||
private final Map<String, Object> arguments;
|
||||
private final boolean invalidShape;
|
||||
private boolean terminal, pending;
|
||||
private final Map<String, Object> teleportData;
|
||||
|
||||
private Action(UUID id, String route, String actorKind, String authorization,
|
||||
EventInput.Context context, Map<String, Object> location,
|
||||
Map<String, Object> arguments, boolean invalidShape, String entryPoint) {
|
||||
Map<String, Object> arguments, boolean invalidShape, String entryPoint, Map<String, Object> teleportData) {
|
||||
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;
|
||||
this.teleportData = teleportData;
|
||||
}
|
||||
public UUID id() { return id; }
|
||||
public String route() { return route; }
|
||||
@@ -105,7 +107,7 @@ public final class AdminActionEventBridge implements AutoCloseable {
|
||||
Consumer<Throwable> failures) {
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
outbox = new FeatureEventOutbox(data, "admin-action-event-intents", Set.of("admin.action"),
|
||||
outbox = new FeatureEventOutbox(data, "admin-action-event-intents", Set.of("admin.action", "admin.teleport_resolved"),
|
||||
Objects.requireNonNull(sink), Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
@@ -183,8 +185,18 @@ public final class AdminActionEventBridge implements AutoCloseable {
|
||||
}
|
||||
safeArgs = Map.copyOf(typed);
|
||||
}
|
||||
Map<String, Object> teleportData = null;
|
||||
if ((route.equals("adventure-site") || route.equals("dungeon")) && args.length >= 2 && args[1].equals("teleport")) {
|
||||
teleportData = new LinkedHashMap<>();
|
||||
teleportData.put("stage", "validation");
|
||||
teleportData.put("native_outcome", "not_requested");
|
||||
if (player != null) {
|
||||
try { teleportData.put("from_location", teleportLocation(player.getLocation())); }
|
||||
catch (RuntimeException unavailable) { teleportData.put("from_location_status", "unavailable"); }
|
||||
} else teleportData.put("from_location_status", "not_applicable");
|
||||
}
|
||||
var action = new Action(UUID.randomUUID(), route, kind, permitted ? "authorized" : "denied",
|
||||
context, place, safeArgs, !valid, entryPoint);
|
||||
context, place, safeArgs, !valid, entryPoint, teleportData);
|
||||
synchronized (recent) { recent.put(action.id, action); }
|
||||
return action;
|
||||
}
|
||||
@@ -228,10 +240,91 @@ public final class AdminActionEventBridge implements AutoCloseable {
|
||||
var facts = action.authorized() && !action.route.equals("unknown") ? safeFacts(typedFacts) : Map.<String, Object>of();
|
||||
emit(action, "result", outcome.name().toLowerCase(java.util.Locale.ROOT), reason, facts);
|
||||
if (outcome == Outcome.PENDING) action.pending = true;
|
||||
else { action.terminal = true; synchronized (recent) { recent.remove(action.id); } }
|
||||
else {
|
||||
emitTeleport(action, outcome, reason);
|
||||
action.terminal = true;
|
||||
synchronized (recent) { recent.remove(action.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable registration facts come only from the authorized teleport owner. */
|
||||
public void teleportTarget(Action action, games.dmg.fantasystick.adventure.AdventureAdminTeleport.Target target) {
|
||||
if (action == null || !action.authorized() || action.teleportData == null) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal) return;
|
||||
action.teleportData.put("registration_kind", action.route.equals("dungeon") ? "dungeon" : "adventure_site");
|
||||
action.teleportData.put("registration_id", target.id().toString());
|
||||
action.teleportData.put("registration_name", target.name());
|
||||
action.teleportData.put("registration_type", switch (target.type()) {
|
||||
case "monster camp" -> "camp";
|
||||
case "dungeon entrance" -> "dungeon";
|
||||
case "fishing site" -> "fishing";
|
||||
case "champion" -> "champion";
|
||||
default -> "unknown";
|
||||
});
|
||||
action.teleportData.put("facing_policy", target.preserveFacing() ? "preserve_player_facing" : "configured_entrance_facing");
|
||||
var destination = new LinkedHashMap<String, Object>();
|
||||
destination.put("world_id", target.world().toString());
|
||||
destination.put("x", target.x()); destination.put("y", target.y()); destination.put("z", target.z());
|
||||
if (!target.preserveFacing()) {
|
||||
destination.put("yaw", (double) target.yaw()); destination.put("pitch", (double) target.pitch());
|
||||
} else if (action.teleportData.get("from_location") instanceof Map<?, ?> origin) {
|
||||
if (origin.get("yaw") != null) destination.put("yaw", origin.get("yaw"));
|
||||
if (origin.get("pitch") != null) destination.put("pitch", origin.get("pitch"));
|
||||
} else destination.put("facing_status", "unavailable");
|
||||
action.teleportData.put("intended_to_location", Map.copyOf(destination));
|
||||
action.teleportData.put("to_location", Map.copyOf(destination));
|
||||
action.teleportData.put("to_location_status", "intended");
|
||||
}
|
||||
}
|
||||
|
||||
/** Called at actual async boundaries; never reads a native sender/session on a worker. */
|
||||
public void teleportObservation(Action action, String stage, String reason, String nativeOutcome, Map<String, Object> destination) {
|
||||
if (action == null || action.teleportData == null) return;
|
||||
synchronized (action) {
|
||||
if (action.terminal) return;
|
||||
action.teleportData.put("stage", stage);
|
||||
action.teleportData.put("reason", reason);
|
||||
action.teleportData.put("native_outcome", nativeOutcome);
|
||||
if (action.authorized() && !destination.isEmpty()) {
|
||||
action.teleportData.put("to_location", destination);
|
||||
action.teleportData.put("to_location_status", nativeOutcome.equals("completed")
|
||||
? "native_event".equals(destination.get("quality")) ? "native_completed" : "requested_only"
|
||||
: "attempted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, Object> teleportLocation(Location location) {
|
||||
var world = Objects.requireNonNull(location.getWorld());
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("world_id", world.getUID().toString());
|
||||
data.put("x", location.getX()); data.put("y", location.getY()); data.put("z", location.getZ());
|
||||
data.put("yaw", (double) location.getYaw()); data.put("pitch", (double) location.getPitch());
|
||||
try { data.put("world_name", Objects.requireNonNull(world.getName())); }
|
||||
catch (RuntimeException unavailable) { data.put("world_name_status", "unavailable"); }
|
||||
return Map.copyOf(data);
|
||||
}
|
||||
|
||||
private void emitTeleport(Action action, Outcome outcome, String fallbackReason) {
|
||||
if (action.teleportData == null) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<>(action.teleportData);
|
||||
data.put("route", action.route);
|
||||
data.put("entry_point", "command");
|
||||
data.put("actor_kind", action.actorKind);
|
||||
data.put("authorization", action.authorization);
|
||||
data.put("outcome", outcome.name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.putIfAbsent("reason", fallbackReason);
|
||||
if (outcome == Outcome.UNCERTAIN) data.put("reason", "operation_uncertain");
|
||||
if (action.context == null || action.context.location() == null)
|
||||
data.put("location_status", action.actorKind.equals("console") ? "not_applicable" : "unavailable");
|
||||
var id = UUID.nameUUIDFromBytes(("fantasystick:admin-teleport:" + action.id).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
outbox.accepted(new EventInput(id, action.id, clock.instant(), "admin.teleport_resolved", action.context, data));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot authorize, undo or repeat a native teleport. */ }
|
||||
}
|
||||
|
||||
private Map<String, Object> safeFacts(Map<String, Object> input) {
|
||||
if (input == null || input.isEmpty()) return Map.of();
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
|
||||
@@ -40,6 +40,14 @@ public final class EventCaptureListener implements Listener {
|
||||
private final Clock clock;
|
||||
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
|
||||
private java.util.function.Predicate<PlayerTeleportEvent> ownedTeleport = ignored -> false;
|
||||
public void excludeOwnedTeleport(java.util.function.Predicate<PlayerTeleportEvent> owner) {
|
||||
ownedTeleport = ownedTeleport.or(Objects.requireNonNull(owner));
|
||||
}
|
||||
private boolean isOwnedTeleport(PlayerTeleportEvent event) {
|
||||
try { return ownedTeleport.test(event); }
|
||||
catch (RuntimeException unavailable) { return false; }
|
||||
}
|
||||
|
||||
/** Feature owners replace the generic incoming view with one richer application record. */
|
||||
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
|
||||
@@ -133,7 +141,7 @@ public final class EventCaptureListener implements Listener {
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerSwapHandItemsEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerTeleportEvent event){if(isOwnedTeleport(event))return;var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerTeleportEvent");details.put("cause",event.getCause().name());details.put("from_location",locationMap(event.getFrom()));if(event.getTo()!=null)details.put("to_location",locationMap(event.getTo()));capture(event,event.getPlayer(),"teleport",details,event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerTeleportEvent event){resolveCaptured(event,event);}
|
||||
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(VehicleEnterEvent event){if(event.getEntered() instanceof Player player)capture(event,player,"vehicle_enter",entity(event.getVehicle(),"VehicleEnterEvent"),event);}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(VehicleEnterEvent event){resolveCaptured(event,event);}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import games.dmg.fantasystick.adventure.AdventureQuestState;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
import games.dmg.fantasystick.dungeon.DungeonDirectory;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered administrator entry -> exact async native owner -> correlated local JSONL. */
|
||||
class AdminTeleportEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredCampArrivalExportsOnceOnlyAfterNativeCompletionWithCapturedIdentityAndLocations() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
f.activate(actor);
|
||||
var active = QuestTrackingEventsNativeTest.active(f, actor.core.id);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
var inventory = actor.core.snapshot();
|
||||
var origin = actor.core.location.clone();
|
||||
origin.setYaw(31); origin.setPitch(-12); actor.core.location = origin.clone();
|
||||
var site = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP,
|
||||
"Audit Camp", actor.world.getUID(), 32, 70, 48).get(5, TimeUnit.SECONDS);
|
||||
var sites = fixture.directory.snapshot();
|
||||
var chunk = new CompletableFuture<Chunk>(); var moving = new CompletableFuture<Boolean>();
|
||||
var destinations = new ArrayList<Location>();
|
||||
doReturn(chunk).when(actor.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> { destinations.add(((Location) call.getArgument(0)).clone()); return moving; })
|
||||
.when(actor.player).teleportAsync(any(Location.class));
|
||||
assertTrue(entry.map.dispatch(actor.player, "fantasystick:fstickadmin adventure-site teleport " + site.id()));
|
||||
assertTrue(destinations.isEmpty());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertTrue(rows(data).isEmpty(), "command dispatch is not arrival");
|
||||
assertTrue(chunk.complete(f.own(mock(Chunk.class))));
|
||||
ReskillEventsNativeTest.await(f, () -> destinations.size() == 1);
|
||||
assertEquals(new Location(actor.world, 32, 70, 48, 31, -12), destinations.getFirst());
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertTrue(rows(data).isEmpty(), "chunk completion is not arrival");
|
||||
assertTrue(moving.complete(true));
|
||||
assertFalse(moving.complete(true), "a duplicate completion cannot repeat the native outcome");
|
||||
// Native future completion is authoritative even before a feedback task is allowed to run.
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var rows = rows(data);
|
||||
assertEquals(1, rows.size(), "registered completed teleport needs one feature-owned JSONL resolution");
|
||||
var row = rows.getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), row.get("playerid").getAsString());
|
||||
assertEquals(before.id().toString(), row.get("characterid").getAsString());
|
||||
assertEquals(active.session().toString(), values.get("session_id").getAsString());
|
||||
assertEquals("adventure-site", values.get("route").getAsString());
|
||||
assertEquals("authorized", values.get("authorization").getAsString());
|
||||
assertEquals("completed", values.get("outcome").getAsString());
|
||||
assertEquals("native_completion", values.get("stage").getAsString());
|
||||
assertEquals("native_completed", values.get("reason").getAsString());
|
||||
assertEquals("camp", values.get("registration_type").getAsString());
|
||||
assertEquals(site.id().toString(), values.get("registration_id").getAsString());
|
||||
assertEquals(site.displayName(), values.get("registration_name").getAsString());
|
||||
assertEquals("preserve_player_facing", values.get("facing_policy").getAsString());
|
||||
assertEquals(origin.getX(), values.getAsJsonObject("from_location").get("x").getAsDouble());
|
||||
assertEquals(32, values.getAsJsonObject("to_location").get("x").getAsDouble());
|
||||
assertEquals(31, values.getAsJsonObject("to_location").get("yaw").getAsFloat());
|
||||
String operation = values.get("action_id").getAsString();
|
||||
var central = AdminActionEntryNativeTest.rows(data).stream()
|
||||
.filter(value -> value.getAsJsonObject("data").get("action_id").getAsString().equals(operation)).toList();
|
||||
assertEquals(List.of("request", "authorization", "result", "result"), central.stream()
|
||||
.map(value -> value.getAsJsonObject("data").get("stage").getAsString()).toList());
|
||||
assertEquals("completed", central.getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
assertArrayEquals(inventory, actor.core.snapshot());
|
||||
assertEquals(sites, fixture.directory.snapshot());
|
||||
}
|
||||
assertEquals(1, rows(data).size(), "shutdown cannot duplicate the result");
|
||||
}
|
||||
|
||||
static Stream<Arguments> nativePaths() {
|
||||
return Stream.of("camp", "champion", "dungeon").flatMap(kind -> Stream.of(
|
||||
"success", "same_world", "native_cancelled", "native_redirected", "native_false", "native_exception", "native_throw",
|
||||
"chunk_exception", "chunk_throw", "stale", "permission_revoked", "connection_replaced", "disconnected",
|
||||
"character_exit", "world_unavailable", "world_removed").map(scenario -> Arguments.of(kind, scenario)));
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0}: {1}") @MethodSource("nativePaths")
|
||||
void registeredTeleportExportsPreciseAsyncStageAndActualNativeDestinationWithoutGenericDuplicate(String kind, String scenario) throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
f.activate(actor);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
var origin = actor.core.location.clone();
|
||||
var inventory = actor.core.snapshot();
|
||||
var destination = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
UUID world = UUID.randomUUID();
|
||||
when(destination.world.getUID()).thenReturn(world); when(destination.world.getName()).thenReturn("Destination Realm");
|
||||
f.players.put(destination.core.id, destination.player);
|
||||
var nativeWorld = scenario.equals("same_world") ? actor.world : destination.world;
|
||||
var persistedWorld = scenario.equals("world_unavailable") ? UUID.randomUUID() : nativeWorld.getUID();
|
||||
var dungeons = (DungeonDirectory) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "dungeons");
|
||||
UUID id = kind.equals("dungeon") ? dungeons.create("Vault \"quoted\" café", persistedWorld, -33, 72, 48, 67, -20)
|
||||
.get(5, TimeUnit.SECONDS).id() : fixture.directory.placeSite(kind.equals("camp") ? AdventureQuestState.SiteType.CAMP : AdventureQuestState.SiteType.CHAMPION,
|
||||
"Site \"quoted\" café", persistedWorld, -33, 72, 48).get(5, TimeUnit.SECONDS).id();
|
||||
var sites = fixture.directory.snapshot(); var dungeonState = dungeons.snapshot();
|
||||
var chunk = new CompletableFuture<Chunk>(); var moving = new CompletableFuture<Boolean>();
|
||||
var destinations = new ArrayList<Location>(); var observedNative = new ArrayList<PlayerTeleportEvent>();
|
||||
if (scenario.equals("chunk_throw")) doThrow(new IllegalStateException("PRIVATE_CHUNK_THROW")).when(nativeWorld).getChunkAtAsync(-3, 3);
|
||||
else doReturn(chunk).when(nativeWorld).getChunkAtAsync(-3, 3);
|
||||
if (scenario.equals("native_redirected")) f.dispatcher.register(new org.bukkit.event.Listener() {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = false)
|
||||
public void redirect(PlayerTeleportEvent event) {
|
||||
if (event.getPlayer() != actor.player || event.getTo() == null) return;
|
||||
var changed = event.getTo().clone(); changed.setX(-65); changed.setYaw(13); event.setTo(changed);
|
||||
}
|
||||
}, f.plugin);
|
||||
doAnswer(call -> {
|
||||
var requested = ((Location) call.getArgument(0)).clone(); destinations.add(requested);
|
||||
if (scenario.equals("native_throw")) throw new IllegalStateException("PRIVATE_NATIVE_THROW");
|
||||
var event = new PlayerTeleportEvent(actor.player, actor.core.location.clone(), requested.clone(), PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
if (scenario.equals("native_cancelled")) event.setCancelled(true);
|
||||
observedNative.add(event); f.dispatcher.accept(event);
|
||||
return moving;
|
||||
}).when(actor.player).teleportAsync(any(Location.class));
|
||||
String route = kind.equals("dungeon") ? "dungeon" : "adventure-site";
|
||||
assertTrue(entry.map.dispatch(actor.player, "fantasystick:fstickadmin " + route + " teleport " + id));
|
||||
boolean immediate = scenario.equals("chunk_throw") || scenario.equals("world_unavailable");
|
||||
if (!immediate) {
|
||||
if (scenario.equals("stale")) {
|
||||
if (kind.equals("dungeon")) dungeons.remove(id).get(5, TimeUnit.SECONDS);
|
||||
else fixture.directory.removeSite(id).get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
if (scenario.equals("permission_revoked")) actor.allowed = false;
|
||||
if (scenario.equals("disconnected")) doReturn(false).when(actor.player).isOnline();
|
||||
if (scenario.equals("connection_replaced")) f.players.put(actor.core.id, destination.player);
|
||||
if (scenario.equals("world_removed")) f.players.remove(destination.core.id);
|
||||
if (scenario.equals("character_exit")) {
|
||||
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
|
||||
ReskillEventsNativeTest.await(f, () -> {
|
||||
try { return ((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(actor.core.id).isEmpty(); } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
}
|
||||
if (scenario.equals("chunk_exception")) chunk.completeExceptionally(new IllegalStateException("PRIVATE_CHUNK_FUTURE"));
|
||||
else chunk.complete(f.own(mock(Chunk.class)));
|
||||
}
|
||||
boolean reachesNative = scenario.startsWith("native_") || scenario.equals("success") || scenario.equals("same_world") || scenario.equals("character_exit");
|
||||
if (reachesNative) {
|
||||
ReskillEventsNativeTest.await(f, () -> destinations.size() == 1);
|
||||
assertEquals(-33, destinations.getFirst().getX());
|
||||
assertEquals(kind.equals("dungeon") ? 67 : origin.getYaw(), destinations.getFirst().getYaw());
|
||||
if (!scenario.equals("native_throw")) {
|
||||
assertEquals(1, observedNative.size());
|
||||
if (scenario.equals("native_exception")) moving.completeExceptionally(new IllegalStateException("PRIVATE_NATIVE_FUTURE"));
|
||||
else moving.complete(!scenario.equals("native_cancelled") && !scenario.equals("native_false"));
|
||||
}
|
||||
} else ReskillEventsNativeTest.await(f, () -> actor.messages.stream().anyMatch(value -> value.contains("teleport refused") || value.contains("teleport failed")));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var outcomes = rows(data);
|
||||
assertEquals(1, outcomes.size(), "each registered attempt has one semantic domain result");
|
||||
var row = outcomes.getFirst(); var values = row.getAsJsonObject("data");
|
||||
String expectedOutcome = scenario.equals("success") || scenario.equals("same_world") || scenario.equals("character_exit") || scenario.equals("native_redirected") ? "completed"
|
||||
: scenario.startsWith("native_") || scenario.startsWith("chunk_") ? "failed" : "refused";
|
||||
String stage = scenario.equals("chunk_throw") ? "chunk_request" : scenario.equals("chunk_exception") ? "chunk_loading"
|
||||
: scenario.equals("world_unavailable") ? "world_lookup" : scenario.equals("native_throw") ? "native_request"
|
||||
: reachesNative ? "native_completion" : "revalidation";
|
||||
assertEquals(expectedOutcome, values.get("outcome").getAsString());
|
||||
assertEquals(stage, values.get("stage").getAsString());
|
||||
assertEquals(kind, values.get("registration_type").getAsString());
|
||||
assertEquals(id.toString(), values.get("registration_id").getAsString());
|
||||
assertEquals(before.id().toString(), row.get("characterid").getAsString(), "capture cannot follow a later replacement session");
|
||||
assertEquals(origin.getX(), values.getAsJsonObject("from_location").get("x").getAsDouble());
|
||||
assertTrue(values.has("intended_to_location"), "the authorized persisted destination must be captured even on failure");
|
||||
assertEquals(persistedWorld.toString(), values.getAsJsonObject("intended_to_location").get("world_id").getAsString());
|
||||
if (scenario.equals("native_redirected")) {
|
||||
assertEquals(-65, values.getAsJsonObject("to_location").get("x").getAsDouble(), "native final target, not configured target");
|
||||
assertEquals(13, values.getAsJsonObject("to_location").get("yaw").getAsFloat());
|
||||
}
|
||||
if (scenario.equals("native_cancelled")) {
|
||||
assertTrue(observedNative.getFirst().isCancelled(), "audit must preserve native cancellation");
|
||||
assertEquals("native_cancelled", values.get("reason").getAsString());
|
||||
}
|
||||
String action = values.get("action_id").getAsString();
|
||||
var central = AdminActionEntryNativeTest.rows(data).stream().filter(value -> value.getAsJsonObject("data").get("action_id").getAsString().equals(action)).toList();
|
||||
assertEquals(expectedOutcome, central.getLast().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals(1, central.stream().filter(value -> value.getAsJsonObject("data").has("outcome")
|
||||
&& !value.getAsJsonObject("data").get("outcome").getAsString().equals("pending")).count());
|
||||
assertFalse(ReskillEventsNativeTest.records(data).stream().anyMatch(value -> value.get("type").getAsString().endsWith("action.resolved")
|
||||
&& value.getAsJsonObject("data").has("action_kind") && value.getAsJsonObject("data").get("action_kind").getAsString().equals("teleport")),
|
||||
"the owned native teleport must not also emit a generic teleport result");
|
||||
assertFalse(outcomes.toString().contains("PRIVATE_"));
|
||||
if (!scenario.equals("character_exit")) {
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor));
|
||||
assertArrayEquals(inventory, actor.core.snapshot());
|
||||
} else assertEquals(before.coins(), CombatExperienceNativeTest.record(f, actor).coins());
|
||||
if (!scenario.equals("stale")) { assertEquals(sites, fixture.directory.snapshot()); assertEquals(dungeonState, dungeons.snapshot()); }
|
||||
if (!reachesNative) assertTrue(destinations.isEmpty(), "refusal cannot request native movement");
|
||||
// Put the native player lease back before orderly fixture disposal, without retrying the command.
|
||||
actor.allowed = true; doReturn(true).when(actor.player).isOnline(); f.players.put(actor.core.id, actor.player);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unauthorizedAndUnsupportedSendersHaveSanitizedResultsAndAutocompleteProducesNone() throws Exception {
|
||||
for (String route : List.of("adventure-site", "dungeon")) {
|
||||
for (String kind : List.of("denied", "console", "command_block")) {
|
||||
Path root = data.resolve(route + "-" + kind);
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(root)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, kind.equals("denied") ? "player" : kind, !kind.equals("denied"));
|
||||
UUID id = UUID.randomUUID();
|
||||
var inventory = actor.core.snapshot();
|
||||
entry.map.tabComplete(entry.sender, "fstickadmin " + route + " teleport ");
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
assertTrue(rows(root).isEmpty()); assertTrue(AdminActionEntryNativeTest.rows(root).isEmpty());
|
||||
assertTrue(entry.map.dispatch(entry.sender, "fantasystick:fstickadmin " + route + " teleport " + id));
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var outcomes = rows(root); assertEquals(1, outcomes.size());
|
||||
var values = outcomes.getFirst().getAsJsonObject("data");
|
||||
assertEquals("refused", values.get("outcome").getAsString());
|
||||
assertEquals(kind.equals("denied") ? "denied" : "authorized", values.get("authorization").getAsString());
|
||||
assertFalse(values.has("registration_id")); assertFalse(values.has("registration_name"));
|
||||
assertFalse(values.has("to_location")); assertFalse(values.has("intended_to_location"));
|
||||
assertFalse(outcomes.toString().contains(id.toString()));
|
||||
assertArrayEquals(inventory, actor.core.snapshot());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test void rejectedAuditAndFeedbackCannotUndoNativeCompletionAndRestartReplaysTheSameCapturedIntent() throws Exception {
|
||||
var attempts = new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
|
||||
games.dmg.fantasystick.events.EventInput original;
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
f.activate(actor);
|
||||
var before = CombatExperienceNativeTest.record(f, actor);
|
||||
var inventory = actor.core.snapshot();
|
||||
var site = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP, "Replay Camp", actor.world.getUID(), 32, 70, 48).get(5, TimeUnit.SECONDS);
|
||||
var writer = (games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
var bridge = FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "adminActionEvents");
|
||||
var outbox = FantasyStickLifecycleNativeTest.Fixture.field(bridge, "outbox");
|
||||
var sink = outbox.getClass().getDeclaredField("sink"); sink.setAccessible(true);
|
||||
Object previous = sink.get(outbox);
|
||||
java.util.function.Function<games.dmg.fantasystick.events.EventInput, java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>> unavailable = event -> {
|
||||
if (!event.type().equals("admin.teleport_resolved")) return writer.emit(event);
|
||||
attempts.add(event);
|
||||
return CompletableFuture.completedFuture(games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED);
|
||||
};
|
||||
var moving = new CompletableFuture<Boolean>(); var nativeCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doReturn(CompletableFuture.completedFuture(f.own(mock(Chunk.class)))).when(actor.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> { nativeCalls.incrementAndGet(); return moving; }).when(actor.player).teleportAsync(any(Location.class));
|
||||
sink.set(outbox, unavailable);
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(actor.player, "fstickadmin adventure-site teleport " + site.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> nativeCalls.get() == 1);
|
||||
f.rejectNextMainTask.set(true);
|
||||
assertTrue(CompletableFuture.supplyAsync(() -> moving.complete(true)).get(5, TimeUnit.SECONDS), "future callback must not touch live native state on its worker");
|
||||
assertFalse(f.rejectNextMainTask.get(), "only the optional feedback task was rejected after native success");
|
||||
ReskillEventsNativeTest.await(f, () -> !attempts.isEmpty());
|
||||
original = attempts.getFirst();
|
||||
assertTrue(java.nio.file.Files.isRegularFile(data.resolve("admin-action-event-intents").resolve(original.id() + ".pending")));
|
||||
assertTrue(rows(data).isEmpty(), "unavailable sink must not be called exported");
|
||||
assertEquals(before, CombatExperienceNativeTest.record(f, actor)); assertArrayEquals(inventory, actor.core.snapshot());
|
||||
actor.core.location.setX(999);
|
||||
f.plugin.setEnabled(false); f.awaitWorkers(); // Retain the durable event, not a teleport retry, across interruption.
|
||||
} finally { f.rejectNextMainTask.set(false); sink.set(outbox, previous); }
|
||||
}
|
||||
assertTrue(rows(data).isEmpty());
|
||||
assertTrue(attempts.stream().allMatch(value -> value.id().equals(original.id()) && value.actionId().equals(original.actionId())
|
||||
&& value.time().equals(original.time()) && value.data().equals(original.data()) && value.context().equals(original.context())), "retry must retain exact original identity and facts");
|
||||
try (var reopened = new QuestTrackingAuditNativeFixture(data)) {
|
||||
ReskillEventsNativeTest.await(reopened.f, () -> {
|
||||
try { return rows(data).size() == 1; } catch (Exception failure) { throw new AssertionError(failure); }
|
||||
});
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(reopened.f);
|
||||
}
|
||||
var recovered = rows(data); assertEquals(1, recovered.size());
|
||||
var row = recovered.getFirst(); var values = row.getAsJsonObject("data");
|
||||
assertEquals(original.id().toString(), row.get("id").getAsString());
|
||||
assertEquals(original.time().toString(), row.get("time").getAsString());
|
||||
assertEquals(original.actionId().toString(), values.get("action_id").getAsString());
|
||||
assertEquals("completed", values.get("outcome").getAsString());
|
||||
assertEquals(12, values.getAsJsonObject("from_location").get("x").getAsDouble(), "offline replay cannot invent the holder's later location");
|
||||
assertEquals(original.context().characterId().toString(), row.get("characterid").getAsString());
|
||||
}
|
||||
|
||||
@Test void repeatedCommandsRemainDistinctAndAnUnrelatedNativeTeleportDuringLoadingKeepsItsGenericOwner() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
f.activate(actor);
|
||||
var site = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP, "Same Camp", actor.world.getUID(), 32, 70, 48).get(5, TimeUnit.SECONDS);
|
||||
var chunk = new CompletableFuture<Chunk>(); var movements = new ArrayList<CompletableFuture<Boolean>>();
|
||||
doReturn(chunk).when(actor.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> {
|
||||
var to = ((Location) call.getArgument(0)).clone();
|
||||
f.dispatcher.accept(new PlayerTeleportEvent(actor.player, actor.core.location.clone(), to, PlayerTeleportEvent.TeleportCause.PLUGIN));
|
||||
var future = new CompletableFuture<Boolean>(); movements.add(future); return future;
|
||||
}).when(actor.player).teleportAsync(any(Location.class));
|
||||
String command = "fstickadmin adventure-site teleport " + site.id();
|
||||
assertTrue(entry.map.dispatch(actor.player, command));
|
||||
var unrelatedTo = actor.core.location.clone(); unrelatedTo.setX(100);
|
||||
f.dispatcher.accept(new PlayerTeleportEvent(actor.player, actor.core.location.clone(), unrelatedTo, PlayerTeleportEvent.TeleportCause.PLUGIN));
|
||||
chunk.complete(f.own(mock(Chunk.class))); ReskillEventsNativeTest.await(f, () -> movements.size() == 1);
|
||||
movements.getFirst().complete(true); assertFalse(movements.getFirst().complete(true));
|
||||
assertTrue(entry.map.dispatch(actor.player, command)); ReskillEventsNativeTest.await(f, () -> movements.size() == 2);
|
||||
movements.getLast().complete(true);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var outcomes = rows(data); assertEquals(2, outcomes.size());
|
||||
assertEquals(2, outcomes.stream().map(row -> row.get("id").getAsString()).distinct().count());
|
||||
assertEquals(2, outcomes.stream().map(row -> row.getAsJsonObject("data").get("action_id").getAsString()).distinct().count());
|
||||
var generic = ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("action.resolved"))
|
||||
.filter(row -> row.getAsJsonObject("data").has("action_kind") && row.getAsJsonObject("data").get("action_kind").getAsString().equals("teleport")).toList();
|
||||
assertEquals(1, generic.size(), "only the unrelated native teleport retains its generic result");
|
||||
assertEquals(100, generic.getFirst().getAsJsonObject("data").getAsJsonObject("to_location").get("x").getAsDouble());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unavailableOptionalNativeWorldNameCannotStopTeleportOrInventLocationEvidence() throws Exception {
|
||||
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
|
||||
var f = fixture.f; var actor = fixture.actor;
|
||||
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
|
||||
f.activate(actor);
|
||||
var site = fixture.directory.placeSite(AdventureQuestState.SiteType.CAMP, "Metadata Camp", actor.world.getUID(), 32, 70, 48).get(5, TimeUnit.SECONDS);
|
||||
var moving = new CompletableFuture<Boolean>(); var nativeCalls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doReturn(CompletableFuture.completedFuture(f.own(mock(Chunk.class)))).when(actor.world).getChunkAtAsync(2, 3);
|
||||
doAnswer(call -> { nativeCalls.incrementAndGet(); return moving; }).when(actor.player).teleportAsync(any(Location.class));
|
||||
doThrow(new IllegalStateException("PRIVATE_WORLD_METADATA")).when(actor.world).getName();
|
||||
try {
|
||||
assertTrue(entry.map.dispatch(actor.player, "fstickadmin adventure-site teleport " + site.id()));
|
||||
ReskillEventsNativeTest.await(f, () -> nativeCalls.get() == 1);
|
||||
moving.complete(true);
|
||||
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
|
||||
var outcomes = rows(data); assertEquals(1, outcomes.size());
|
||||
var values = outcomes.getFirst().getAsJsonObject("data");
|
||||
assertEquals("completed", values.get("outcome").getAsString());
|
||||
assertEquals("unavailable", values.getAsJsonObject("to_location").get("world_name_status").getAsString());
|
||||
assertEquals(32, values.getAsJsonObject("to_location").get("x").getAsDouble());
|
||||
assertFalse(outcomes.toString().contains("PRIVATE_WORLD_METADATA"));
|
||||
} finally { doReturn("Camp Realm").when(actor.world).getName(); }
|
||||
}
|
||||
}
|
||||
|
||||
static List<JsonObject> rows(Path root) throws Exception {
|
||||
return ReskillEventsNativeTest.records(root).stream().filter(row -> row.get("type").getAsString()
|
||||
.equals("games.dmg.minecraft.fantasystick.admin.teleport_resolved")).toList();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user