feat(recovery): audit session and configuration outcomes
Release / release (push) Successful in 15m50s
Release / release (push) Successful in 15m50s
This commit is contained in:
1 parent
9737ed0ddb
commit
39881ad6fc
8 files changed
+944
-29
No files matched your search
@@ -183,6 +183,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -769,6 +771,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
|
||||
sessions.attachEquipmentEvents(equipmentAuditEvents);
|
||||
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
|
||||
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
sessions.attachDownedEvents(downedEvents);
|
||||
downedController.attachEvents(downedEvents);
|
||||
revive.attachDownedEvents(downedEvents);
|
||||
@@ -778,7 +785,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
|
||||
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; messageAuditEvents = null;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -874,6 +881,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
|
||||
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
|
||||
});
|
||||
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
|
||||
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
@@ -915,6 +927,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
@@ -1237,6 +1250,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
|
||||
});
|
||||
if (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
|
||||
closingSessionRecoveryAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
|
||||
@@ -80,14 +80,16 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
fields.setProperty("time", event.time().toString());
|
||||
fields.setProperty("type", event.type());
|
||||
var context = event.context();
|
||||
fields.setProperty("player", context.playerId().toString());
|
||||
fields.setProperty("player_name", context.playerName());
|
||||
if (context.characterId() != null) {
|
||||
if (context != null) {
|
||||
fields.setProperty("player", context.playerId().toString());
|
||||
fields.setProperty("player_name", context.playerName());
|
||||
}
|
||||
if (context != null && context.characterId() != null) {
|
||||
fields.setProperty("character", context.characterId().toString());
|
||||
fields.setProperty("character_name", context.characterName());
|
||||
fields.setProperty("session", context.sessionId().toString());
|
||||
}
|
||||
if (context.location() != null) {
|
||||
if (context != null && context.location() != null) {
|
||||
var location = context.location();
|
||||
fields.setProperty("world", location.worldId().toString());
|
||||
fields.setProperty("world_name", location.worldName());
|
||||
@@ -101,6 +103,8 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
|
||||
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
|
||||
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
|
||||
else if (value instanceof Double number && Double.isFinite(number))
|
||||
fields.setProperty("data." + key, "d" + number);
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty("data." + key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
@@ -143,9 +147,13 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
|
||||
fields.getProperty("quality")) : null;
|
||||
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
|
||||
var context = new EventInput.Context(UUID.fromString(fields.getProperty("player")),
|
||||
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
|
||||
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location);
|
||||
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
|
||||
throw new IOException("Unbound event context");
|
||||
var context = fields.containsKey("player")
|
||||
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
|
||||
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
|
||||
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
|
||||
: null;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
|
||||
var value = fields.getProperty(key);
|
||||
@@ -157,6 +165,11 @@ final class FeatureEventOutbox implements AutoCloseable {
|
||||
yield Boolean.valueOf(value.substring(1));
|
||||
}
|
||||
case 'i' -> Integer.valueOf(value.substring(1));
|
||||
case 'd' -> {
|
||||
var number = Double.valueOf(value.substring(1));
|
||||
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
|
||||
yield number;
|
||||
}
|
||||
case 'l' -> {
|
||||
var encoded = value.substring(1);
|
||||
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureSettings;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured administrative/session recovery observations; never authorizes native recovery. */
|
||||
public final class SessionRecoveryEventBridge implements AutoCloseable {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
|
||||
String locationStatus, UUID target) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final Clock clock;
|
||||
|
||||
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
|
||||
Set.of("recovery.resolved", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
|
||||
public Action capture(CommandSender sender, UUID target) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
if (sender instanceof Player player) {
|
||||
var location = player.getLocation();
|
||||
EventInput.Location origin = location == null || location.getWorld() == null ? null
|
||||
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), now, "exact");
|
||||
return new Action(UUID.randomUUID(), now,
|
||||
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
|
||||
"player", origin == null ? "unavailable" : null, target);
|
||||
}
|
||||
return new Action(UUID.randomUUID(), now, null,
|
||||
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
|
||||
"not_applicable", target);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void observed(Action action, String kind) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", data);
|
||||
}
|
||||
|
||||
public void resolved(Action action, String kind, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
if (outcome.equals("refused")) data.remove("target_player_id");
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
|
||||
}
|
||||
|
||||
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
|
||||
String nativePhase, boolean online) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", "equipment_inspect");
|
||||
data.put("outcome", "authorized");
|
||||
var saved = state.orElse(null);
|
||||
var receipt = saved == null ? null : saved.pending();
|
||||
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
|
||||
if (saved != null) data.put("receipt_revision", saved.revision().toString());
|
||||
if (receipt != null) {
|
||||
data.put("session_id", receipt.sessionId().toString());
|
||||
data.put("character_id", receipt.characterId().toString());
|
||||
}
|
||||
data.put("native_phase", online ? nativePhase : "offline_unavailable");
|
||||
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
|
||||
emit(action, "recovery.inspected", "inspected", data);
|
||||
}
|
||||
|
||||
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
|
||||
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
|
||||
String currentPhase, String outcome, String quality, String reason,
|
||||
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
|
||||
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("recovery_operation_id", action.id().toString());
|
||||
if (session != null) data.put("session_id", session.toString());
|
||||
if (character != null) data.put("character_id", character.toString());
|
||||
data.put("prior_phase", priorPhase);
|
||||
data.put("current_phase", currentPhase);
|
||||
data.put("custody_quality", quality);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
|
||||
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
|
||||
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
|
||||
nativeLocation(data, "original", original);
|
||||
nativeLocation(data, "current", current);
|
||||
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
|
||||
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
|
||||
? "native_equipment_unacknowledged" : "not_independently_verified");
|
||||
emit(action, "recovery.resolved", "reconciled", data);
|
||||
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
|
||||
}
|
||||
|
||||
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
|
||||
public EventInput.Location captureLocation(Location value) {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
|
||||
if (location == null) {
|
||||
data.put(name + "_location_status", "unavailable");
|
||||
return;
|
||||
}
|
||||
data.put(name + "_world_id", location.worldId().toString());
|
||||
data.put(name + "_world_name", location.worldName());
|
||||
data.put(name + "_x", location.x());
|
||||
data.put(name + "_y", location.y());
|
||||
data.put(name + "_z", location.z());
|
||||
data.put(name + "_captured_at", location.capturedAt().toString());
|
||||
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
|
||||
}
|
||||
|
||||
public Action system(Player player) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var location = player.getLocation();
|
||||
var action = new Action(UUID.randomUUID(), now, null, "system",
|
||||
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
|
||||
player.getUniqueId());
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void configRead(Action action, boolean effective, Optional<String> revision) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
|
||||
else data.put("persistence_revision_status", "not_verified_at_read");
|
||||
data.put("outcome", "authorized");
|
||||
emit(action, "config.read", "read", data);
|
||||
}
|
||||
|
||||
public void configChanged(Action action, AdventureSettings.Saved saved) {
|
||||
if (action == null || saved == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("before_value", Boolean.toString(saved.beforeValue()));
|
||||
data.put("after_value", Boolean.toString(saved.value()));
|
||||
data.put("effective_value", Boolean.toString(saved.value()));
|
||||
data.put("persistence_revision", saved.revision());
|
||||
data.put("application", "future_sessions");
|
||||
data.put("outcome", "acknowledged");
|
||||
emit(action, "config.changed", "changed", data);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Action action) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", action.actorKind());
|
||||
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
|
||||
if (action.target() != null) data.put("target_player_id", action.target().toString());
|
||||
return data;
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
|
||||
try {
|
||||
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
|
||||
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
|
||||
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
|
||||
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
|
||||
else sink.apply(event);
|
||||
}
|
||||
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private volatile String revision;
|
||||
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
|
||||
public record Saved(boolean beforeValue, boolean value, String revision) { }
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
public CompletionStage<Boolean> set(boolean value) {
|
||||
return setRecorded(value).thenApply(Saved::value);
|
||||
}
|
||||
|
||||
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
|
||||
public CompletionStage<Saved> setRecorded(boolean value) {
|
||||
return setRecorded(value, ignored -> { });
|
||||
}
|
||||
|
||||
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
|
||||
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
|
||||
Objects.requireNonNull(onSaved);
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
try {
|
||||
boolean before = enabled;
|
||||
var revision = save(value);
|
||||
enabled = value;
|
||||
this.revision = revision;
|
||||
var saved = new Saved(before, value, revision);
|
||||
try { onSaved.accept(saved); }
|
||||
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
|
||||
return saved;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
private String save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
String revision;
|
||||
try {
|
||||
revision = "sha256:" + java.util.HexFormat.of().formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (java.security.NoSuchAlgorithmException missing) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", missing);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
return revision;
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
|
||||
private games.dmg.fantasystick.persistence.CharacterStorage characters;
|
||||
private AdminCharacterRecovery characterRecovery;
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -412,6 +413,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events) {
|
||||
if (closed || sessionRecoveryEvents != null) throw new IllegalStateException("Session recovery events already attached or command closed");
|
||||
sessionRecoveryEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
|
||||
|
||||
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
|
||||
@@ -441,6 +447,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
if (sessionRecoveryEvents != null && args.length > 0
|
||||
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
var action = sessionRecoveryEvents.capture(sender, null);
|
||||
var kind = args[0].equals("inspect") ? "equipment_inspect" : "equipment_recovery";
|
||||
sessionRecoveryEvents.observed(action, kind);
|
||||
sessionRecoveryEvents.resolved(action, kind, "refused", "unauthorized");
|
||||
}
|
||||
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
@@ -497,24 +510,41 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
if (args[0].equals("recover")) {
|
||||
var request = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(request, "equipment_recovery");
|
||||
var player = server.getPlayer(owner);
|
||||
if (player == null || !player.isOnline()) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(request,
|
||||
"equipment_recovery", "refused", "target_offline");
|
||||
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
|
||||
return true;
|
||||
}
|
||||
sessions.recover(player);
|
||||
sessions.recover(player, request);
|
||||
sender.sendMessage("Validated equipment recovery requested for " + owner
|
||||
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
|
||||
return true;
|
||||
}
|
||||
var inspection = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(inspection, "equipment_inspect");
|
||||
receipts.find(owner).whenComplete((state, failure) -> {
|
||||
if (failure != null) report(owner, "inspect", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return;
|
||||
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (closed) return;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "refused", "permission_changed");
|
||||
return;
|
||||
}
|
||||
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "cancelled", "sender_offline");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "failed", "receipt_unavailable");
|
||||
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
|
||||
return;
|
||||
}
|
||||
@@ -530,9 +560,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
|
||||
}
|
||||
var player = server.getPlayer(owner);
|
||||
var marker = player == null || !player.isOnline()
|
||||
? java.util.Optional.<EquipmentSessionMarker.Token>empty() : EquipmentSessionMarker.read(player);
|
||||
if (sessionRecoveryEvents != null) {
|
||||
sessionRecoveryEvents.inspected(inspection, state,
|
||||
marker.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT))
|
||||
.orElse(player == null || !player.isOnline() ? "offline_unavailable" :
|
||||
EquipmentSessionMarker.present(player) ? "malformed" : "absent"),
|
||||
player != null && player.isOnline());
|
||||
sessionRecoveryEvents.resolved(inspection, "equipment_inspect", "accepted", "read_completed");
|
||||
}
|
||||
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
|
||||
else {
|
||||
var marker = EquipmentSessionMarker.read(player);
|
||||
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
|
||||
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
|
||||
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
|
||||
@@ -1686,22 +1725,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(action, "adventure_mode_config");
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
|
||||
"adventure_mode_config", "refused", "invalid_value");
|
||||
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (settings == null) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
|
||||
"adventure_mode_config", "failed", "settings_unavailable");
|
||||
sender.sendMessage("Adventure configuration is unavailable."); return;
|
||||
}
|
||||
if (args.length == 2) {
|
||||
if (sessionRecoveryEvents != null) {
|
||||
sessionRecoveryEvents.configRead(action, settings.enabled(), settings.revision());
|
||||
sessionRecoveryEvents.resolved(action, "adventure_mode_config", "accepted", "read_completed");
|
||||
}
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
settings.setRecorded(Boolean.parseBoolean(args[2]), saved -> {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.configChanged(action, saved);
|
||||
}).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action, "adventure_mode_config",
|
||||
failure == null ? "accepted" : "failed", failure == null ? "save_acknowledged" : "save_failed");
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
if (closed) return;
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
|
||||
? "Saved adventure-mode=" + saved.value() + " for new sessions only; active sessions are unchanged."
|
||||
: "Adventure configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
|
||||
@@ -51,6 +51,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
|
||||
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
|
||||
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
|
||||
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
|
||||
@@ -128,9 +129,13 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Capture restoreEvent;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action restoreAction;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action recoveryAction;
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action sessionRecoveryAction;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryOriginalLocation;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryCurrentLocation;
|
||||
String recoveryPriorPhase = "unavailable";
|
||||
java.math.BigInteger recoveryReceiptRevision;
|
||||
boolean recoveryResolved;
|
||||
boolean sessionRecoveryResolved;
|
||||
boolean restoreEventWritten;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
@@ -228,6 +233,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events){if(!owned.isEmpty()||sessionRecoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or sessions started");sessionRecoveryEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
|
||||
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
|
||||
|
||||
@@ -1078,7 +1084,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
|
||||
captureRecoveryLocations(attempt);
|
||||
persistExit(attempt, false);
|
||||
diagnostic(attempt, failure);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
|
||||
@@ -1088,6 +1098,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (equipmentEvents != null) {
|
||||
@@ -1099,6 +1110,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"retained", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else {
|
||||
if ((!modeRestored || !vitalsRestored) && equipmentEvents != null) {
|
||||
@@ -1187,11 +1200,33 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void recoveryIncomplete(Attempt attempt, String reason) {
|
||||
if (equipmentEvents == null || attempt.recoveryAction == null || attempt.recoveryResolved) return;
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
|
||||
"ambiguous", reason, attempt.recoveryReceiptRevision);
|
||||
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
|
||||
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
|
||||
"ambiguous", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "ambiguous", "native_restored_receipt_pending",
|
||||
reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
|
||||
private void captureRecoveryLocations(Attempt attempt) {
|
||||
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null) return;
|
||||
attempt.recoveryOriginalLocation = sessionRecoveryEvents.captureLocation(attempt.entryLocation);
|
||||
attempt.recoveryCurrentLocation = sessionRecoveryEvents.captureLocation(attempt.player.getLocation());
|
||||
}
|
||||
|
||||
private void sessionReconciled(Attempt attempt, String phase, String outcome, String quality,
|
||||
String reason, java.math.BigInteger revision) {
|
||||
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null || attempt.sessionRecoveryResolved) return;
|
||||
attempt.sessionRecoveryResolved = true;
|
||||
try {
|
||||
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
|
||||
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
|
||||
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
private boolean restoreVitals(Attempt attempt) {
|
||||
@@ -1220,6 +1255,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) {
|
||||
sessionReconciled(attempt,
|
||||
attempt.nativeRestored ? "restored" : "pending",
|
||||
attempt.nativeRestored ? "ambiguous" : "failed",
|
||||
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
|
||||
"receipt_save_failed", attempt.recoveryReceiptRevision);
|
||||
diagnostic(attempt, failure);
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
@@ -1240,6 +1280,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
"restored", "receipt_completed", state.revision());
|
||||
}
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"receipt_completed", state.revision());
|
||||
if (!attempt.switchEventWritten && switchEvents != null) {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
@@ -1357,16 +1400,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
|
||||
public synchronized void recover(Player player) { recover(player, false); }
|
||||
public synchronized void recover(Player player) { recover(player, false, null); }
|
||||
|
||||
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
recover(player, false, action);
|
||||
}
|
||||
|
||||
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
|
||||
public synchronized void arrive(Player player) {
|
||||
var directory=downedDirectory;
|
||||
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
|
||||
recover(player,true);
|
||||
recover(player,true,null);
|
||||
}
|
||||
|
||||
private void recover(Player player, boolean arrival) {
|
||||
private void recover(Player player, boolean arrival,
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
UUID owner = player.getUniqueId();
|
||||
invalidateCursor(owner);
|
||||
if (closed || !player.isOnline()) return;
|
||||
@@ -1385,6 +1433,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
cancelEntry(owner);
|
||||
var attempt = new Attempt(player, owner, null);
|
||||
attempt.recoveryOwner = previous;
|
||||
attempt.sessionRecoveryAction = action;
|
||||
// A recovered receipt has no process-local never-packed proof.
|
||||
attempt.effectsStarted = true;
|
||||
attempt.arrival = arrival;
|
||||
@@ -1401,7 +1450,16 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { diagnostic(attempt, failure); return; }
|
||||
if (failure != null) {
|
||||
if (sessionRecoveryEvents != null) {
|
||||
var action = attempt.sessionRecoveryAction == null
|
||||
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
|
||||
sessionRecoveryEvents.reconciled(action, null, null, "unavailable", "unavailable",
|
||||
"failed", "receipt_unreadable", "receipt_read_failed", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
diagnostic(attempt, failure); return;
|
||||
}
|
||||
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
|
||||
if (receipt == null) {
|
||||
if (attempt.arrival) {
|
||||
@@ -1414,11 +1472,20 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(
|
||||
equipmentEvents.action(attempt.player, null, null, "equipment_recovery"), "refused", "no_receipt");
|
||||
if (sessionRecoveryEvents != null) {
|
||||
var action = attempt.sessionRecoveryAction == null
|
||||
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
|
||||
sessionRecoveryEvents.reconciled(action, null, null, "absent", "absent", "refused",
|
||||
"receipt_missing", "no_receipt", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.recoveryReceiptRevision = state.orElseThrow().revision();
|
||||
if (sessionRecoveryEvents != null && attempt.sessionRecoveryAction == null)
|
||||
attempt.sessionRecoveryAction = sessionRecoveryEvents.system(attempt.player);
|
||||
if (equipmentEvents != null) attempt.recoveryAction = equipmentEvents.action(
|
||||
attempt.player, attempt.session, attempt.character,
|
||||
attempt.arrival ? "equipment_arrival_recovery" : "equipment_recovery");
|
||||
@@ -1438,6 +1505,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
// do not replace it with a recovery Attempt that could release its claim early.
|
||||
previous.deliveryInvalidated = true;
|
||||
previous.recoveryAction = attempt.recoveryAction;
|
||||
previous.sessionRecoveryAction = attempt.sessionRecoveryAction;
|
||||
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
|
||||
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
@@ -1457,6 +1525,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
captureRecoveryLocations(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
@@ -1476,6 +1545,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.recoveryPriorPhase, "pending", "receipt_and_marker_only", "retained",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), state.orElseThrow().revision());
|
||||
}
|
||||
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
@@ -1485,6 +1556,9 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
fail(attempt, error);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
|
||||
} finally {
|
||||
|
||||
@@ -1584,6 +1584,8 @@ class FantasyStickLifecycleNativeTest {
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "equipmentAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "sessionRecoveryAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) {
|
||||
throw new IOException("Switch event intent worker did not drain before fixture disposal", failure);
|
||||
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered admin/lifecycle outcome -> local JSONL, not isolated bridge invocation. */
|
||||
class SessionRecoveryEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("pending session=")));
|
||||
awaitType(fixture, "recovery.inspected");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entry = records().stream().filter(value -> value.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")).findFirst().orElseThrow();
|
||||
var fields = entry.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), fields.get("target_player_id").getAsString());
|
||||
assertEquals("pending", fields.get("receipt_phase").getAsString());
|
||||
assertEquals("authorized", fields.get("outcome").getAsString());
|
||||
assertFalse(fields.toString().contains("DIAMOND_SWORD"));
|
||||
assertFalse(fields.toString().contains("item_meta"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAdventureSaveRetainsEffectivePolicyAndEmitsNoChange() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var collision = data.resolve("config.yml");
|
||||
Files.createDirectory(collision);
|
||||
Files.writeString(collision.resolve("block"), "not a config");
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.getLast().contains("save failed"));
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(administrator.messages.getLast().contains("adventure-mode=true"));
|
||||
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")); i++)
|
||||
Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("config.changed")));
|
||||
Files.delete(collision.resolve("block"));
|
||||
Files.delete(collision);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedAdventureChangeKeepsStableEventIdAcrossWriterOutageAndRestart() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pending;
|
||||
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
|
||||
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pending.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType(fixture, "config.changed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var saved = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.changed")).toList();
|
||||
assertEquals(pending, saved.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitRecoveryWithoutReceiptIsRefusedWithoutInventingSession() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
actor.core.inventory.setItemInOffHand(null);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("refused", result.get("outcome").getAsString());
|
||||
assertEquals("no_receipt", result.get("reason").getAsString());
|
||||
assertFalse(result.has("character_id"));
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRecoveryReceiptCompletionRemainsAmbiguousWithoutSuccessEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("receipt-backup.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("ambiguous", result.get("outcome").getAsString());
|
||||
assertEquals("receipt_save_failed", result.get("reason").getAsString());
|
||||
assertEquals("restored", result.get("current_phase").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unreadableReceiptRefusesRegisteredRecoveryWithoutTouchingNativeItems() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
var nativeBefore = actor.core.snapshot();
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("saved-receipt.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
assertArrayEquals(nativeBefore, actor.core.snapshot());
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("failed", result.get("outcome").getAsString());
|
||||
assertEquals("receipt_read_failed", result.get("reason").getAsString());
|
||||
assertFalse(result.has("session_id"));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ambiguousCopiedBoxThroughRegisteredRecoveryRetainsReceiptAndItems() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("retained", result.get("outcome").getAsString());
|
||||
assertEquals("ambiguous", result.get("reason").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainNativeRecoveryReportsAmbiguousWithoutInventingEquipmentSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Uncertain cursor write"); };
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
actor.core.beforeCursorMutation = () -> {};
|
||||
fixture.plugin.setEnabled(false);
|
||||
var results = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("ambiguous", results.getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals("native_exception", results.getFirst().getAsJsonObject("data").get("reason").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCommandRecoveryReplaysStableResultAfterClosedWriter() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pending;
|
||||
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
|
||||
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pending.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var outcome = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(pending, outcome.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(1, outcome.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartedAutomaticReconciliationRetainsUnverifiedHealthAndOriginalLocationStatus() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
actor.core.inventory.setItem(0, null);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var results = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, results.size());
|
||||
var value = results.getFirst().getAsJsonObject("data");
|
||||
assertEquals("ambiguous", value.get("outcome").getAsString());
|
||||
assertEquals("health_unverified", value.get("reason").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertEquals("restored", value.get("current_phase").getAsString());
|
||||
assertEquals("captured_current_only", value.get("location_status").getAsString());
|
||||
assertEquals("unavailable", value.get("original_location_status").getAsString());
|
||||
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredProofBasedRecoveryCorrelatesEquipmentWithoutAnotherSessionExit() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
var originalAttempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
sessions, "owned")).get(actor.core.id);
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryAction"));
|
||||
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryResolved"),
|
||||
"Completed receipt should trigger one session recovery outcome");
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var recovered = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, recovered.size());
|
||||
var value = recovered.getFirst().getAsJsonObject("data");
|
||||
assertEquals("restored", value.get("outcome").getAsString());
|
||||
assertEquals(session.toString(), value.get("session_id").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertTrue(value.has("equipment_recovery_operation_id"));
|
||||
assertEquals("captured_at_entry", value.get("original_location_status").getAsString());
|
||||
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
|
||||
assertTrue(value.has("original_x"));
|
||||
assertTrue(value.has("current_x"));
|
||||
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.exited")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAdventureReadAndAcknowledgedChangeHaveSafeRevisionAndNoInvalidChange() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "wrong"});
|
||||
awaitType(fixture, "config.changed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var read = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.read") && entry.getAsJsonObject("data").has("key")
|
||||
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
|
||||
var changed = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.changed") && entry.getAsJsonObject("data").has("key")
|
||||
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
|
||||
assertEquals(1, read.size());
|
||||
assertEquals("true", read.getFirst().getAsJsonObject("data").get("effective_value").getAsString());
|
||||
assertEquals(1, changed.size(), "Invalid input must not produce an acknowledged config change");
|
||||
var fields = changed.getFirst().getAsJsonObject("data");
|
||||
assertEquals("true", fields.get("before_value").getAsString());
|
||||
assertEquals("false", fields.get("after_value").getAsString());
|
||||
assertTrue(fields.get("persistence_revision").getAsString().matches("sha256:[0-9a-f]{64}"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inspectionRevokedBeforeReadResponseDoesNotPublishProtectedTarget() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
administrator.allowed = false;
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.isEmpty());
|
||||
for (int i = 0; i < 200 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("permission_changed")); i++)
|
||||
Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("reason") && value.get("reason").getAsString()
|
||||
.equals("permission_changed") && !value.has("target_player_id")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authorizedOfflineInspectionReportsOnlySavedReceiptAndUnknownNativeLocation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var target = java.util.UUID.randomUUID();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", target.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.inspected");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(target.toString(), result.get("target_player_id").getAsString());
|
||||
assertEquals("absent", result.get("receipt_phase").getAsString());
|
||||
assertEquals("offline_unavailable", result.get("native_phase").getAsString());
|
||||
assertEquals("receipt_only", result.get("custody_quality").getAsString());
|
||||
assertEquals("not_applicable", result.get("location_status").getAsString());
|
||||
assertFalse(result.has("session_id"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredPermissionGateCannotExposeProtectedInspectionOrTarget() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var denied = new EquipmentAdminCommandNativeTest.Sender();
|
||||
denied.allowed = false;
|
||||
fixture.adminCommand.execute(denied.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
assertTrue(denied.messages.getLast().toLowerCase(java.util.Locale.ROOT).contains("permission"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.noneMatch(fields -> fields.has("action_kind") && fields.get("action_kind").getAsString()
|
||||
.equals("equipment_inspect") && fields.has("target_player_id")));
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitRefusedExitReady(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var owned = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned");
|
||||
var pending = owned.get(actor.core.id);
|
||||
assertNotNull(pending);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
if (!(boolean) FantasyStickLifecycleNativeTest.Fixture.field(pending, "saving")) return;
|
||||
fixture.tick();
|
||||
}
|
||||
fail("Refused exit persistence callback did not settle before recovery request");
|
||||
}
|
||||
|
||||
private void awaitType(FantasyStickLifecycleNativeTest.Fixture fixture, String type) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
if (records().stream().anyMatch(entry -> entry.get("type").getAsString().endsWith(type))) return;
|
||||
}
|
||||
fail("Registered recovery outcome did not reach JSONL: " + type + "; observed types="
|
||||
+ records().stream().map(entry -> entry.get("type").getAsString()).toList());
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
Path directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return result;
|
||||
try (var files = Files.walk(directory)) {
|
||||
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user