feat(recovery): publish saved-character recovery events
This commit is contained in:
1 parent
441037f5ff
commit
c45d05fd0f
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;
|
||||
@@ -1139,6 +1141,20 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
input -> { inspectionLog.emit(input); }, Clock.systemUTC());
|
||||
books.attachInspectionEvents(inspectionEvents);
|
||||
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);
|
||||
@@ -1322,6 +1338,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;
|
||||
@@ -1996,6 +2013,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user