feat(sessions): audit persisted character-switch deadlines
Release / release (push) Failing after 7m33s

This commit is contained in:
dmg committed 2026-09-25 11:29:18 -04:00
1 parent 2cc053e6ff
commit 84205c0c14
7 files changed
+540 -10

No files matched your search

@@ -177,6 +177,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
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.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@@ -747,6 +749,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
sessions.attachSwitchEvents(switchAuditEvents);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
@@ -756,7 +762,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; messageAuditEvents = null;
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;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -837,6 +843,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
recipeDiscovery.attachEvents(recipeEvents);
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
var replaySwitchEvents = switchAuditEvents;
switchAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
if (replaySwitchEvents != null) replaySwitchEvents.replay();
});
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
}
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
@@ -875,6 +886,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = 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;
@@ -1179,6 +1191,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
var sessionsDrained = closingSessions == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
switchAuditDrain = sessionsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Switch exit drain failed", failure);
closingSwitchAuditEvents.close();
return null;
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
});
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
unregister(closingModes);
@@ -0,0 +1,265 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
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.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Properties;
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;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes persisted switch deadlines and pre-pack admission without authorizing either. */
public final class SwitchDelayEventBridge implements AutoCloseable {
private static final int MAX_BYTES = 16_384;
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
private final Clock clock;
private final Path pending;
private final Consumer<Throwable> failures;
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
new Thread(task, "fantasy-switch-delay-events"));
private final CompletableFuture<Void> drained = new CompletableFuture<>();
public SwitchDelayEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path dataFolder, Consumer<Throwable> failures) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("switch-event-intents");
this.failures = Objects.requireNonNull(failures);
}
public record ExitContext(UUID player, String playerName, UUID character, String characterName,
UUID session, EventInput.Location location, String reason) { }
public ExitContext exit(Player player, UUID session, CharacterRecord character, String reason) {
try {
return new ExitContext(player.getUniqueId(), player.getName(), character.id(), character.name(),
session, location(player.getLocation()), reason);
} catch (RuntimeException ignored) { return null; }
}
private record SavedStart(ExitContext capture, EquipmentSessionStore.Exit exit,
String revisionBefore, String revisionAfter, Instant time) { }
public void started(ExitContext captured, EquipmentSessionStore.State saved) {
if (captured == null || saved == null || saved.pending() != null || saved.lastExit() == null
|| !captured.session().equals(saved.lastExit().sessionId())) return;
var start = new SavedStart(captured, saved.lastExit(),
saved.revision().subtract(java.math.BigInteger.ONE).toString(), saved.revision().toString(),
saved.lastExit().exitedAt());
try { worker.execute(() -> {
try { publish(write(start)); }
catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private void publish(SavedStart start) {
var captured = start.capture();
var exit = start.exit();
var values = new LinkedHashMap<String, Object>();
values.put("outcome", "acknowledged");
values.put("exit_reason", captured.reason());
values.put("exit_operation_id", captured.session().toString());
values.put("character_id", exit.characterId().toString());
values.put("session_id", exit.sessionId().toString());
values.put("duration_seconds", Long.toString(Duration.between(exit.exitedAt(), exit.deadline()).getSeconds()));
values.put("exited_at", exit.exitedAt().toString());
values.put("deadline", exit.deadline().toString());
values.put("revision_before", start.revisionBefore());
values.put("revision_after", start.revisionAfter());
values.put("zero_duration", exit.deadline().equals(exit.exitedAt()));
var input = new EventInput(id(captured.session(), "started"), captured.session(), start.time(),
"character.switch_delay_started", new EventInput.Context(captured.player(), captured.playerName(),
captured.character(), captured.characterName(), captured.session(), captured.location()), values);
try { sink.apply(input).whenComplete((delivery, failure) -> {
if (failure != null) { report(failure); return; }
if (delivery == LocalEventLog.Delivery.REJECTED) return;
try { if (Files.deleteIfExists(file(captured.session()))) forceDirectory(); }
catch (IOException error) { report(error); }
}); } catch (RuntimeException failure) { report(failure); }
}
/** Replay only persisted, previously acknowledged exits, before any new native session observations. */
public void replay() {
if (worker.isShutdown()) return;
try { worker.execute(() -> {
try {
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
try (var entries = Files.list(pending)) {
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
try { publish(read(path)); }
catch (IOException | RuntimeException failure) { report(failure); }
}
}
} catch (IOException | RuntimeException failure) { report(failure); }
}); } catch (RuntimeException rejected) { report(rejected); }
}
private Path file(UUID session) { return pending.resolve(session + ".pending"); }
private SavedStart write(SavedStart value) throws IOException {
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
Files.createDirectories(pending);
Path target = file(value.capture().session());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
var fields = new Properties();
var capture = value.capture(); var exit = value.exit();
fields.setProperty("player", capture.player().toString());
fields.setProperty("player_name", capture.playerName());
fields.setProperty("character", capture.character().toString());
fields.setProperty("character_name", capture.characterName());
fields.setProperty("session", capture.session().toString());
fields.setProperty("reason", capture.reason());
fields.setProperty("exited_at", exit.exitedAt().toString());
fields.setProperty("deadline", exit.deadline().toString());
fields.setProperty("revision_before", value.revisionBefore());
fields.setProperty("revision_after", value.revisionAfter());
fields.setProperty("time", value.time().toString());
if (capture.location() != null) {
var location = capture.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("location_time", location.capturedAt().toString());
}
var output = new ByteArrayOutputStream();
fields.store(output, "acknowledged switch deadline");
byte[] bytes = output.toByteArray();
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
Path temporary = Files.createTempFile(pending, ".switch-", ".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 value;
}
private void forceDirectory() throws IOException {
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
}
private SavedStart read(Path path) throws IOException {
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch 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("Switch event intent too large");
fields.load(new java.io.ByteArrayInputStream(bytes));
}
try {
var player = UUID.fromString(fields.getProperty("player"));
var character = UUID.fromString(fields.getProperty("character"));
var session = UUID.fromString(fields.getProperty("session"));
if (!path.equals(file(session))) throw new IOException("Switch event identity mismatch");
var reason = fields.getProperty("reason");
if (!java.util.Set.of("voluntary", "logout", "defeat", "shutdown").contains(reason))
throw new IOException("Invalid switch exit reason");
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("location_time")), "exact")
: null;
var before = fields.getProperty("revision_before"); var after = fields.getProperty("revision_after");
if (!before.matches("0|[1-9][0-9]*") || !after.matches("[1-9][0-9]*"))
throw new IOException("Invalid switch revision");
var exit = new EquipmentSessionStore.Exit(session, character,
Instant.parse(fields.getProperty("exited_at")), Instant.parse(fields.getProperty("deadline")));
return new SavedStart(new ExitContext(player, Objects.requireNonNull(fields.getProperty("player_name")),
character, Objects.requireNonNull(fields.getProperty("character_name")), session, location, reason),
exit, before, after, Instant.parse(fields.getProperty("time")));
} catch (RuntimeException invalid) { throw new IOException("Invalid switch event intent", invalid); }
}
private void report(Throwable failure) {
try { failures.accept(failure); } catch (RuntimeException ignored) { }
}
/** Shutdown does not wait for file I/O on the Minecraft thread. */
@Override public void close() {
worker.shutdown();
CompletableFuture.runAsync(() -> {
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS))
report(new IOException("Switch event intents did not drain"));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); report(interrupted);
} finally { drained.complete(null); }
});
}
public CompletionStage<Void> drained() { return drained; }
public void evaluated(Player player, UUID requested, EquipmentSessionStore.State state,
Duration configured, Instant now, boolean allowed) {
try {
var action = UUID.randomUUID();
var values = new LinkedHashMap<String, Object>();
values.put("action_kind", "character_switch_preflight");
values.put("requested_character_id", requested.toString());
values.put("configured_duration_seconds", Long.toString(configured.getSeconds()));
values.put("preflight_order", "before_equipment_pack");
values.put("outcome", "observed");
var exit = state == null ? null : state.lastExit();
boolean same = exit != null && requested.equals(exit.characterId());
values.put("same_character", same);
values.put("last_exited_character_id", exit == null ? "none" : exit.characterId().toString());
if (exit != null) {
values.put("deadline", exit.deadline().toString());
var remaining = Duration.between(now, exit.deadline());
values.put("remaining_seconds", Long.toString(Math.max(0,
remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0))));
values.put("receipt_revision", state.revision().toString());
}
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
location(player.getLocation()));
emit(new EventInput(id(action, "observed"), action, clock.instant(), "action.observed", context, values));
values.put("outcome", allowed ? "allowed" : "refused");
values.put("reason", allowed ? (same ? "same_character" : "prepack_allowed") : "cooldown");
emit(new EventInput(id(action, "resolved"), action, clock.instant(), "action.resolved", context, values));
} catch (RuntimeException ignored) { /* Logging cannot change entry admission. */ }
}
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 void emit(EventInput event) {
try { sink.apply(event); } catch (RuntimeException ignored) { }
}
private static UUID id(UUID action, String kind) {
return UUID.nameUUIDFromBytes(("fantasystick:switch-delay:" + action + ':' + kind)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -166,7 +166,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
queued.remove(owner);
swaps.remove(owner);
boolean reserved = writing.add(owner);
try { sessions.invalidateContext(owner); sessions.exit(player); }
try { sessions.invalidateContext(owner); sessions.exit(player, "logout"); }
finally { if (reserved) writing.remove(owner); observed.remove(owner); }
}
@@ -48,6 +48,7 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
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 =
@@ -117,6 +118,9 @@ public final class EquipmentSessions implements AutoCloseable {
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
boolean switchEventWritten;
String exitReason = "voluntary";
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -210,6 +214,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
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 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)));}
@@ -610,13 +615,17 @@ public final class EquipmentSessions implements AutoCloseable {
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
}
Instant now = clock.instant();
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
boolean admitted = state.canEnter(attempt.character, switchDelay.get(), now);
if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
state, switchDelay.get(), now, admitted);
if (!admitted) {
Duration remaining = Duration.between(now, state.lastExit().deadline());
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
refuse(attempt, "COOLDOWN", "Wait " + seconds
+ " seconds to switch characters; the same character may be resumed now."); return;
}
}
} else if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
null, switchDelay.get(), clock.instant(), true);
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
refuse(attempt, "NATIVE_RECOVERY_REQUIRED", "Native equipment marker or control/box remnants require inspection; entry did not change items.");
return;
@@ -737,9 +746,14 @@ public final class EquipmentSessions implements AutoCloseable {
* A native exception returns INVALID_BOX with NATIVE_UNCERTAIN notice/diagnostic; never retry it.
*/
public synchronized EquipmentStorage.RestoreResult exit(Player player) {
return exit(player, "voluntary");
}
public synchronized EquipmentStorage.RestoreResult exit(Player player, String reason) {
UUID owner = player.getUniqueId();
var transition = transitioning.get(owner);
if (transition != null) {
transition.exitReason = Objects.requireNonNull(reason);
captureExitDelay(transition);
active.remove(owner, transition);
withdrawPresentation(transition);
@@ -750,6 +764,7 @@ public final class EquipmentSessions implements AutoCloseable {
cancelEntry(owner);
var attempt = active.remove(owner);
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
attempt.exitReason = Objects.requireNonNull(reason);
captureExitDelay(attempt);
return restoreExit(attempt);
}
@@ -785,6 +800,7 @@ public final class EquipmentSessions implements AutoCloseable {
}
withdrawPresentation(attempt);
attempt.defeated = true;
attempt.exitReason = "defeat";
event.setCancelled(true);
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
@@ -854,7 +870,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;attempt.exitReason="defeat";boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
@@ -1008,6 +1024,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
if(switchEvents!=null&&attempt.switchExit==null&&attempt.record!=null)
attempt.switchExit=switchEvents.exit(attempt.player,attempt.session,attempt.record,attempt.exitReason);
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
captureExitDelay(attempt);
@@ -1130,6 +1148,10 @@ public final class EquipmentSessions implements AutoCloseable {
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (!attempt.switchEventWritten && switchEvents != null) {
attempt.switchEventWritten = true;
switchEvents.started(attempt.switchExit, state);
}
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
@@ -1357,7 +1379,7 @@ public final class EquipmentSessions implements AutoCloseable {
if (attempt.defeated && !attempt.bookDelivered) conversionUncertain(attempt, "Shutdown before confirmed Tail conversion");
}
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player, "shutdown");
characterRecoveries.clear();
recoveryRecipients.clear();
claims.close();
@@ -61,8 +61,12 @@ class DownedSessionNativeTest{
}
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
games.dmg.fantasystick.party.CharacterParty finalParty;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
games.dmg.fantasystick.events.SwitchDelayEventBridge switchAudit;
UUID exitedSession;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);switchAudit=(games.dmg.fantasystick.events.SwitchDelayEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchAuditEvents");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();exitedSession=episode.session();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
switchAudit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){restarted.plugin.setEnabled(true);for(int i=0;i<100&&!java.nio.file.Files.exists(data.resolve("events"));i++)Thread.sleep(10);for(int i=0;i<100;i++){boolean found=false;try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())if(java.nio.file.Files.readString(file).contains("character.switch_delay_started")){found=true;break;}}if(found)break;Thread.sleep(10);}}
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var switchStarts=events.stream().filter(event->event.get("type").getAsString().endsWith("character.switch_delay_started")).filter(event->event.getAsJsonObject("data").get("session_id").getAsString().equals(exitedSession.toString())).toList();assertEquals(1,switchStarts.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals("defeat",switchStarts.getFirst().getAsJsonObject("data").get("exit_reason").getAsString());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
}
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
@@ -149,8 +149,13 @@ class FantasyStickLifecycleNativeTest {
assertTrue(records.contains("\"item_kind\":\"control\""));
assertTrue(records.contains("\"control_kind\":\"book\""));
assertTrue(records.contains("\"guard_reason\":\"protected_item\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.action.observed", -1).length - 1,
"The duplicate offhand callback must not create another logical observation");
assertEquals(1, records.lines().filter(line -> !line.isBlank())
.map(line -> com.google.gson.JsonParser.parseString(line).getAsJsonObject())
.filter(entry -> entry.get("type").getAsString().endsWith("action.observed"))
.map(entry -> entry.getAsJsonObject("data"))
.filter(details -> details.has("guard_reason")
&& details.get("guard_reason").getAsString().equals("protected_item"))
.count(), "The duplicate offhand callback must not create another logical observation");
}
}
@@ -580,13 +585,17 @@ class FantasyStickLifecycleNativeTest {
@Test void packagedRemovalSwapQuitAndDisableRestoreExactlyOnce() throws Exception {
for (String route : List.of("removal", "swap", "quit", "disable")) {
java.util.UUID exitedSession;
games.dmg.fantasystick.events.SwitchDelayEventBridge audit;
try (var f = new Fixture(data.resolve(route))) {
f.plugin.setEnabled(true);
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player);
var viewer = f.viewer(a, data.resolve(route)); f.activate(a);
audit = (games.dmg.fantasystick.events.SwitchDelayEventBridge) Fixture.field(f.plugin, "switchAuditEvents");
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
var receipts = f.receipts();
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
exitedSession = session.sessionId();
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
var entered = new java.util.concurrent.CountDownLatch(1);
@@ -643,9 +652,42 @@ class FantasyStickLifecycleNativeTest {
}
} finally { release.countDown(); }
}
audit.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
Path directory = data.resolve(route);
if (route.equals("disable")) {
var intent = directory.resolve("switch-event-intents").resolve(exitedSession + ".pending");
assertTrue(java.nio.file.Files.exists(intent), "Shutdown exit intent survives closed event writer");
}
try (var restarted = new Fixture(directory)) {
restarted.plugin.setEnabled(true);
for (int i = 0; i < 100 && localRecords(directory).stream()
.noneMatch(entry -> entry.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
Thread.sleep(10);
}
var events = localRecords(directory);
var starts = events.stream().filter(entry -> entry.get("type").getAsString()
.endsWith("character.switch_delay_started"))
.filter(entry -> entry.getAsJsonObject("data").get("session_id").getAsString()
.equals(exitedSession.toString())).toList();
assertEquals(1, starts.stream().map(entry -> entry.get("id").getAsString()).distinct().count(), route);
var start = starts.getFirst().getAsJsonObject("data");
assertEquals(route.equals("quit") ? "logout" : route.equals("disable") ? "shutdown" : "voluntary",
start.get("exit_reason").getAsString());
assertEquals("900", start.get("duration_seconds").getAsString());
assertEquals(starts.getFirst().get("time").getAsString(), start.get("exited_at").getAsString());
}
}
private static java.util.List<com.google.gson.JsonObject> localRecords(Path directory) throws IOException {
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
@Test void packagedRefusalsExplainSpaceAndIneligibleEquipmentWithoutMutation() throws Exception {
for (boolean full : new boolean[]{false, true}) try (var f = new Fixture(data.resolve("refusal-" + full))) {
f.plugin.setEnabled(true);
@@ -1013,6 +1055,21 @@ class FantasyStickLifecycleNativeTest {
}
} finally { release.countDown(); }
}
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
var started = records.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.map(e -> e.getAsJsonObject("data")).findFirst().orElseThrow();
assertEquals(value.equals("default") ? "900" : value, started.get("duration_seconds").getAsString());
assertEquals("logout", started.get("exit_reason").getAsString());
assertEquals(value.equals("0"), started.get("zero_duration").getAsBoolean());
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
&& e.getAsJsonObject("data").has("action_kind")
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("character_switch_preflight")
&& e.getAsJsonObject("data").get("outcome").getAsString().equals(value.equals("0") ? "allowed" : "refused")));
}
}
@@ -1483,6 +1540,10 @@ class FantasyStickLifecycleNativeTest {
try {
for (var worker : workers) assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS),
"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);
} 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); }
}
@Override public void close() throws IOException {
@@ -0,0 +1,157 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import games.dmg.fantasystick.items.CharacterSheets;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SwitchDelayEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void persistedExitAndPrepackSameOrDifferentCharacterDecisionsReachJsonl() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
actor.core.location.add(3, 0, 1);
double exitX = actor.core.location.getX();
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
actor.core.location.add(8, 0, 0);
double attemptX = actor.core.location.getX();
var saved = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow();
var exit = saved.lastExit();
assertNotNull(exit);
var another = new FantasyStickLifecycleNativeTest.Actor(data);
actor.core.inventory.setItemInOffHand(another.core.inventory.getItemInOffHand().clone());
var before = actor.core.snapshot();
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
fixture.tick(); fixture.tick();
assertArrayEquals(before, actor.core.snapshot(), "A refused switch cannot alter equipment");
assertTrue(actor.messages.getLast().contains("same character may be resumed now"));
actor.core.inventory.setItemInOffHand(new CharacterSheets().create(actor.core.record));
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
fixture.tick(); fixture.tick(); fixture.tick();
assertTrue(actor.core.marker().endsWith("|PACKED"));
fixture.plugin.setEnabled(false);
var events = records();
var started = events.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals(actor.core.id.toString(), started.get("player_id").getAsString());
assertEquals(exit.characterId().toString(), started.get("character_id").getAsString());
assertEquals(exit.deadline().toString(), started.get("deadline").getAsString());
assertEquals("900", started.get("duration_seconds").getAsString());
assertEquals(saved.revision().toString(), started.get("revision_after").getAsString());
assertEquals(exitX, started.getAsJsonObject("location").get("x").getAsDouble());
assertEquals(exit.exitedAt().toString(), events.stream()
.filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
.findFirst().orElseThrow().get("time").getAsString());
var decisions = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
.toList();
assertEquals(3, decisions.size(), "Initial entry, refused switch and same-character resumption are distinct preflights");
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
&& d.get("last_exited_character_id").getAsString().equals("none")));
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("refused")
&& d.get("reason").getAsString().equals("cooldown")
&& d.getAsJsonObject("location").get("x").getAsDouble() == attemptX));
var observations = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.observed"))
.map(e -> e.getAsJsonObject("data"))
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
.toList();
assertEquals(decisions.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
observations.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
&& d.get("same_character").getAsBoolean()));
}
}
@Test void acknowledgedExitRecoversSameDeadlineEventAfterWriterOutage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var exit = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && records().stream()
.noneMatch(e -> e.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var recovered = records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started")).toList();
assertEquals(1, recovered.stream().map(e -> e.get("id").getAsString()).distinct().count());
assertEquals(exit.deadline().toString(), recovered.getFirst().getAsJsonObject("data")
.get("deadline").getAsString());
assertEquals(actor.core.record.id().toString(), recovered.getFirst().getAsJsonObject("data")
.get("character_id").getAsString());
}
}
@Test void twoPersistedExitsRemainDistinctDuringWriterOutageAndRestart() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
fixture.activate(actor);
((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var first = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
fixture.reenter(actor);
sessions.exit(actor.player);
fixture.awaitCharacterAndReceipt(actor);
var second = fixture.receipts().find(actor.core.id).toCompletableFuture()
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
assertNotEquals(first.sessionId(), second.sessionId());
fixture.plugin.setEnabled(false);
fixture.plugin.setEnabled(true);
for (int i = 0; i < 80 && records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started")).map(e -> e.get("id").getAsString()).distinct().count() < 2; i++)
Thread.sleep(10);
fixture.plugin.setEnabled(false);
var starts = records().stream().filter(e -> e.get("type").getAsString()
.endsWith("character.switch_delay_started"))
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
(one, duplicate) -> one, java.util.LinkedHashMap::new));
assertEquals(2, starts.size());
assertEquals(java.util.Set.of(first.sessionId().toString(), second.sessionId().toString()),
starts.values().stream().map(e -> e.getAsJsonObject("data").get("session_id").getAsString())
.collect(java.util.stream.Collectors.toSet()));
}
}
private List<com.google.gson.JsonObject> records() throws Exception {
var events = new ArrayList<com.google.gson.JsonObject>();
try (var files = Files.walk(data.resolve("events"))) {
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : Files.readAllLines(path))
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return events;
}
}