feat(characters): audit craft rename and observed transfer
Release / release (push) Successful in 14m59s

This commit is contained in:
dmg committed 2026-09-25 12:07:48 -04:00
1 parent 84205c0c14
commit 53366b3c50
10 files changed
+928 -11

No files matched your search

@@ -179,6 +179,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -753,6 +755,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
sessions.attachSwitchEvents(switchAuditEvents);
var identitySessions = sessions;
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
crafting.attachIdentityEvents(identityAuditEvents);
anvilNames.attachIdentityEvents(identityAuditEvents);
sessions.attachIdentityEvents(identityAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
@@ -762,7 +772,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
} catch (java.io.IOException failure) {
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; messageAuditEvents = null;
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; messageAuditEvents = null;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -848,6 +858,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
if (replaySwitchEvents != null) replaySwitchEvents.replay();
});
var replayIdentityEvents = identityAuditEvents;
identityAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
if (replayIdentityEvents != null) replayIdentityEvents.replay();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
@@ -887,6 +902,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
@@ -1245,6 +1261,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 (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
var charactersDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
identityAuditDrain = charactersDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
closingIdentityAuditEvents.close();
return null;
}).thenCompose(ignored -> closingIdentityAuditEvents.drained());
});
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
// Never await IO on the server thread or close receipts ahead of accepted preparations.
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
@@ -17,6 +17,12 @@ public final class CharacterAnvilNames implements Listener {
private final Consumer<Failure> failures;
private final org.bukkit.inventory.ItemFactory items;
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null) throw new IllegalStateException("Anvil identity events already attached");
identityEvents = java.util.Objects.requireNonNull(events);
}
/** Diagnostics may run on the persistence worker and must not access native player state. */
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
@@ -44,9 +50,19 @@ public final class CharacterAnvilNames implements Listener {
if (name.equals(name(input))) return;
var player = event.getPlayer().getUniqueId();
var character = identity.orElseThrow().character();
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
try { acknowledged.accept(renamed.orElseThrow()); }
var audit = identityEvents;
var action = audit == null ? null : audit.rename(event.getPlayer(), character, name);
storage.renameCharacterObserved(character, name, identity.orElseThrow().generation()).whenComplete((outcome, failure) -> {
if (failure != null || outcome.record().isEmpty()) {
failures.accept(new Failure(player, character, failure));
if (audit != null) audit.resolved(action, "refused", failure == null ? outcome.status() : "save_failed");
return;
}
if (audit != null) {
if ("renamed".equals(outcome.status())) audit.renamed(action, outcome.before().orElseThrow(), outcome.record().orElseThrow());
audit.resolved(action, "renamed".equals(outcome.status()) ? "accepted" : "refused", outcome.status());
}
try { acknowledged.accept(outcome.record().orElseThrow()); }
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
});
}
@@ -34,6 +34,14 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
private final Consumer<Failure> failures;
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
private volatile boolean closed;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private final java.util.Set<CraftItemEvent> handledCrafts =
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
if (identityEvents != null || !pending.isEmpty()) throw new IllegalStateException("Craft identity events already attached or attempts started");
identityEvents = java.util.Objects.requireNonNull(events);
}
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
@@ -50,22 +58,30 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
public void onCraft(CraftItemEvent event) {
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
handledCrafts.add(event); // MONITOR can distinguish our containment from an existing veto.
java.util.Optional<ManualCraftPlan> plan;
try {
plan = ManualCraftPlan.capture(event);
} catch (RuntimeException failure) {
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
refuseCraft(event, "failed", "plan");
return;
} finally {
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
event.setCancelled(true);
}
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|| !plan.orElseThrow().matches(player.getOpenInventory())) {
refuseCraft(event, "refused", "invalid_or_stale_preflight");
return;
}
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
var audit = identityEvents;
if (audit != null) attempt.craft = audit.craft(player, attempt.plan.route().name().toLowerCase(java.util.Locale.ROOT), attempt.plan.quantity());
try {
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
attempt.records = records;
attempt.characters = records.stream().map(CharacterRecord::id).toList();
attempt.preparation = storage.prepareCraft(records);
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
@@ -73,9 +89,24 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
pending.remove(attempt.id, attempt);
abandon(attempt);
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "prepare", 0);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onCraftDecision(CraftItemEvent event) {
if (!(event.getRecipe() instanceof Keyed keyed) || !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
if (handledCrafts.remove(event) || !event.isCancelled()) return;
refuseCraft(event, "cancelled", "prior_cancellation");
}
private void refuseCraft(CraftItemEvent event, String outcome, String reason) {
var audit = identityEvents;
if (audit == null || !(event.getWhoClicked() instanceof Player player)) return;
var action = audit.craft(player, "unknown", 0);
audit.resolved(action, outcome, reason, 0);
}
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
if (closed || pending.get(attempt.id) != attempt) {
abandon(attempt);
@@ -98,22 +129,45 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
}
if (!delivering.add(attempt.id)) return;
try {
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
else if (attempt.player.isOnline()) {
var audit = identityEvents;
if (failure != null) {
failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
if (audit != null) audit.resolved(attempt.craft, "failed", "save", 0);
} else if (attempt.player.isOnline()) {
attempt.handoffStarted = true;
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
if (audit != null) {
for (int index = 0; index < result.completed(); index++) audit.created(attempt.craft, attempt.records.get(index), index);
var outcome = switch (result.status()) {
case COMPLETE -> "accepted";
case CANCELLED -> "cancelled";
case STALE -> "stale";
case UNCONFIRMED -> "uncertain";
case UNAVAILABLE, UNSUPPORTED_ROUTE -> "refused";
};
audit.resolved(attempt.craft, outcome,
"drop_" + result.status().name().toLowerCase(java.util.Locale.ROOT), result.completed());
}
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
}
} else {
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
if (audit != null) {
if (attempt.handoffStarted) for (int index = 0; index < attempt.records.size(); index++)
audit.created(attempt.craft, attempt.records.get(index), index);
audit.resolved(attempt.craft, attempt.handoffStarted ? "accepted"
: result == ManualCraftDelivery.Result.STALE ? "stale" : "refused",
result.name().toLowerCase(java.util.Locale.ROOT), attempt.handoffStarted ? attempt.records.size() : 0);
}
}
}
} catch (RuntimeException unexpected) {
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
if (identityEvents != null) identityEvents.resolved(attempt.craft, "uncertain", "delivery", 0);
} finally {
pending.remove(attempt.id, attempt);
abandon(attempt);
@@ -132,6 +186,8 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
final Player player;
final ManualCraftPlan plan;
List<UUID> characters = List.of();
List<CharacterRecord> records = List.of();
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Craft craft;
CharacterStorage.CraftPreparation preparation;
volatile boolean handoffStarted;
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
@@ -0,0 +1,192 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.nio.file.Path;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Captures actual identity-creating and identity-changing native actions, never previews. */
public final class CharacterIdentityEventBridge implements AutoCloseable {
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final CharacterIdentityEventOutbox outbox;
private final Clock clock;
private final Function<UUID, Optional<UUID>> activeSession;
private static final int MAX_RECENT_HOLDERS = 8192;
private final Map<UUID, Holder> lastObserved = new LinkedHashMap<>(16, .75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Holder> eldest) {
return size() > MAX_RECENT_HOLDERS; // Eviction is uncertainty, never an invented transfer.
}
};
private record Holder(UUID player, UUID session, EventInput.Location entryLocation) { }
public CharacterIdentityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path dataFolder, Function<UUID, Optional<UUID>> activeSession,
Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.activeSession = Objects.requireNonNull(activeSession);
this.outbox = new CharacterIdentityEventOutbox(Objects.requireNonNull(dataFolder), sink,
Objects.requireNonNull(failures));
}
public void replay() { outbox.replay(); }
@Override public void close() { outbox.close(); }
public CompletionStage<Void> drained() { return outbox.drained(); }
/** A later acknowledged activation proves a transfer only if we observed a different prior holder. */
public void entered(Player player, UUID session, CharacterRecord record) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
Holder previous;
synchronized (lastObserved) {
previous = lastObserved.put(record.id(), new Holder(actor.playerId(), session, actor.location()));
}
if (previous == null || previous.player().equals(actor.playerId())) return;
var action = id(previous.session(), session.toString());
var values = new LinkedHashMap<String, Object>();
values.put("character_id", record.id().toString());
values.put("generation", record.sheetGeneration().toString());
values.put("from_player_id", previous.player().toString());
values.put("to_player_id", actor.playerId().toString());
values.put("from_session_id", previous.session().toString());
values.put("to_session_id", session.toString());
values.put("observation_boundary", "acknowledged_activation");
values.put("custody_quality", "activation_after_prior_holder");
if (previous.entryLocation() != null) values.put("from_location", locationMap(previous.entryLocation(), "cached_at_previous_activation"));
else values.put("from_location_status", "unavailable");
if (actor.location() != null) values.put("to_location", locationMap(actor.location(), "exact"));
else values.put("to_location_status", "unavailable");
values.put("outcome", "observed");
emit("character.transfer_observed", action, clock.instant(), actor, values, "transfer_observed");
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
}
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
UUID holderSession) { }
public Craft craft(Player player, String route, int quantity) {
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var craft = new Craft(UUID.randomUUID(), clock.instant(), context, route, quantity,
activeSession.apply(context.playerId()).orElse(null));
emit("action.observed", craft.action(), craft.time(), context, Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", route, "quantity", Integer.toString(quantity), "outcome", "observed"), "observed");
return craft;
} catch (RuntimeException ignored) { return null; }
}
public record Rename(UUID action, Instant time, EventInput.Context actor, UUID character,
String requestedName, UUID holderSession) { }
public Rename rename(Player player, UUID character, String requestedName) {
try {
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
var action = new Rename(UUID.randomUUID(), clock.instant(), actor, character, requestedName,
activeSession.apply(actor.playerId()).orElse(null));
emit("action.observed", action.action(), action.time(), actor, Map.of(
"action_kind", "character_rename", "character_id", character.toString(),
"outcome", "observed"), "observed");
return action;
} catch (RuntimeException ignored) { return null; }
}
public void renamed(Rename action, CharacterRecord before, CharacterRecord after) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_rename");
values.put("anvil_action_id", action.action().toString());
holder(values, action.actor().playerId(), action.holderSession());
values.put("character_id", after.id().toString());
values.put("generation", after.sheetGeneration().toString());
values.put("before_name", before.name());
values.put("after_name", after.name());
values.put("revision_before", before.revision().toString());
values.put("revision_after", after.revision().toString());
values.put("native_item_outcome", "unverified");
values.put("outcome", "acknowledged");
durable("character.renamed", action.action(), action.time(), action.actor(), values, "renamed");
}
public void resolved(Rename action, String outcome, String reason) {
if (action == null) return;
emit("action.resolved", action.action(), clock.instant(), action.actor(), Map.of(
"action_kind", "character_rename", "character_id", action.character().toString(),
"outcome", outcome, "reason", reason), "resolved");
}
public void created(Craft craft, CharacterRecord record, int index) {
if (craft == null) return;
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_craft");
values.put("craft_operation_id", craft.action().toString());
holder(values, craft.actor().playerId(), craft.holderSession());
values.put("character_id", record.id().toString());
values.put("character_name", record.name());
values.put("generation", record.sheetGeneration().toString());
values.put("revision", record.revision().toString());
values.put("recipe_id", "fantasystick:character_sheet");
values.put("quantity", Integer.toString(craft.quantity()));
values.put("index", index);
values.put("delivery_route", craft.route());
values.put("outcome", "acknowledged_delivered");
durable("character.created", craft.action(), craft.time(), craft.actor(), values, record.id().toString());
}
public void resolved(Craft craft, String outcome, String reason, int delivered) {
if (craft == null) return;
emit("action.resolved", craft.action(), clock.instant(), craft.actor(), Map.of(
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
"delivery_route", craft.route(), "quantity", Integer.toString(craft.quantity()),
"delivered", Integer.toString(delivered), "outcome", outcome, "reason", reason), "resolved");
}
private void durable(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { outbox.accepted(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* Auditing never changes acknowledged gameplay. */ }
}
private void emit(String kind, UUID action, Instant time, EventInput.Context context,
Map<String, Object> values, String stage) {
try { sink.apply(new EventInput(id(action, stage), action, time, kind, context, values)); }
catch (RuntimeException ignored) { /* The audit cannot change native craft outcomes. */ }
}
private static void holder(Map<String, Object> data, UUID player, UUID session) {
data.put("holder_player_id", player.toString());
data.put("holder_session_status", session == null ? "no_active_session" : "active_at_request");
if (session != null) data.put("holder_session_id", session.toString());
}
private static Map<String, Object> locationMap(EventInput.Location location, String quality) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
"x", location.x(), "y", location.y(), "z", location.z(),
"captured_at", location.capturedAt().toString(), "quality", quality);
}
private EventInput.Location location(Location value) {
return value == null || value.getWorld() == null ? null
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
}
private static UUID id(UUID action, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:character-identity:" + action + ':' + stage)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,190 @@
package games.dmg.fantasystick.events;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
/** Recoverable intent after acknowledged craft delivery or character-file rename. Never replays gameplay. */
final class CharacterIdentityEventOutbox implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private static final Set<String> TYPES = Set.of("character.created", "character.renamed");
private final Path directory;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-character-identity-events"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
CharacterIdentityEventOutbox(Path data, Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Consumer<Throwable> failures) {
directory = data.toAbsolutePath().resolve("character-identity-event-intents");
this.sink = sink;
this.failures = failures;
}
void accepted(EventInput event) {
try { worker.execute(() -> {
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
try (var files = Files.list(directory)) {
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException failure) { report(failure); }
}
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
private EventInput write(EventInput event) throws IOException {
if (!TYPES.contains(event.type())) throw new IOException("Unsupported character intent type");
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
Files.createDirectories(directory);
var target = path(event);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
fields.setProperty("id", event.id().toString());
fields.setProperty("action", event.actionId().toString());
fields.setProperty("time", event.time().toString());
fields.setProperty("type", event.type());
var context = event.context();
fields.setProperty("player", context.playerId().toString());
fields.setProperty("player_name", context.playerName());
if (context.characterId() != null) {
fields.setProperty("character", context.characterId().toString());
fields.setProperty("character_name", context.characterName());
fields.setProperty("session", context.sessionId().toString());
}
if (context.location() != null) {
var location = context.location();
fields.setProperty("world", location.worldId().toString());
fields.setProperty("world_name", location.worldName());
fields.setProperty("x", Double.toString(location.x()));
fields.setProperty("y", Double.toString(location.y()));
fields.setProperty("z", Double.toString(location.z()));
fields.setProperty("captured_at", location.capturedAt().toString());
fields.setProperty("quality", location.quality());
}
event.data().forEach((key, value) -> {
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
else throw new IllegalArgumentException("Unsupported character intent value");
});
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged character identity event");
var bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
var temporary = Files.createTempFile(directory, ".identity-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
forceDirectory();
} finally { Files.deleteIfExists(temporary); }
return event;
}
private EventInput read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
var fields = new Properties();
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
var bytes = input.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
fields.load(new ByteArrayInputStream(bytes));
}
try {
var id = UUID.fromString(fields.getProperty("id"));
var action = UUID.fromString(fields.getProperty("action"));
var type = fields.getProperty("type");
if (!path.equals(directory.resolve(id + ".pending")) || !TYPES.contains(type))
throw new IOException("Invalid character event intent identity");
EventInput.Location location = fields.containsKey("world")
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
fields.getProperty("quality")) : null;
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
var context = new EventInput.Context(UUID.fromString(fields.getProperty("player")),
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location);
var data = new LinkedHashMap<String, Object>();
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
var value = fields.getProperty(key);
if (value.isEmpty()) throw new IOException("Invalid character event value");
data.put(key.substring(5), switch (value.charAt(0)) {
case 's' -> value.substring(1);
case 'b' -> {
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
yield Boolean.valueOf(value.substring(1));
}
case 'i' -> Integer.valueOf(value.substring(1));
default -> throw new IOException("Invalid character event value type");
});
}
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
}
private void publish(EventInput event) {
try { sink.apply(event).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
}
private void report(Throwable error) {
try { failures.accept(error); } catch (RuntimeException ignored) { }
}
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
CompletionStage<Void> drained() { return drained; }
}
@@ -370,20 +370,29 @@ public final class CharacterStorage implements AutoCloseable {
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
return renameCharacterObserved(characterId, name, expectedSheetGeneration).thenApply(RenameOutcome::record);
}
/** Capture the authoritative before/after pair in the same serialized save, not from stale item text. */
public record RenameOutcome(Optional<CharacterRecord> record, Optional<CharacterRecord> before, String status) { }
public CompletableFuture<RenameOutcome> renameCharacterObserved(
UUID characterId, String name, BigInteger expectedSheetGeneration) {
Objects.requireNonNull(characterId);
Objects.requireNonNull(name);
return submit(() -> {
var found = repository.load(characterId);
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
if (found.isEmpty() || found.orElseThrow().dead())
return new RenameOutcome(Optional.empty(), Optional.empty(), "unavailable");
var old = found.orElseThrow();
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
return Optional.empty();
if (old.name().equals(name)) return Optional.of(old);
return new RenameOutcome(Optional.empty(), Optional.of(old), "stale_generation");
if (old.name().equals(name)) return new RenameOutcome(Optional.of(old), Optional.of(old), "unchanged");
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
repository.replace(renamed, old.revision());
return Optional.of(renamed);
return new RenameOutcome(Optional.of(renamed), Optional.of(old), "renamed");
});
}
@@ -882,6 +891,19 @@ public final class CharacterStorage implements AutoCloseable {
}
}
/** Observe accepted save callbacks without waiting for file I/O on the native thread. */
public java.util.concurrent.CompletionStage<Void> drained() {
return java.util.concurrent.CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS))
throw new java.util.concurrent.CompletionException(new java.io.IOException("Character storage did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new java.util.concurrent.CompletionException(interrupted);
}
});
}
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
@Override
public void close() {
@@ -49,6 +49,7 @@ public final class EquipmentSessions implements AutoCloseable {
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
private java.util.function.Function<DownedEpisode,CompletionStage<?>> downedDefeatRewards=ignored->CompletableFuture.completedStage(null);
private java.util.function.Predicate<UUID> unavailableCharacter = ignored -> false;
private java.util.function.Function<UUID,java.util.concurrent.CompletionStage<?>> partyDefeat =
@@ -215,6 +216,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
@@ -717,6 +719,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
attempt.player.updateInventory();
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
if(active.get(attempt.owner)==attempt&&identityEvents!=null)identityEvents.entered(attempt.player,attempt.session,record);
} catch (RuntimeException error) {
fail(attempt, error); // Effects may have started: never roll back or replay them.
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
@@ -0,0 +1,391 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Location;
import org.bukkit.craftbukkit.CraftWorld;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.InventoryAction;
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;
class CharacterIdentityEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredManualCraftOnlyPublishesAnAcknowledgedDeliveredCharacter() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("world");
actor.eyeLocation = new Location(world, 12, 70, 31);
var preview = fixture.recipes.get(CharacterSheets.RECIPE);
var event = new CraftItemEvent(preview, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(complete);
complete.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
complete.run(); // A repeated completion cannot create or deliver a second logical success.
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.created")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var created = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).filter(entry -> entry.getAsJsonObject("data")
.get("character_id").getAsString().equals(id.toString())).toList();
assertEquals(1, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = created.getFirst().getAsJsonObject("data");
assertEquals(saved.revision().toString(), values.get("revision").getAsString());
assertEquals("fantasystick:character_sheet", values.get("recipe_id").getAsString());
assertEquals("cursor", values.get("delivery_route").getAsString());
assertEquals(actor.id.toString(), values.get("holder_player_id").getAsString());
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
assertFalse(values.has("holder_session_id"));
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void priorCancellationAndStaleCraftDoNotInventCreation() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var recipe = fixture.recipes.get(CharacterSheets.RECIPE);
var cancelled = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
cancelled.setCancelled(true);
fixture.dispatcher.accept(cancelled);
assertTrue(fixture.tasks.isEmpty());
var stale = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(stale);
assertTrue(stale.isCancelled());
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
actor.fixture.grid.getMatrix()[0].setAmount(7);
complete.run();
assertTrue(actor.fixture.view.getCursor().isEmpty());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
var resolutions = entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.filter(details -> details.has("action_kind")
&& details.get("action_kind").getAsString().equals("character_craft")).toList();
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("cancelled")));
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("stale")));
}
}
@Test void failedNativeCraftSaveNeverReportsCreatedOrDeliversASheet() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var before = actor.fixture.grid.getMatrix()[0].getAmount();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
Files.createDirectories(data.resolve("characters"));
if (!Files.exists(data.resolve("characters/.records.lock"))) Files.createFile(data.resolve("characters/.records.lock"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
complete.run();
}
assertTrue(event.isCancelled());
assertTrue(actor.fixture.view.getCursor().isEmpty());
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(values -> values.has("action_kind") && values.get("action_kind").getAsString()
.equals("character_craft") && values.get("reason").getAsString().equals("save")));
}
}
@Test void cancelledNativeDropSpawnRemainsUnconfirmedWithoutClaimingACreatedSheet() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
var world = new PlayerSheetDropsNativeTest.WorldFixture();
actor.eyeLocation = new Location(world.world, 3, 72, 8);
world.onSpawn = spawn -> spawn.setCancelled(true);
var before = actor.fixture.grid.getMatrix()[0].getAmount();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
assertEquals(1, world.spawnCalls);
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
assertTrue(actor.fixture.view.getCursor().isEmpty());
fixture.plugin.setEnabled(false);
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.created")), "Unconfirmed spawn is not a delivered sheet");
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
.map(entry -> entry.getAsJsonObject("data"))
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("character_craft") && value.get("reason").getAsString().equals("drop_unconfirmed")
&& value.get("outcome").getAsString().equals("uncertain")));
}
}
@Test void registeredNativeAnvilPublishesOnlyItsAcknowledgedUnicodeRename() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
var repository = new YamlCharacterRepository(data);
repository.create(original);
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("world");
anvil.actor.eyeLocation = new Location(world, 4, 64, 9);
var sheet = new CharacterSheets().create(original);
var output = CharacterAnvilNamesNativeTest.named(sheet,
net.kyori.adventure.text.Component.text("雪 🐉 <literal>"));
anvil.grid.setItem(0, sheet);
anvil.grid.setItem(2, output);
var event = new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output);
fixture.dispatcher.accept(event);
fixture.dispatcher.accept(event);
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (repository.load(original.id()).orElseThrow().revision().equals(original.revision())
&& System.nanoTime() < deadline) Thread.sleep(5);
var saved = repository.load(original.id()).orElseThrow();
assertEquals("雪 🐉 <literal>", saved.name());
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var renamed = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")).toList();
assertEquals(1, renamed.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = renamed.getFirst().getAsJsonObject("data");
assertEquals(original.name(), values.get("before_name").getAsString());
assertEquals(saved.name(), values.get("after_name").getAsString());
assertEquals(original.revision().toString(), values.get("revision_before").getAsString());
assertEquals(saved.revision().toString(), values.get("revision_after").getAsString());
assertEquals(saved.sheetGeneration().toString(), values.get("generation").getAsString());
assertEquals("unverified", values.get("native_item_outcome").getAsString());
assertEquals(anvil.actor.id.toString(), values.get("holder_player_id").getAsString());
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void acknowledgedCraftAndRenameReplayOriginalEventsAfterWriterOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
fixture.dispatcher.accept(event);
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var sheet = new CharacterSheets().create(saved);
var result = CharacterAnvilNamesNativeTest.named(sheet,
net.kyori.adventure.text.Component.text("Archived 雪 🐉"));
anvil.grid.setItem(0, sheet); anvil.grid.setItem(2, result);
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, result));
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (new YamlCharacterRepository(data).load(id).orElseThrow().name().equals(saved.name())
&& System.nanoTime() < deadline) Thread.sleep(5);
assertEquals("Archived 雪 🐉", new YamlCharacterRepository(data).load(id).orElseThrow().name());
fixture.plugin.setEnabled(false);
fixture.awaitWorkers();
java.util.Set<String> pendingIds;
try (var files = Files.list(data.resolve("character-identity-event-intents"))) {
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
.map(path -> path.getFileName().toString().replace(".pending", ""))
.collect(java.util.stream.Collectors.toSet());
}
assertEquals(2, pendingIds.size(), "Each acknowledged mutation has independent recoverable intent");
fixture.plugin.setEnabled(true);
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")); i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created") || entry.get("type").getAsString().endsWith("character.renamed")).toList();
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
.collect(java.util.stream.Collectors.toSet()), "Recovery keeps the original accepted event IDs");
assertEquals(id.toString(), domain.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).findFirst().orElseThrow().getAsJsonObject("data")
.get("character_id").getAsString());
}
}
@Test void distinctNativeHolderActivationObservesOnlyProvenCharacterTransfer() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var first = new FantasyStickLifecycleNativeTest.Actor(data);
var second = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(first.core.id, first.player);
fixture.players.put(second.core.id, second.player);
var world = mock(CraftWorld.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("other-world");
second.core.location.setWorld(world);
fixture.activate(first);
var firstSession = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions"))
.active(first.core.id).orElseThrow().session();
var sheet = first.core.inventory.getItemInOffHand().clone();
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
fixture.awaitCharacterAndReceipt(first);
first.core.inventory.setItemInOffHand(null);
second.core.inventory.setItemInOffHand(sheet);
fixture.activate(second);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var secondSession = sessions.active(second.core.id).orElseThrow().session();
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
sessions.exit(second.player);
fixture.awaitCharacterAndReceipt(second);
fixture.reenter(second);
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
fixture.plugin.setEnabled(false);
var transfers = records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.transfer_observed")).toList();
assertEquals(1, transfers.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
var values = transfers.getFirst().getAsJsonObject("data");
assertEquals(first.core.id.toString(), values.get("from_player_id").getAsString());
assertEquals(second.core.id.toString(), values.get("to_player_id").getAsString());
assertEquals(firstSession.toString(), values.get("from_session_id").getAsString());
assertEquals(secondSession.toString(), values.get("to_session_id").getAsString());
assertEquals("activation_after_prior_holder", values.get("custody_quality").getAsString());
assertEquals(first.world.getUID().toString(), values.getAsJsonObject("from_location")
.get("world_id").getAsString());
assertEquals("cached_at_previous_activation", values.getAsJsonObject("from_location")
.get("quality").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("to_location")
.get("world_id").getAsString());
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
}
}
@Test void firstActivationAfterRestartDoesNotInventUnseenSheetTransfer() throws Exception {
org.bukkit.inventory.ItemStack transferred;
try (var firstBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
firstBoot.plugin.setEnabled(true);
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
firstBoot.players.put(owner.core.id, owner.player);
firstBoot.activate(owner);
transferred = owner.core.inventory.getItemInOffHand().clone();
((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(firstBoot.plugin, "sessions")).exit(owner.player);
firstBoot.awaitCharacterAndReceipt(owner);
firstBoot.plugin.setEnabled(false);
}
try (var secondBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
secondBoot.plugin.setEnabled(true);
var newHolder = new FantasyStickLifecycleNativeTest.Actor(data);
secondBoot.players.put(newHolder.core.id, newHolder.player);
newHolder.core.inventory.setItemInOffHand(transferred);
secondBoot.activate(newHolder);
secondBoot.plugin.setEnabled(false);
}
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.transfer_observed")), "Offline custody was not seen by this plugin lifetime");
}
@Test void staleGenerationAndSaveFailureResolveAnvilAttemptsWithoutRenameSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var repository = new YamlCharacterRepository(data);
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(original);
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
var operation = UUID.randomUUID();
storage.beginRecovery(new games.dmg.fantasystick.persistence.CharacterStorage.RecoveryRequest(
operation, original.id(), UUID.randomUUID(), UUID.randomUUID(), original.revision(),
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
storage.completeRecovery(original.id(), operation).join();
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
var stale = new CharacterSheets().create(original);
anvil.grid.setItem(0, stale);
var result = CharacterAnvilNamesNativeTest.named(stale,
net.kyori.adventure.text.Component.text("forged revision 雪"));
anvil.grid.setItem(2, result);
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, result));
storage.find(original.id()).join();
var second = new games.dmg.fantasystick.crafting.CharacterFactory()
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
repository.create(second);
var live = new CharacterSheets().create(second);
anvil.grid.setItem(0, live);
var attempted = CharacterAnvilNamesNativeTest.named(live,
net.kyori.adventure.text.Component.text("not persisted"));
anvil.grid.setItem(2, attempted);
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
anvil.actor.player, anvil.view, attempted));
storage.find(second.id()).handle((ignored, failure) -> null).join();
}
for (int i = 0; i < 100 && records().stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved")).count() < 2; i++) Thread.sleep(10);
fixture.plugin.setEnabled(false);
assertEquals(second.name(), repository.load(second.id()).orElseThrow().name());
var entries = records();
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
.endsWith("character.renamed")));
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
.filter(value -> value.has("action_kind") && value.get("action_kind").getAsString()
.equals("character_rename")).toList();
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("stale_generation")));
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("save_failed")));
}
}
private List<JsonObject> records() throws Exception {
var results = new ArrayList<JsonObject>();
try (var dates = Files.list(data.resolve("events"))) {
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
.toList()) {
try (var files = Files.list(date)) {
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(file);
var complete = content.lastIndexOf('\n');
if (complete < 0) continue;
for (var line : content.substring(0, complete).lines().toList())
if (!line.isBlank()) results.add(JsonParser.parseString(line).getAsJsonObject());
}
}
}
}
return results;
}
}
@@ -436,6 +436,23 @@ class FantasyStickLifecycleNativeTest {
if (route >= 4) assertEquals(quantity, world.spawnCalls);
complete.run();
assertEquals(1, actor.syncs);
java.util.List<com.google.gson.JsonObject> created = java.util.List.of();
for (int attempt = 0; attempt < 100; attempt++) {
created = localRecords(directory).stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.created")).toList();
if (created.size() == quantity) break;
Thread.sleep(10);
}
assertEquals(quantity, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
assertEquals(java.util.stream.IntStream.range(0, quantity).boxed().collect(java.util.stream.Collectors.toSet()),
created.stream().map(entry -> entry.getAsJsonObject("data").get("index").getAsInt())
.collect(java.util.stream.Collectors.toSet()));
assertEquals(1, created.stream().map(entry -> entry.getAsJsonObject("data").get("action_id").getAsString())
.distinct().count(), "All outputs share their actual craft action");
assertEquals(new java.util.HashSet<>(ids), created.stream().map(entry ->
java.util.UUID.fromString(entry.getAsJsonObject("data").get("character_id").getAsString()))
.collect(java.util.stream.Collectors.toSet()));
assertTrue(created.stream().allMatch(entry -> entry.getAsJsonObject("data").get("quantity").getAsInt() == quantity));
}
}
}
@@ -1542,6 +1559,8 @@ class FantasyStickLifecycleNativeTest {
"Accepted persistence must drain before fixture disposal; production shutdown is nonblocking");
((java.util.concurrent.CompletionStage<?>) field(plugin, "switchAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
((java.util.concurrent.CompletionStage<?>) field(plugin, "identityAuditDrain"))
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) {
throw new IOException("Switch event intent worker did not drain before fixture disposal", failure);
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
@@ -268,6 +268,7 @@ class PlayerSheetDropsNativeTest {
static final class WorldFixture {
final UUID playerId = UUID.randomUUID();
final World world;
final UUID worldId = UUID.randomUUID();
final Player player;
final Item item;
ItemStack stack;
@@ -307,6 +308,8 @@ class PlayerSheetDropsNativeTest {
default -> throw new AssertionError("Unexpected item call " + name);
});
world = proxy(World.class, (name, args) -> switch (name) {
case "getUID" -> worldId;
case "getName" -> "craft-world";
case "createEntity" -> {
creates++;
assertEquals(Item.class, args[1]);