diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java index 7207d94..023def2 100644 --- a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -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 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.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.completedFuture(null) : closingCharacters.drained(); diff --git a/src/main/java/games/dmg/fantasystick/events/CharacterRecoveryEventBridge.java b/src/main/java/games/dmg/fantasystick/events/CharacterRecoveryEventBridge.java new file mode 100644 index 0000000..ec747c3 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/events/CharacterRecoveryEventBridge.java @@ -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 failures; + + public CharacterRecoveryEventBridge(Function> sink, + Clock clock, Path data, Consumer 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(); + 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 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 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(); + 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(); + 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(); + 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(); + 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 drained() { return outbox.drained(); } + @Override public void close() { outbox.close(); } +} diff --git a/src/main/java/games/dmg/fantasystick/persistence/CharacterRecoveryStore.java b/src/main/java/games/dmg/fantasystick/persistence/CharacterRecoveryStore.java index 43f0337..5884566 100644 --- a/src/main/java/games/dmg/fantasystick/persistence/CharacterRecoveryStore.java +++ b/src/main/java/games/dmg/fantasystick/persistence/CharacterRecoveryStore.java @@ -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 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(); + 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"); } } diff --git a/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java b/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java index 8f9a21e..b6876dc 100644 --- a/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java +++ b/src/main/java/games/dmg/fantasystick/persistence/CharacterStorage.java @@ -27,6 +27,48 @@ public final class CharacterStorage implements AutoCloseable { private final java.util.function.Supplier 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 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 record, UUID operation) { } + public record RecoveryCommit(RecoveryStatus status, Optional record, UUID operation, + games.dmg.fantasystick.events.EventInput committedEvent) { + public RecoveryCommit(RecoveryStatus status, Optional record, UUID operation) { + this(status, record, operation, null); + } + } + + public CompletableFuture 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 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.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 completeRecovery(UUID character, UUID operation) { + return completeRecovery(character, operation, false); + } + + /** Native caller supplies explicit delivery acknowledgment; offline proof alone cannot assert delivery. */ + public CompletableFuture 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; }); } diff --git a/src/main/java/games/dmg/fantasystick/session/AdminCharacterRecovery.java b/src/main/java/games/dmg/fantasystick/session/AdminCharacterRecovery.java index 0040923..3f00b3b 100644 --- a/src/main/java/games/dmg/fantasystick/session/AdminCharacterRecovery.java +++ b/src/main/java/games/dmg/fantasystick/session/AdminCharacterRecovery.java @@ -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> 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); } } diff --git a/src/nativeTest/java/games/dmg/fantasystick/CharacterRecoveryEventsNativeTest.java b/src/nativeTest/java/games/dmg/fantasystick/CharacterRecoveryEventsNativeTest.java new file mode 100644 index 0000000..6aebe0b --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/CharacterRecoveryEventsNativeTest.java @@ -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") + .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(); + 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> + 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(); + 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 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 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 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); + } + } +}