Merge pull request 'feat(recovery): publish saved-character recovery events' (#1) from us-079-admin-recover-saved-character-sheets into main
Release / release (push) Failing after 14m52s

This commit was merged in pull request #1.
This commit is contained in:
dmg committed 2026-10-07 13:44:43 +00:00
commit d2090bd35a
6 files changed
+902 -24

No files matched your search

@@ -66,6 +66,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.party.PartyCombat partyCombat;
private EquipmentAdminCommand admin;
private games.dmg.fantasystick.session.AdminCharacterRecovery characterRecovery;
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge characterRecoveryEvents;
private java.util.concurrent.CompletionStage<Void> characterRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.persistence.PlayerFeedbackPreferences feedbackPreferences;
private games.dmg.fantasystick.session.FantasyFeedbackCommand feedbackCommand;
private games.dmg.fantasystick.session.FantasyParticlesCommand particlesCommand;
@@ -1143,6 +1145,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
admin.attachSavedEvents(savedEvents);
books.attachSavedEvents(savedEvents);
admin.attachAdminAudit(adminActionEvents);
characterRecoveryEvents = new games.dmg.fantasystick.events.CharacterRecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Character recovery event intent failed", failure));
characterRecovery.attachEvents(characterRecoveryEvents);
characters.attachRecoveryEvents(characterRecoveryEvents);
var replayCharacterRecoveryEvents = characterRecoveryEvents;
var replayCharacterRecoveryStorage = characters;
characterRecoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous character recovery audit drain failed", failure);
replayCharacterRecoveryEvents.replay();
replayCharacterRecoveryStorage.replayRecoveryEvents().whenComplete((unused, replayFailure) -> {
if (replayFailure != null) replayCharacterRecoveryEvents.diagnostic(replayFailure);
});
});
eventCapture.excludeOwnedTeleport(adventureQuestCommands::ownsTeleport);
eventCapture.excludeOwnedTeleport(dungeonCommands::ownsTeleport);
adminActionObserver = new games.dmg.fantasystick.events.AdminCommandEntryObserver(adminCommand, adminActionEvents, inspectionEvents, savedEvents);
@@ -1326,6 +1342,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingAdminActionEvents = adminActionEvents; adminActionEvents = null;
var closingAdminActionObserver = adminActionObserver; adminActionObserver = null;
var closingCharacterRecovery = characterRecovery; characterRecovery = null;
var closingCharacterRecoveryEvents = characterRecoveryEvents; characterRecoveryEvents = null;
var closingFeedbackCommand = feedbackCommand; feedbackCommand = null;
var closingParticlesCommand = particlesCommand; particlesCommand = null;
var closingFeedbackPreferences = feedbackPreferences; feedbackPreferences = null;
@@ -2000,6 +2017,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
});
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
if (closingCharacterRecoveryEvents != null) cleanup("character recovery event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
characterRecoveryAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character recovery save drain failed", failure);
closingCharacterRecoveryEvents.close();
return null;
}).thenCompose(ignored -> closingCharacterRecoveryEvents.drained());
});
if (closingAdminRevivalAuditEvents != null) cleanup("admin revival event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
@@ -0,0 +1,176 @@
package games.dmg.fantasystick.events;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.CharacterRecord;
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.Map;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
/** Observation only: frozen recovery evidence never grants native delivery or character authority. */
public final class CharacterRecoveryEventBridge implements AutoCloseable {
public record Capture(UUID operation, UUID command, UUID confirmation, EventInput.Context administrator,
UUID recipient, String recipientName, EventInput.Location recipientLocation,
int destination, Instant confirmedAt) { }
private final Clock clock;
private final FeatureEventOutbox outbox;
private final Consumer<Throwable> failures;
public CharacterRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.clock = clock;
this.failures = failures;
outbox = new FeatureEventOutbox(data, "character-recovery-event-intents", java.util.Set.of(
"character.recovery_committed", "character.recovery_uncertain", "character.recovery_completed"), sink, failures);
}
/** Called on the native thread at confirmation, not from the file worker or on reconnect. */
public Capture capture(UUID operation, AdminActionEventBridge.Action action, UUID confirmation,
Player administrator, Player recipient, int destination) {
var context = action == null ? new EventInput.Context(administrator.getUniqueId(), administrator.getName(),
null, null, null, location(administrator)) : action.context();
return new Capture(operation, action == null ? null : action.id(), confirmation, context,
recipient.getUniqueId(), recipient.getName(), location(recipient), destination, clock.instant());
}
public static EventInput committed(Capture capture, CharacterRecord before, CharacterRecord after) {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", "player");
data.put("operation_id", capture.operation().toString());
if (capture.command() != null) data.put("command_action_id", capture.command().toString());
data.put("confirmation_action_id", capture.confirmation().toString());
data.put("admin_player_id", capture.administrator().playerId().toString());
data.put("admin_player_name", capture.administrator().playerName());
data.put("recipient_player_id", capture.recipient().toString());
data.put("recipient_player_name", capture.recipientName());
data.put("recovered_character_id", before.id().toString());
data.put("recovered_character_name", before.name());
data.put("mode", before.dead() ? "no_tail_revival" : "living_reissue");
data.put("revision_before", before.revision().toString());
data.put("revision_after", after.revision().toString());
data.put("generation_before", before.sheetGeneration().toString());
data.put("generation_after", after.sheetGeneration().toString());
data.put("alive_before", !before.dead()); data.put("alive_after", !after.dead());
data.put("health_before", before.health().toPlainString()); data.put("health_after", after.health().toPlainString());
if (before.dead()) { data.put("death_id", before.deathId().toString()); data.put("death_time", before.diedAt().toString()); }
data.put("destination_slot", capture.destination());
if (capture.recipientLocation() == null) data.put("recipient_location_status", "unavailable");
else data.put("recipient_location", place(capture.recipientLocation()));
if (capture.administrator().location() == null) data.put("location_status", "unavailable");
data.put("outcome", "pending_delivery");
return event(capture.operation(), "character.recovery_committed", capture.confirmedAt(), capture.administrator(), data);
}
public EventInput completed(EventInput committed) {
var data = new LinkedHashMap<>(committed.data());
data.put("outcome", "acknowledged"); data.put("native_delivery", "acknowledged");
data.put("final_revision", data.get("revision_after")); data.put("final_generation", data.get("generation_after"));
data.put("revoked_generation", data.get("generation_before")); data.put("recovery_reconciliation", false);
return event(committed.actionId(), "character.recovery_completed", clock.instant(), committed.context(), data);
}
public EventInput uncertain(EventInput committed, String stage, String reason, boolean nativeWriteMayHaveOccurred) {
var data = new LinkedHashMap<>(committed.data());
data.put("outcome", "uncertain"); data.put("uncertain_stage", stage); data.put("reason", reason);
data.put("native_write_may_have_occurred", nativeWriteMayHaveOccurred);
var id = UUID.nameUUIDFromBytes(("fantasystick:character-recovery:" + committed.actionId()
+ ":character.recovery_uncertain:" + stage).getBytes(StandardCharsets.UTF_8));
return new EventInput(id, committed.actionId(), clock.instant(), "character.recovery_uncertain", committed.context(), data);
}
private static EventInput event(UUID operation, String type, Instant time, EventInput.Context context, Map<String, Object> data) {
var id = UUID.nameUUIDFromBytes(("fantasystick:character-recovery:" + operation + ":" + type).getBytes(StandardCharsets.UTF_8));
return new EventInput(id, operation, time, type, context, data);
}
private EventInput.Location location(Player player) {
try {
var place = player.getLocation(); var world = place.getWorld();
if (world == null) return null;
return new EventInput.Location(world.getUID(), world.getName(), place.getX(), place.getY(), place.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static Map<String, Object> place(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString(), "quality", value.quality());
}
/** Bounded owner receipt codec, with explicit RFC3339/UUID parsing rather than reflective runtime serialization. */
public static String encode(EventInput event) {
var fields = new LinkedHashMap<String, Object>();
fields.put("id", event.id().toString()); fields.put("action", event.actionId().toString());
fields.put("time", event.time().toString()); fields.put("type", event.type()); fields.put("data", event.data());
var context = event.context();
if (context != null) {
var actor = new LinkedHashMap<String, Object>();
actor.put("player", context.playerId().toString()); actor.put("name", context.playerName());
if (context.characterId() != null) {
actor.put("character", context.characterId().toString()); actor.put("character_name", context.characterName());
actor.put("session", context.sessionId().toString());
}
if (context.location() != null) actor.put("location", place(context.location()));
fields.put("context", actor);
}
return new Gson().toJson(fields);
}
public static EventInput decode(String text) {
if (text.length() > 16_384) throw new IllegalArgumentException("Recovery event too large");
var json = JsonParser.parseString(text).getAsJsonObject();
EventInput.Context context = null;
if (json.has("context")) {
var actor = json.getAsJsonObject("context"); EventInput.Location location = null;
if (actor.has("location")) {
var place = actor.getAsJsonObject("location");
location = new EventInput.Location(UUID.fromString(place.get("world_id").getAsString()), place.get("world_name").getAsString(),
place.get("x").getAsDouble(), place.get("y").getAsDouble(), place.get("z").getAsDouble(),
Instant.parse(place.get("captured_at").getAsString()), place.get("quality").getAsString());
}
context = new EventInput.Context(UUID.fromString(actor.get("player").getAsString()), actor.get("name").getAsString(),
actor.has("character") ? UUID.fromString(actor.get("character").getAsString()) : null,
actor.has("character_name") ? actor.get("character_name").getAsString() : null,
actor.has("session") ? UUID.fromString(actor.get("session").getAsString()) : null, location);
}
var data = new LinkedHashMap<String, Object>();
json.getAsJsonObject("data").entrySet().forEach(entry -> data.put(entry.getKey(), value(entry.getValue(), 0)));
return new EventInput(UUID.fromString(json.get("id").getAsString()), UUID.fromString(json.get("action").getAsString()),
Instant.parse(json.get("time").getAsString()), json.get("type").getAsString(), context, data);
}
private static Object value(JsonElement element, int depth) {
if (depth > 8) throw new IllegalArgumentException("Recovery event nesting too deep");
if (element.isJsonNull()) return null;
if (element.isJsonObject()) {
var map = new LinkedHashMap<String, Object>();
element.getAsJsonObject().entrySet().forEach(entry -> map.put(entry.getKey(), value(entry.getValue(), depth + 1)));
return map;
}
if (element.isJsonArray()) return element.getAsJsonArray().asList().stream().map(item -> value(item, depth + 1)).toList();
var primitive = element.getAsJsonPrimitive();
if (primitive.isBoolean()) return primitive.getAsBoolean();
if (primitive.isNumber()) {
String number = primitive.getAsString();
if (number.matches("-?[0-9]+")) return Integer.valueOf(number);
double finite = primitive.getAsDouble();
if (!Double.isFinite(finite)) throw new IllegalArgumentException("Non-finite recovery event value");
return finite;
}
return primitive.getAsString();
}
public void publish(EventInput event) {
try { outbox.accepted(event); }
catch (RuntimeException failure) { diagnostic(failure); }
}
public void diagnostic(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { /* Audit failure cannot authorize or undo recovery. */ }
}
public void replay() { outbox.replay(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
@Override public void close() { outbox.close(); }
}
@@ -23,19 +23,44 @@ final class CharacterRecoveryStore {
enum Phase { PREPARED, COMMITTED, DELIVERED }
record Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase) {
BigInteger replacementGeneration, boolean revive, UUID death, Phase phase,
java.util.List<games.dmg.fantasystick.events.EventInput> events) {
Operation(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration, BigInteger replacementGeneration,
boolean revive, UUID death, Phase phase) {
this(operation, character, administrator, recipient, expectedRevision, expectedGeneration,
replacementGeneration, revive, death, phase, java.util.List.of());
}
Operation {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(replacementGeneration); Objects.requireNonNull(phase);
events = java.util.List.copyOf(events);
if (events.size() > 8 || events.stream().map(games.dmg.fantasystick.events.EventInput::id).distinct().count() != events.size())
throw new IllegalArgumentException("Invalid recovery audit identities");
for (var event : events) {
if (!event.actionId().equals(operation) || !java.util.Set.of("character.recovery_committed",
"character.recovery_uncertain", "character.recovery_completed").contains(event.type())
|| !character.toString().equals(event.data().get("recovered_character_id"))
|| !administrator.toString().equals(event.data().get("admin_player_id"))
|| !recipient.toString().equals(event.data().get("recipient_player_id"))
|| !expectedRevision.toString().equals(event.data().get("revision_before"))
|| !expectedRevision.add(BigInteger.ONE).toString().equals(event.data().get("revision_after"))
|| !expectedGeneration.toString().equals(event.data().get("generation_before"))
|| !replacementGeneration.toString().equals(event.data().get("generation_after"))
|| event.type().equals("character.recovery_completed") && phase != Phase.DELIVERED)
throw new IllegalArgumentException("Recovery audit does not match operation proof");
}
if (!events.isEmpty() && !events.getFirst().type().equals("character.recovery_committed"))
throw new IllegalArgumentException("Missing committed recovery capture");
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| !replacementGeneration.equals(expectedGeneration.add(BigInteger.ONE))
|| revive != (death != null)) throw new IllegalArgumentException("Invalid recovery operation");
}
Operation phase(Phase value) {
return new Operation(operation, character, administrator, recipient, expectedRevision,
expectedGeneration, replacementGeneration, revive, death, value);
expectedGeneration, replacementGeneration, revive, death, value, events);
}
}
@@ -65,16 +90,85 @@ final class CharacterRecoveryStore {
var existing = find(operation.character());
if (existing.isPresent() && existing.orElseThrow().phase() != Phase.DELIVERED)
throw new IOException("Character recovery is unresolved");
// Preserve the previous acknowledged audit before a new operation replaces character custody.
if (existing.isPresent() && !existing.orElseThrow().events().isEmpty()) archive(existing.orElseThrow());
write(operation);
});
}
@FunctionalInterface interface AuditVisitor { void accept(Operation operation, boolean historical) throws IOException; }
/** Stream owner proofs, not a materialized backlog; history is audit-only and never gameplay authority. */
void visitAudits(AuditVisitor visitor) throws IOException {
if (Files.notExists(directory, LinkOption.NOFOLLOW_LINKS)) return;
safeDirectory();
visit(directory, false, visitor);
Path history = directory.resolve("audit-history");
if (Files.exists(history, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(history, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(history))
throw new IOException("Unsafe recovery audit history");
visit(history, true, visitor);
}
}
private void visit(Path folder, boolean historical, AuditVisitor visitor) throws IOException {
try (var files = Files.newDirectoryStream(folder, "*.yml")) {
for (var path : files) {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)
|| Files.size(path) > MAX_FILE_BYTES) throw new IOException("Unsafe recovery audit proof");
var operation = decode(Files.readString(path, StandardCharsets.UTF_8));
String expectedName = (historical ? operation.operation() : operation.character()) + ".yml";
if (!path.getFileName().toString().equals(expectedName) || historical && operation.phase() != Phase.DELIVERED)
throw new IOException("Recovery audit filename or phase conflicts");
if (!operation.events().isEmpty()) visitor.accept(operation, historical);
}
}
}
private void archive(Operation operation) throws IOException {
Path history = directory.resolve("audit-history");
if (Files.exists(history, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(history, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(history))
throw new IOException("Unsafe recovery audit history");
} else { Files.createDirectory(history); DurableFiles.forceDirectory(directory); }
Path destination = history.resolve(operation.operation() + ".yml");
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(destination)
|| Files.size(destination) > MAX_FILE_BYTES || !decode(Files.readString(destination)).equals(operation))
throw new IOException("Recovery audit history conflicts");
return;
}
writeTo(operation, destination);
}
void advance(UUID character, UUID operation, Phase expected, Phase replacement) throws IOException {
advance(character, operation, expected, replacement, null);
}
void advance(UUID character, UUID operation, Phase expected, Phase replacement,
games.dmg.fantasystick.events.EventInput event) throws IOException {
withLock(() -> {
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!current.operation().equals(operation) || current.phase() != expected)
throw new IOException("Recovery operation changed");
write(current.phase(replacement));
var events = new java.util.ArrayList<>(current.events());
if (event != null) events.add(event);
write(new Operation(current.operation(), current.character(), current.administrator(), current.recipient(),
current.expectedRevision(), current.expectedGeneration(), current.replacementGeneration(),
current.revive(), current.death(), replacement, events));
});
}
void retainEvent(UUID character, UUID operation, games.dmg.fantasystick.events.EventInput event) throws IOException {
withLock(() -> {
var current = find(character).orElseThrow(() -> new IOException("Recovery operation missing"));
if (!current.operation().equals(operation) || current.phase() != Phase.COMMITTED)
throw new IOException("Recovery uncertainty conflicts with custody");
if (current.events().stream().anyMatch(saved -> saved.id().equals(event.id()))) return;
var events = new java.util.ArrayList<>(current.events()); events.add(event);
write(new Operation(current.operation(), current.character(), current.administrator(), current.recipient(),
current.expectedRevision(), current.expectedGeneration(), current.replacementGeneration(),
current.revive(), current.death(), current.phase(), events));
});
}
@@ -91,8 +185,13 @@ final class CharacterRecoveryStore {
}
private void write(Operation operation) throws IOException {
writeTo(operation, file(operation.character()));
}
private void writeTo(Operation operation, Path destination) throws IOException {
safeDirectory();
byte[] bytes = encode(operation).getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILE_BYTES) throw new IOException("Recovery proof too large");
Path temporary = Files.createTempFile(directory, ".recovery-", ".tmp");
try {
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
@@ -100,9 +199,9 @@ final class CharacterRecoveryStore {
while (buffer.hasRemaining()) output.write(buffer);
output.force(true);
}
Files.move(temporary, file(operation.character()), StandardCopyOption.ATOMIC_MOVE,
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
DurableFiles.forceDirectory(directory);
DurableFiles.forceDirectory(destination.getParent());
} finally { Files.deleteIfExists(temporary); }
}
@@ -136,7 +235,11 @@ final class CharacterRecoveryStore {
+ "replacement-generation: " + value.replacementGeneration() + "\n"
+ "revive: " + value.revive() + "\n"
+ "death: " + (value.death() == null ? "" : value.death()) + "\n"
+ "phase: " + value.phase() + "\n";
+ "phase: " + value.phase() + "\n"
+ (value.events().isEmpty() ? "" : "events: " + java.util.Base64.getEncoder().encodeToString(
new com.google.gson.Gson().toJson(value.events().stream()
.map(games.dmg.fantasystick.events.CharacterRecoveryEventBridge::encode).toList())
.getBytes(StandardCharsets.UTF_8)) + "\n");
}
private static Operation decode(String text) throws IOException {
@@ -148,6 +251,13 @@ final class CharacterRecoveryStore {
throw new IOException("Invalid recovery record");
}
try {
String audit = values.remove("events");
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
if (audit != null) {
var array = com.google.gson.JsonParser.parseString(new String(java.util.Base64.getDecoder().decode(audit),
StandardCharsets.UTF_8)).getAsJsonArray();
for (var item : array) events.add(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.decode(item.getAsString()));
}
if (!values.keySet().equals(java.util.Set.of("schema-version", "operation", "character",
"administrator", "recipient", "expected-revision", "expected-generation",
"replacement-generation", "revive", "death", "phase"))
@@ -159,7 +269,7 @@ final class CharacterRecoveryStore {
UUID.fromString(values.get("administrator")), UUID.fromString(values.get("recipient")),
new BigInteger(values.get("expected-revision")), new BigInteger(values.get("expected-generation")),
new BigInteger(values.get("replacement-generation")), revive,
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")));
death.isEmpty() ? null : UUID.fromString(death), Phase.valueOf(values.get("phase")), events);
} catch (RuntimeException invalid) { throw new IOException("Invalid recovery record"); }
}
@@ -27,6 +27,48 @@ public final class CharacterStorage implements AutoCloseable {
private final java.util.function.Supplier<games.dmg.fantasystick.progression.CoinRules> coinRules;
private final java.util.random.RandomGenerator coinRandom;
private final CharacterRecoveryStore recoveries;
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge recoveryEvents;
public void attachRecoveryEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
if (worker.isShutdown() || recoveryEvents != null) throw new IllegalStateException("Recovery events already attached or storage closed");
recoveryEvents = Objects.requireNonNull(bridge);
}
private void publishRecovery(CharacterRecoveryStore.Operation operation) {
var bridge = recoveryEvents;
if (bridge != null) for (var event : operation.events()) bridge.publish(event);
}
/** Owner proof closes the mutation-to-outbox gap; replay never invokes native delivery or game mutation. */
public CompletableFuture<Void> replayRecoveryEvents() {
return submit(() -> {
recoveries.visitAudits((operation, historical) -> {
if (historical) { publishRecovery(operation); return; }
var record = repository.load(operation.character()).orElseThrow(() -> new IOException("Recovery audit character missing"));
if (operation.phase() == CharacterRecoveryStore.Phase.PREPARED) {
if (expectedMatches(record, operation)) { recoveries.cancelPrepared(record.id(), operation.operation()); return; }
if (!replacementMatches(record, operation)) throw new IOException("Prepared recovery audit conflicts");
recoveries.advance(record.id(), operation.operation(), CharacterRecoveryStore.Phase.PREPARED, CharacterRecoveryStore.Phase.COMMITTED);
operation = operation.phase(CharacterRecoveryStore.Phase.COMMITTED);
}
if (operation.phase() == CharacterRecoveryStore.Phase.COMMITTED) {
if (!replacementMatches(record, operation)) throw new IOException("Committed recovery audit conflicts");
// Visibility after a failed rename/fsync is not itself a durability acknowledgment.
DurableFiles.forceDirectory(repository.pluginDataDirectory().resolve("characters"));
if (operation.events().size() == 1 && recoveryEvents != null) {
var uncertain = recoveryEvents.uncertain(operation.events().getFirst(), "reconciliation", "unresolved_native_delivery", true);
recoveries.retainEvent(record.id(), operation.operation(), uncertain);
operation = recoveries.find(record.id()).orElseThrow();
}
} else if (!record.sheetGeneration().equals(operation.replacementGeneration())
|| record.revision().compareTo(operation.expectedRevision().add(BigInteger.ONE)) < 0) {
throw new IOException("Delivered recovery audit conflicts");
}
publishRecovery(operation);
});
return null;
});
}
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
private final ExecutorService worker = Executors.newSingleThreadExecutor(
task -> new Thread(task, "fantasy-character-persistence"));
@@ -326,18 +368,36 @@ public final class CharacterStorage implements AutoCloseable {
public enum RecoveryStatus { APPLIED, UNAVAILABLE, STALE, BLOCKED }
public record RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
boolean revive, UUID death, java.math.BigDecimal nativeLimit,
games.dmg.fantasystick.events.CharacterRecoveryEventBridge.Capture capture) {
public RecoveryRequest(UUID operation, UUID character, UUID administrator, UUID recipient,
BigInteger expectedRevision, BigInteger expectedGeneration,
boolean revive, UUID death, java.math.BigDecimal nativeLimit) {
this(operation, character, administrator, recipient, expectedRevision, expectedGeneration, revive, death, nativeLimit, null);
}
public RecoveryRequest {
Objects.requireNonNull(operation); Objects.requireNonNull(character);
Objects.requireNonNull(administrator); Objects.requireNonNull(recipient);
Objects.requireNonNull(expectedRevision); Objects.requireNonNull(expectedGeneration);
Objects.requireNonNull(nativeLimit);
if (capture != null && (!operation.equals(capture.operation()) || !recipient.equals(capture.recipient())
|| !administrator.equals(capture.administrator().playerId())))
throw new IllegalArgumentException("Recovery capture identity mismatch");
if (expectedRevision.signum() < 0 || expectedGeneration.signum() < 0
|| nativeLimit.signum() <= 0 || revive != (death != null))
throw new IllegalArgumentException("Invalid character recovery request");
}
}
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) { }
public record RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation,
games.dmg.fantasystick.events.EventInput committedEvent) {
public RecoveryCommit(RecoveryStatus status, Optional<CharacterRecord> record, UUID operation) {
this(status, record, operation, null);
}
}
public CompletableFuture<Void> retainRecoveryEvent(UUID character, UUID operation, games.dmg.fantasystick.events.EventInput event) {
return submit(() -> { recoveries.retainEvent(character, operation, event); return null; });
}
/** Durable generation/revival commit; native delivery happens only after APPLIED acknowledgment. */
public CompletableFuture<RecoveryCommit> beginRecovery(RecoveryRequest request) {
@@ -358,29 +418,51 @@ public final class CharacterStorage implements AutoCloseable {
if (existing.isPresent() && existing.orElseThrow().phase() != CharacterRecoveryStore.Phase.DELIVERED)
return new RecoveryCommit(RecoveryStatus.BLOCKED, Optional.of(old), request.operation());
BigInteger nextGeneration = old.sheetGeneration().add(BigInteger.ONE);
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED);
recoveries.prepare(operation);
var health = request.revive() ? old.maximumHealth().min(request.nativeLimit()) : old.health();
var replacement = new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.passives(),
old.cooldowns(), old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(),
old.coinsInitialized(), old.rewardReceipts(), request.revive() ? BigInteger.ZERO : old.pvpCoinEscrow(),
old.gear(), nextGeneration);
var audit = request.capture() == null ? java.util.List.<games.dmg.fantasystick.events.EventInput>of()
: java.util.List.of(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.committed(request.capture(), old, replacement));
var operation = new CharacterRecoveryStore.Operation(request.operation(), old.id(),
request.administrator(), request.recipient(), old.revision(), old.sheetGeneration(),
nextGeneration, request.revive(), request.death(), CharacterRecoveryStore.Phase.PREPARED, audit);
recoveries.prepare(operation);
try { repository.replaceForRecovery(replacement, old.revision()); }
catch (IOException failure) {
try { recoveries.cancelPrepared(old.id(), request.operation()); }
catch (IOException retained) { failure.addSuppressed(retained); }
// A mover/fsync can fail after replacement. Cancel only when unchanged authoritative
// state proves no commit; missing/conflicting/read-failed proof stays fail-closed.
try {
var current = repository.load(old.id()).orElseThrow(() -> new IOException("Recovery state unavailable after failed replacement"));
if (expectedMatches(current, operation)) recoveries.cancelPrepared(old.id(), request.operation());
else if (replacementMatches(current, operation)) {
DurableFiles.forceDirectory(repository.pluginDataDirectory().resolve("characters"));
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED, CharacterRecoveryStore.Phase.COMMITTED);
if (!audit.isEmpty() && recoveryEvents != null) {
var uncertain = recoveryEvents.uncertain(audit.getFirst(), "commit_acknowledgment", "commit_unacknowledged", false);
recoveries.retainEvent(old.id(), request.operation(), uncertain);
publishRecovery(recoveries.find(old.id()).orElseThrow());
}
}
} catch (IOException retained) { failure.addSuppressed(retained); }
throw failure;
}
recoveries.advance(old.id(), request.operation(), CharacterRecoveryStore.Phase.PREPARED,
CharacterRecoveryStore.Phase.COMMITTED);
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation());
publishRecovery(operation.phase(CharacterRecoveryStore.Phase.COMMITTED));
return new RecoveryCommit(RecoveryStatus.APPLIED, Optional.of(replacement), request.operation(),
audit.isEmpty() ? null : audit.getFirst());
});
}
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation) {
return completeRecovery(character, operation, false);
}
/** Native caller supplies explicit delivery acknowledgment; offline proof alone cannot assert delivery. */
public CompletableFuture<Boolean> completeRecovery(UUID character, UUID operation, boolean nativeDelivered) {
Objects.requireNonNull(character); Objects.requireNonNull(operation);
return submit(() -> {
var record = repository.load(character).orElseThrow(() -> new IOException("Recovered character missing"));
@@ -388,8 +470,11 @@ public final class CharacterStorage implements AutoCloseable {
if (!proof.operation().equals(operation) || proof.phase() != CharacterRecoveryStore.Phase.COMMITTED
|| !replacementMatches(record, proof))
throw new IOException("Recovery completion proof conflicts with saved character");
var completed = nativeDelivered && recoveryEvents != null && !proof.events().isEmpty()
? recoveryEvents.completed(proof.events().getFirst()) : null;
recoveries.advance(character, operation, CharacterRecoveryStore.Phase.COMMITTED,
CharacterRecoveryStore.Phase.DELIVERED);
CharacterRecoveryStore.Phase.DELIVERED, completed);
if (completed != null) recoveryEvents.publish(completed);
return true;
});
}
@@ -42,6 +42,12 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
private final CharacterSheets sheets = new CharacterSheets();
private volatile boolean closed;
private AdminActionEventBridge adminAudit;
private games.dmg.fantasystick.events.CharacterRecoveryEventBridge events;
public void attachEvents(games.dmg.fantasystick.events.CharacterRecoveryEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Recovery events already attached or closed");
events = Objects.requireNonNull(bridge);
}
public void attachAdminAudit(AdminActionEventBridge audit) {
if (closed || adminAudit != null) throw new IllegalStateException("Character recovery audit already attached");
@@ -183,12 +189,13 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
boolean revive = slot == 6;
if (!(slot == 2 || slot == 6) || revive != view.record().dead()) return;
if (!views.remove(administrator.getUniqueId(), view)) return;
if (adminAudit != null) adminAudit.confirmation(view.action(), current(administrator), "inventory_confirmation", null);
var confirmation = UUID.randomUUID();
if (adminAudit != null) adminAudit.confirmation(view.action(), current(administrator), "inventory_confirmation", confirmation);
administrator.closeInventory();
confirm(view, revive);
confirm(view, revive, confirmation);
}
private void confirm(View view, boolean revive) {
private void confirm(View view, boolean revive, UUID confirmation) {
Player administrator = view.administrator(); Player recipient = view.recipient();
UUID recipientId = recipient.getUniqueId();
if (!current(administrator) || server.getPlayer(recipient.getUniqueId()) != recipient
@@ -204,6 +211,9 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
return;
}
var claim = custody.orElseThrow();
UUID operation = UUID.randomUUID();
var capture = events == null ? null : events.capture(operation, view.action(), confirmation,
administrator, recipient, view.destination());
java.util.concurrent.CompletionStage<java.util.Optional<EquipmentSessionStore.State>> receiptRead;
try { receiptRead = receipts.find(recipientId); }
catch (RuntimeException rejected) {
@@ -237,17 +247,27 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
administrator.sendMessage("Character recovery confirmation became stale; no state changed.");
return;
}
UUID operation = UUID.randomUUID();
var request = new CharacterStorage.RecoveryRequest(operation, view.record().id(),
administrator.getUniqueId(), recipientId, view.record().revision(),
view.record().sheetGeneration(), revive, revive ? view.record().deathId() : null,
NativeHealthLimit.read());
NativeHealthLimit.read(), capture);
characters.beginRecovery(request).whenComplete((commit, commitFailure) -> schedule(
view.record().id(), recipientId, () -> deliver(view, claim, operation, commit, commitFailure)));
}));
}));
}
private void uncertain(CharacterStorage.RecoveryCommit commit, String stage, String reason, boolean mayHaveWritten) {
if (events == null || commit.committedEvent() == null) return;
var event = events.uncertain(commit.committedEvent(), stage, reason, mayHaveWritten);
// A failed custody write cannot suppress truthful native uncertainty. The writer owns the
// transient observation handoff; the original committed capture remains in the owner receipt.
events.publish(event);
var owner = events;
characters.retainRecoveryEvent(commit.record().orElseThrow().id(), commit.operation(), event)
.whenComplete((ignored, failure) -> { if (failure != null) owner.diagnostic(failure); });
}
private void deliver(View view, EquipmentSessions.CharacterRecoveryClaim claim, UUID operation,
CharacterStorage.RecoveryCommit commit, Throwable failure) {
if (failure != null) {
@@ -268,6 +288,7 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
Player recipient = view.recipient();
if (!current(view.administrator()) || server.getPlayer(recipient.getUniqueId()) != recipient || !recipient.isOnline()
|| !empty(recipient, view.destination())) {
uncertain(commit, "delivery_revalidation", "stale_destination_or_lifetime", false);
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
Map.of("domain_operation_id", commit.operation()));
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
@@ -279,6 +300,7 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
throw new IllegalStateException("Native inventory did not retain the recovered sheet");
} catch (RuntimeException uncertain) {
report(record.id(), recipient.getUniqueId(), "native-delivery", uncertain);
uncertain(commit, "native_delivery", "native_write_uncertain", true);
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
Map.of("domain_operation_id", commit.operation()));
view.administrator().sendMessage("Character recovery committed, but native delivery is uncertain; do not retry blindly.");
@@ -287,10 +309,11 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
try { recipient.updateInventory(); }
catch (RuntimeException syncFailure) { report(record.id(), recipient.getUniqueId(), "inventory-sync", syncFailure); }
UUID recipientId = recipient.getUniqueId();
characters.completeRecovery(record.id(), commit.operation()).whenComplete((completed, completionFailure) ->
characters.completeRecovery(record.id(), commit.operation(), true).whenComplete((completed, completionFailure) ->
schedule(record.id(), recipientId, () -> {
if (completionFailure != null || !completed) {
if (completionFailure != null) report(record.id(), recipient.getUniqueId(), "completion", completionFailure);
uncertain(commit, "completion", "completion_unacknowledged", true);
outcome(view.action(), AdminActionEventBridge.Outcome.UNCERTAIN, "operation_uncertain",
Map.of("domain_operation_id", commit.operation()));
view.administrator().sendMessage("Recovery sheet was delivered, but durable completion is retained for inspection.");
@@ -324,7 +347,8 @@ public final class AdminCharacterRecovery implements Listener, AutoCloseable {
private void schedule(UUID character, UUID recipient, Runnable task) {
if (closed) return;
try { nextTick.accept(() -> { if (!closed) task.run(); }); }
var invoked = new java.util.concurrent.atomic.AtomicBoolean();
try { nextTick.accept(() -> { if (!closed && invoked.compareAndSet(false, true)) task.run(); }); }
catch (RuntimeException rejected) { report(character, recipient, "schedule", rejected); }
}
@@ -0,0 +1,457 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Registered command -> exact native confirmation -> authoritative recovery -> published JSONL. */
class CharacterRecoveryEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void livingRecoveryExportsCommittedAndCompletedWithFrozenIdentityAndRevokedGeneration() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
fixture.f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var before = storage(fixture.f).renameCharacter(source.id(), "Ω \"Knight\"\n<saved>")
.get(5, TimeUnit.SECONDS).orElseThrow();
assertTrue(entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient"));
drain(fixture.f, recipient.core.id);
assertFalse(fixture.actor.inventories.isEmpty());
var confirmationClick = click(fixture.f, fixture.actor, fixture.actor.inventories.getLast(), 2);
drain(fixture.f, recipient.core.id);
fixture.f.dispatcher.accept(confirmationClick);
drain(fixture.f, recipient.core.id);
var after = storage(fixture.f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertEquals(before.sheetGeneration().add(java.math.BigInteger.ONE), after.sheetGeneration());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = recoveryRows(data);
assertEquals(List.of("character.recovery_committed", "character.recovery_completed"), rows.stream()
.map(row -> row.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", "")).toList());
assertEquals(1, rows.stream().map(row -> row.getAsJsonObject("data").get("operation_id").getAsString()).distinct().count());
for (var row : rows) {
var values = row.getAsJsonObject("data");
assertEquals("living_reissue", values.get("mode").getAsString());
assertEquals(before.id().toString(), values.get("recovered_character_id").getAsString());
assertEquals(before.name(), values.get("recovered_character_name").getAsString());
assertEquals(recipient.core.id.toString(), values.get("recipient_player_id").getAsString());
assertEquals(fixture.actor.core.id.toString(), values.get("admin_player_id").getAsString());
assertEquals(before.sheetGeneration().toString(), values.get("generation_before").getAsString());
assertEquals(after.sheetGeneration().toString(), values.get("generation_after").getAsString());
assertEquals(before.revision().toString(), values.get("revision_before").getAsString());
assertEquals(after.revision().toString(), values.get("revision_after").getAsString());
assertNotEquals(values.get("command_action_id").getAsString(), values.get("confirmation_action_id").getAsString());
assertTrue(values.has("recipient_location"));
assertTrue(values.has("location"));
assertFalse(row.toString().contains("fstickadmin character recover"));
}
assertEquals("pending_delivery", rows.getFirst().getAsJsonObject("data").get("outcome").getAsString());
assertEquals("acknowledged", rows.getLast().getAsJsonObject("data").get("outcome").getAsString());
var domain = rows.getFirst().getAsJsonObject("data");
var actions = AdminActionEntryNativeTest.rows(data);
assertTrue(actions.stream().anyMatch(row -> "request".equals(row.getAsJsonObject("data").get("stage").getAsString())
&& domain.get("command_action_id").equals(row.getAsJsonObject("data").get("action_id"))));
assertTrue(actions.stream().anyMatch(row -> "confirmation".equals(row.getAsJsonObject("data").get("stage").getAsString())
&& domain.get("confirmation_action_id").equals(row.getAsJsonObject("data").get("causation_id"))));
}
}
@Test void noTailRevivalExportsExactDeathAndPostDefeatHealthWithoutReplayingConsequences() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
fixture.f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var before = storage(fixture.f).recordDeath(source.id(), true).get(5, TimeUnit.SECONDS).orElseThrow();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient");
drain(fixture.f, recipient.core.id);
click(fixture.f, fixture.actor, fixture.actor.inventories.getLast(), 6);
drain(fixture.f, recipient.core.id);
var after = storage(fixture.f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow();
assertFalse(after.dead()); assertEquals(after.maximumHealth(), after.health()); assertEquals(before.coins(), after.coins());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertEquals(2, recoveryRows(data).size());
for (var row : recoveryRows(data)) {
var values = row.getAsJsonObject("data");
assertEquals("no_tail_revival", values.get("mode").getAsString());
assertEquals(before.deathId().toString(), values.get("death_id").getAsString());
assertEquals(before.diedAt().toString(), values.get("death_time").getAsString());
assertFalse(values.get("alive_before").getAsBoolean()); assertTrue(values.get("alive_after").getAsBoolean());
assertEquals(after.health().toPlainString(), values.get("health_after").getAsString());
}
assertTrue(storage(fixture.f).restoreDeathRecordAdministratively(before.id(), before.deathId(), after.maximumHealth())
.get(5, TimeUnit.SECONDS).isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"stale", "failed_commit"})
void preCommitRefusalsAndFailedWritesNeverPublishRecoverySuccess(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
fixture.f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var before = storage(fixture.f).inspect(source.id()).get(5, TimeUnit.SECONDS).orElseThrow();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient");
drain(fixture.f, recipient.core.id);
Path lock = data.resolve("character-recoveries/.recoveries.lock");
if (failure.equals("stale")) storage(fixture.f).renameCharacter(before.id(), "After preview").get(5, TimeUnit.SECONDS);
else { java.nio.file.Files.createDirectories(lock.getParent()); java.nio.file.Files.createDirectory(lock); }
try {
click(fixture.f, fixture.actor, fixture.actor.inventories.getLast(), 2);
drain(fixture.f, recipient.core.id);
assertEquals(before.sheetGeneration(), storage(fixture.f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow().sheetGeneration());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertTrue(recoveryRows(data).isEmpty());
assertTrue(AdminActionEntryNativeTest.rows(data).stream().anyMatch(row -> row.getAsJsonObject("data").get("stage")
.getAsString().equals("result")));
} finally { if (failure.equals("failed_commit")) java.nio.file.Files.delete(lock); }
}
}
@ParameterizedTest @ValueSource(strings = {"destination", "native_write", "completion"})
void uncertainNativeOrCompletionFailureIsTruthfulAndCannotBlindlyDeliverAgain(String failure) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var before = storage(f).inspect(source.id()).get(5, TimeUnit.SECONDS).orElseThrow();
int destination = recipient.core.inventory.firstEmpty();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient");
drain(f, recipient.core.id); click(f, fixture.actor, fixture.actor.inventories.getLast(), 2);
nextTask(f, recipient.core.id).run(); nextTask(f, recipient.core.id).run();
storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var delivery = nextTask(f, recipient.core.id);
Path lock = data.resolve("character-recoveries/.recoveries.lock");
if (failure.equals("destination")) recipient.core.inventory.setItem(destination, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
else if (failure.equals("completion")) { java.nio.file.Files.delete(lock); java.nio.file.Files.createDirectory(lock); }
else {
var proxy = (org.bukkit.inventory.PlayerInventory) java.lang.reflect.Proxy.newProxyInstance(
org.bukkit.inventory.PlayerInventory.class.getClassLoader(), new Class<?>[]{org.bukkit.inventory.PlayerInventory.class},
(ignored, method, args) -> {
try {
var result = method.invoke(recipient.core.inventory, args);
if (method.getName().equals("setItem")) throw new IllegalStateException("test-only failure after native sheet installation");
return result;
} catch (java.lang.reflect.InvocationTargetException cause) { throw cause.getCause(); }
});
when(recipient.player.getInventory()).thenReturn(proxy);
}
try {
delivery.run(); drain(f, recipient.core.id);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var rows = recoveryRows(data);
assertEquals(List.of("character.recovery_committed", "character.recovery_uncertain"), rows.stream()
.map(row -> row.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", "")).toList());
var uncertain = rows.getLast().getAsJsonObject("data");
assertEquals("uncertain", uncertain.get("outcome").getAsString());
assertEquals(!failure.equals("destination"), uncertain.get("native_write_may_have_occurred").getAsBoolean());
assertEquals(destination, uncertain.get("destination_slot").getAsInt());
assertThrows(java.util.concurrent.CompletionException.class, () -> storage(f).find(before.id()).join());
if (failure.equals("completion")) java.nio.file.Files.delete(lock);
var nativeBefore = recipient.core.snapshot();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient");
drain(f, recipient.core.id);
assertArrayEquals(nativeBefore, recipient.core.snapshot());
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(2, recoveryRows(data).size(), "refused retry must not claim another domain outcome");
} finally {
if (java.nio.file.Files.isDirectory(lock)) java.nio.file.Files.delete(lock);
when(recipient.player.getInventory()).thenReturn(recipient.core.inventory);
}
}
}
@Test void restartReplaysRejectedEventsWithIdenticalIdsPayloadAndNoNativeOrCharacterReplay() throws Exception {
var received = new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
UUID character; CharacterRecord after;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var bridge = FantasyStickLifecycleNativeTest.Fixture.field(fixture.f.plugin, "characterRecoveryEvents");
var outbox = FantasyStickLifecycleNativeTest.Fixture.field(bridge, "outbox");
var sink = outbox.getClass().getDeclaredField("sink"); sink.setAccessible(true);
java.util.function.Function<games.dmg.fantasystick.events.EventInput, java.util.concurrent.CompletionStage<games.dmg.fantasystick.events.LocalEventLog.Delivery>>
reject = event -> { received.add(event); return java.util.concurrent.CompletableFuture.completedFuture(
games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED); };
sink.set(outbox, reject);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
fixture.f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data); character = source.id();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(fixture.f, recipient.core.id); click(fixture.f, fixture.actor, fixture.actor.inventories.getLast(), 2);
drain(fixture.f, recipient.core.id);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertEquals(2, received.size()); assertTrue(recoveryRows(data).isEmpty());
after = storage(fixture.f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow();
}
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
ReskillEventsNativeTest.await(fixture.f, () -> { try { return recoveryRows(data).size() >= 2; }
catch (Exception failure) { throw new AssertionError(failure); } });
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = recoveryRows(data);
assertEquals(2, rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
for (var input : received) {
var row = rows.stream().filter(value -> value.get("id").getAsString().equals(input.id().toString())).findFirst().orElseThrow();
assertEquals(input.time().toString(), row.get("time").getAsString());
var frozen = com.google.gson.JsonParser.parseString(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.encode(input)).getAsJsonObject().getAsJsonObject("data");
for (var field : frozen.entrySet()) assertEquals(field.getValue(), row.getAsJsonObject("data").get(field.getKey()), field.getKey());
}
assertEquals(after, storage(fixture.f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow());
}
}
@ParameterizedTest @ValueSource(strings = {"completed", "uncertain"})
void ownerReceiptClosesHandoffGapAndSurvivesLaterReissueWithoutAuthorizingNativeReplay(String outcome) throws Exception {
var originals = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
UUID character; CharacterRecord finalState;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var bridge = (games.dmg.fantasystick.events.CharacterRecoveryEventBridge)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characterRecoveryEvents");
bridge.close(); bridge.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data); character = source.id();
int destination = recipient.core.inventory.firstEmpty();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(f, recipient.core.id); click(f, fixture.actor, fixture.actor.inventories.getLast(), 2);
if (outcome.equals("uncertain")) {
nextTask(f, recipient.core.id).run(); nextTask(f, recipient.core.id).run();
var delivery = nextTask(f, recipient.core.id);
recipient.core.inventory.setItem(destination, new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIAMOND));
delivery.run();
}
drain(f, recipient.core.id);
originals.addAll(receiptEvents(data, character));
assertEquals(2, originals.size());
if (outcome.equals("completed")) {
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(f, recipient.core.id); click(f, fixture.actor, fixture.actor.inventories.getLast(), 2);
drain(f, recipient.core.id); originals.addAll(receiptEvents(data, character));
assertEquals(4, originals.size());
}
assertTrue(recoveryRows(data).isEmpty(), "no audit worker accepted the handoff");
finalState = storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow();
}
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
ReskillEventsNativeTest.await(fixture.f, () -> { try { return recoveryRows(data).size() >= originals.size(); }
catch (Exception failure) { throw new AssertionError(failure); } });
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = recoveryRows(data);
assertEquals(originals.size(), rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
for (var original : originals) assertFrozen(original, rows);
assertEquals(finalState, storage(fixture.f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow());
if (outcome.equals("uncertain")) {
assertTrue(rows.stream().noneMatch(row -> row.get("type").getAsString().endsWith("recovery_completed")));
assertThrows(java.util.concurrent.CompletionException.class, () -> storage(fixture.f).find(character).join());
}
}
}
@ParameterizedTest @ValueSource(strings = {"before_move", "after_move"})
void fileMoveFailureOnlyEmitsCommitWhenAuthoritativeReplacementIsProvedAndNeverReleasesAmbiguousCustody(String moment) throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var before = storage(f).inspect(source.id()).get(5, TimeUnit.SECONDS).orElseThrow();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + before.id() + " Recipient");
drain(f, recipient.core.id);
var repository = FantasyStickLifecycleNativeTest.Fixture.field(storage(f), "repository");
var mover = repository.getClass().getDeclaredField("replacement"); mover.setAccessible(true);
var original = mover.get(repository);
var failing = java.lang.reflect.Proxy.newProxyInstance(mover.getType().getClassLoader(), new Class<?>[]{mover.getType()},
(ignored, method, args) -> {
assertEquals("move", method.getName());
if (moment.equals("after_move")) java.nio.file.Files.move((Path) args[0], (Path) args[1],
java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
throw new java.io.IOException("test-only recovery move failure " + moment);
});
mover.set(repository, failing);
try { click(f, fixture.actor, fixture.actor.inventories.getLast(), 2); drain(f, recipient.core.id); }
finally { mover.set(repository, original); }
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
var saved = storage(f).inspect(before.id()).get(5, TimeUnit.SECONDS).orElseThrow();
if (moment.equals("before_move")) { assertEquals(before, saved); assertTrue(recoveryRows(data).isEmpty()); }
else {
assertEquals(before.sheetGeneration().add(java.math.BigInteger.ONE), saved.sheetGeneration());
var rows = recoveryRows(data);
assertEquals(List.of("character.recovery_committed", "character.recovery_uncertain"), rows.stream()
.map(row -> row.get("type").getAsString().replace("games.dmg.minecraft.fantasystick.", "")).toList());
assertEquals("commit_acknowledgment", rows.getLast().getAsJsonObject("data").get("uncertain_stage").getAsString());
assertFalse(rows.getLast().getAsJsonObject("data").get("native_write_may_have_occurred").getAsBoolean());
assertThrows(java.util.concurrent.CompletionException.class, () -> storage(f).find(before.id()).join());
}
}
}
@ParameterizedTest @ValueSource(strings = {"committed", "prepared"})
void startupReconcilesOnlyProvedCommitAndRetainsUncertaintyWithoutDeliveryOrUnlock(String phase) throws Exception {
UUID character; CharacterRecord committed; games.dmg.fantasystick.events.EventInput original;
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data); character = source.id();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(f, recipient.core.id); click(f, fixture.actor, fixture.actor.inventories.getLast(), 2);
nextTask(f, recipient.core.id).run(); nextTask(f, recipient.core.id).run();
// Simulated lost callback after real generation mutation, before any native sheet write.
nextTask(f, recipient.core.id);
committed = storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow();
original = receiptEvents(data, character).getFirst();
if (phase.equals("prepared")) {
var path = data.resolve("character-recoveries").resolve(character + ".yml");
java.nio.file.Files.writeString(path, java.nio.file.Files.readString(path).replace("phase: COMMITTED", "phase: PREPARED"));
}
}
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
ReskillEventsNativeTest.await(fixture.f, () -> { try { return recoveryRows(data).stream()
.anyMatch(row -> row.get("type").getAsString().endsWith("recovery_uncertain")); }
catch (Exception failure) { throw new AssertionError(failure); } });
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
var rows = recoveryRows(data); assertFrozen(original, rows);
var uncertain = rows.stream().filter(row -> row.get("type").getAsString().endsWith("recovery_uncertain")).findFirst().orElseThrow();
assertEquals("reconciliation", uncertain.getAsJsonObject("data").get("uncertain_stage").getAsString());
assertTrue(uncertain.getAsJsonObject("data").get("native_write_may_have_occurred").getAsBoolean());
assertTrue(rows.stream().noneMatch(row -> row.get("type").getAsString().endsWith("recovery_completed")));
assertEquals(committed, storage(fixture.f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow());
assertThrows(java.util.concurrent.CompletionException.class, () -> storage(fixture.f).find(character).join());
var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
fixture.f.players.put(recipient.core.id, recipient.player);
var inventory = recipient.core.snapshot();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(fixture.f, recipient.core.id);
// A read-only preview is not recovery authority. Exercise the explicit confirmation,
// where authoritative unresolved custody must refuse the actual generation/delivery mutation.
assertEquals(1, fixture.actor.inventories.size());
click(fixture.f, fixture.actor, fixture.actor.inventories.getLast(), 2);
drain(fixture.f, recipient.core.id);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(fixture.f);
assertArrayEquals(inventory, recipient.core.snapshot());
assertEquals(committed, storage(fixture.f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow());
assertTrue(recoveryRows(data).stream().noneMatch(row -> row.get("type").getAsString().endsWith("recovery_completed")));
}
}
@Test void repeatedDeferredDeliveryCallbackCannotReclassifyAcknowledgedRecoveryOrWriteAnotherSheet() throws Exception {
try (var fixture = new QuestTrackingAuditNativeFixture(data)) {
var f = fixture.f; var entry = new AdminActionEntryNativeTest.Entry(fixture, "player", true);
var recipient = new FantasyStickLifecycleNativeTest.Actor(data); recipient.accountName = "Recipient";
f.players.put(recipient.core.id, recipient.player);
var source = sourceRecord(data);
var character = source.id();
entry.map.dispatch(entry.sender, "fstickadmin character recover " + character + " Recipient");
drain(f, recipient.core.id); click(f, fixture.actor, fixture.actor.inventories.getLast(), 2);
nextTask(f, recipient.core.id).run(); nextTask(f, recipient.core.id).run();
var delivery = nextTask(f, recipient.core.id); delivery.run(); drain(f, recipient.core.id);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertEquals(2, recoveryRows(data).size());
var saved = storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow();
var inventory = recipient.core.snapshot();
delivery.run(); drain(f, recipient.core.id);
FixtureAuditAcknowledgmentNativeTest.awaitOwnQuiescence(f);
assertArrayEquals(inventory, recipient.core.snapshot());
assertEquals(saved, storage(f).inspect(character).get(5, TimeUnit.SECONDS).orElseThrow());
assertEquals(2, recoveryRows(data).size(), "one callback lifetime must not produce a false uncertain stage after success");
}
}
static List<games.dmg.fantasystick.events.EventInput> receiptEvents(Path data, UUID character) throws Exception {
var line = java.nio.file.Files.readAllLines(data.resolve("character-recoveries").resolve(character + ".yml"))
.stream().filter(value -> value.startsWith("events: ")).findFirst().orElseThrow().substring(8);
var json = new String(java.util.Base64.getDecoder().decode(line), java.nio.charset.StandardCharsets.UTF_8);
return com.google.gson.JsonParser.parseString(json).getAsJsonArray().asList().stream()
.map(value -> games.dmg.fantasystick.events.CharacterRecoveryEventBridge.decode(value.getAsString())).toList();
}
static void assertFrozen(games.dmg.fantasystick.events.EventInput input, List<JsonObject> rows) {
for (var row : rows.stream().filter(value -> value.get("id").getAsString().equals(input.id().toString())).toList()) {
assertEquals(input.time().toString(), row.get("time").getAsString());
var context = input.context();
assertEquals(context.playerId().toString(), row.get("playerid").getAsString());
if (context.characterId() != null) {
assertEquals(context.characterId().toString(), row.get("characterid").getAsString());
assertEquals(context.sessionId().toString(), row.getAsJsonObject("data").get("session_id").getAsString());
}
var encoded = com.google.gson.JsonParser.parseString(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.encode(input))
.getAsJsonObject();
if (context.location() != null) assertEquals(encoded.getAsJsonObject("context").getAsJsonObject("location"),
row.getAsJsonObject("data").getAsJsonObject("location"));
var frozen = com.google.gson.JsonParser.parseString(games.dmg.fantasystick.events.CharacterRecoveryEventBridge.encode(input))
.getAsJsonObject().getAsJsonObject("data");
for (var field : frozen.entrySet()) assertEquals(field.getValue(), row.getAsJsonObject("data").get(field.getKey()), field.getKey());
}
assertTrue(rows.stream().anyMatch(value -> value.get("id").getAsString().equals(input.id().toString())));
}
static Runnable nextTask(FantasyStickLifecycleNativeTest.Fixture f, UUID recipient) throws Exception {
for (int pass = 0; pass < 100; pass++) {
storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
f.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
((games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"))
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
var callback = f.tasks.poll(); if (callback != null) return callback;
Thread.sleep(5);
}
throw new AssertionError("Expected deferred recovery callback");
}
/** Real inactive saved subject: no native actor/mocks are needed or left outside fixture ownership. */
static CharacterRecord sourceRecord(Path data) throws Exception {
var record = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).create(record);
return record;
}
static List<JsonObject> recoveryRows(Path data) throws Exception {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
.startsWith("games.dmg.minecraft.fantasystick.character.recovery_")).toList();
}
static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
static InventoryClickEvent click(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor admin,
org.bukkit.inventory.Inventory inventory, int slot) {
var view = f.own(mock(org.bukkit.inventory.InventoryView.class));
when(view.getTopInventory()).thenReturn(inventory); when(view.getBottomInventory()).thenReturn(admin.core.inventory);
when(view.getPlayer()).thenReturn(admin.player);
when(view.getType()).thenReturn(InventoryType.CHEST);
var event = new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, slot,
org.bukkit.event.inventory.ClickType.LEFT, InventoryAction.PICKUP_ALL);
f.dispatcher.accept(event); assertTrue(event.isCancelled()); return event;
}
static void drain(FantasyStickLifecycleNativeTest.Fixture f, UUID recipient) throws Exception {
for (int pass = 0; pass < 40; pass++) {
storage(f).inspect(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
f.receipts().find(recipient).toCompletableFuture().get(5, TimeUnit.SECONDS);
((games.dmg.fantasystick.persistence.RevivalStore) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "revivals"))
.find(UUID.randomUUID()).get(5, TimeUnit.SECONDS);
Runnable callback; boolean ran = false;
while ((callback = f.tasks.poll()) != null) { callback.run(); ran = true; }
if (!ran) Thread.sleep(5);
}
}
}