feat(events): record character session and message outcomes
Release / release (push) Successful in 14m5s

This commit is contained in:
dmg committed 2026-09-25 00:18:41 -04:00
1 parent 26ccc95cdd
commit 22d92b5bbf
10 files changed
+360 -6

No files matched your search

@@ -175,6 +175,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.PartyEventBridge partyAuditEvents;
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.MessageEventBridge messageAuditEvents;
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
@Override public void onEnable() {
@@ -721,13 +723,17 @@ public final class FantasyStickPlugin extends JavaPlugin {
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
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);
sessions.attachDownedEvents(downedEvents);
downedController.attachEvents(downedEvents);
revive.attachDownedEvents(downedEvents);
admin.attachDownedEvents(downedEvents);
getServer().getPluginManager().registerEvents(eventCapture, this);
messageAuditEvents = new games.dmg.fantasystick.events.MessageEventBridge(
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;
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;
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
}
getServer().getPluginManager().registerEvents(crafting, this);
@@ -830,7 +836,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
combatFeedback = null;
var closingEvents = sessionEvents; sessionEvents = null;
var closingEventLog = eventLog; eventLog = null;
var closingEventCapture = eventCapture; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = 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;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1138,6 +1144,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
unregister(closingModes);
unregister(closingChat);
unregister(closingEventCapture);
unregister(closingMessageAuditEvents);
unregister(closingPartyEvents);
unregister(closingPartyCombat);
unregister(closingPartyRegistrarController);
@@ -113,6 +113,18 @@ public final class DownedEventBridge {
"character.died", data);
}
/** Physical return is a separate observed native result, never proof of final-death commitment or Tail delivery. */
public void returnAttempted(DownedEpisode episode, String outcome) {
if (episode.outcome() != DownedEpisode.Outcome.FINAL_DEATH) return;
var data = common(episode);
data.put("action_kind", "final_death_return");
data.put("death_id", episode.id().toString());
data.put("effective_death_time", episode.resolvedAt());
data.put("stage", "native_return_attempt");
data.put("outcome", outcome);
emit(stable(episode.id(), "return"), episode.outcomeOperation(), clock.instant(), "action.resolved", data);
}
public void outcomePublished(DownedEpisode episode) {
if(episode.decision()!=null&&"phoenix_down".equals(episode.decision().source()))resolved(episode);
var data = common(episode);
@@ -97,6 +97,30 @@ public final class EventCaptureListener implements Listener {
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(org.bukkit.event.block.BlockDispenseArmorEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PrePlayerAttackEntityEvent");details.put("target_id",event.getAttacked().getUniqueId().toString());details.put("will_attack",event.willAttack());details.putAll(item(event.getPlayer().getInventory().getItemInMainHand()));capture(event,event.getPlayer(),"attack_entity",details,event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(io.papermc.paper.event.player.PrePlayerAttackEntityEvent event){resolveCaptured(event,event);}
/** Observe the final accepted native damage event once; no damage or health mutation occurs here. */
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0) return;
var snapshot = context(player).orElse(null);
if (snapshot == null) return;
var details = new LinkedHashMap<String, Object>();
details.put("target_player_id", player.getUniqueId().toString());
details.put("target_character_id", snapshot.characterId().toString());
details.put("cause", event.getCause().name());
details.put("amount", Double.toString(event.getFinalDamage()));
details.put("outcome", "accepted_native_event");
details.put("cancelled", false);
if (event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent by) {
details.put("source_entity_id", by.getDamager().getUniqueId().toString());
details.put("source_entity_type", by.getDamager().getType().name());
}
try {
var operation = UUID.randomUUID();
events.emit(new EventInput(operation, operation, clock.instant(), "combat.damage", snapshot, details));
} catch (RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerItemHeldEvent event){capture(event,event.getPlayer(),"held_slot",Map.of("entry_point","PlayerItemHeldEvent","previous_slot",event.getPreviousSlot(),"new_slot",event.getNewSlot()),event);}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false) public void resolve(PlayerItemHeldEvent event){resolveCaptured(event,event);}
@EventHandler(priority=EventPriority.LOWEST,ignoreCancelled=false) public void observe(PlayerSwapHandItemsEvent event){var details=new LinkedHashMap<String,Object>();details.put("entry_point","PlayerSwapHandItemsEvent");details.putAll(item(event.getOffHandItem()));capture(event,event.getPlayer(),"swap_hands",details,event);}
@@ -0,0 +1,88 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.presentation.CharacterPresentation;
import io.papermc.paper.event.player.AsyncChatEvent;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
/** Observes supported character messages; no recipient/delivery claims or signed-body changes. */
public final class MessageEventBridge implements Listener {
private static final Set<String> PRIVATE = Set.of("msg", "message", "tell", "w", "whisper", "pm", "reply", "r");
private final Function<UUID, CharacterPresentation.View> views;
private final SessionEventBridge sessions;
private final java.util.function.Consumer<EventInput> sink;
private final Clock clock;
public MessageEventBridge(Function<UUID, CharacterPresentation.View> views, SessionEventBridge sessions,
java.util.function.Consumer<EventInput> sink, Clock clock) {
this.views=Objects.requireNonNull(views);this.sessions=Objects.requireNonNull(sessions);
this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void chat(AsyncChatEvent event) {
try {
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var context=sessions.lastKnown(view.holder(),view.session(),view.name());
if(context==null)return;
var data=new LinkedHashMap<String,Object>();
data.put("channel","public");
data.put("message",event.signedMessage().message());
data.put("asynchronous",event.isAsynchronous());
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
data.put("location_quality","last_native_session_capture");
emit("character.chat",context,data);
} catch(RuntimeException ignored) { }
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=false)
public void privateMessage(PlayerCommandPreprocessEvent event) {
try {
String command=event.getMessage();
if(command==null||!command.startsWith("/"))return;
int end=command.indexOf(' ');
String label=(end<0?command.substring(1):command.substring(1,end)).toLowerCase(Locale.ROOT);
label=label.substring(label.lastIndexOf(':')+1);
if(!PRIVATE.contains(label))return;
var view=views.apply(event.getPlayer().getUniqueId());
if(view==null)return;
var cached=sessions.lastKnown(view.holder(),view.session(),view.name());
if(cached==null)return;
var position=event.getPlayer().getLocation();
var context=new EventInput.Context(cached.playerId(),cached.playerName(),view.character(),view.name(),view.session(),
new EventInput.Location(position.getWorld().getUID(),position.getWorld().getName(),
position.getX(),position.getY(),position.getZ(),clock.instant(),"native_capture"));
boolean reply=label.equals("reply")||label.equals("r");
String remainder=end<0?"":command.substring(end+1);
var data=new LinkedHashMap<String,Object>();
data.put("command",label);data.put("reply",reply);
if(reply)data.put("message",remainder);
else {
int separator=remainder.indexOf(' ');
if(!remainder.isEmpty())data.put("recipient_token",separator<0?remainder:remainder.substring(0,separator));
data.put("message",separator<0?"":remainder.substring(separator+1));
}
data.put("cancelled",event.isCancelled());
data.put("outcome",event.isCancelled()?"cancelled":"observed");
data.put("delivery","unverified");
emit("character.private_message",context,data);
} catch(RuntimeException ignored) { }
}
private void emit(String type,EventInput.Context context,java.util.Map<String,Object> data) {
var action=UUID.randomUUID();
sink.accept(new EventInput(action,action,clock.instant(),type,context,data));
}
}
@@ -0,0 +1,99 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Player;
/** Observes acknowledged session boundaries; the log never authorizes a transition. */
public final class SessionEventBridge {
private final Consumer<EventInput> sink;
private final Clock clock;
private final java.util.concurrent.ConcurrentHashMap<UUID, EventInput.Context> current = new java.util.concurrent.ConcurrentHashMap<>();
public SessionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public void entered(Player player, UUID session, CharacterRecord record, Location entry,
boolean resumedDowned, boolean adventureMode) {
try {
var now = clock.instant();
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", record.id().toString());
data.put("revision", record.revision().toString());
data.put("entry_location", locationMap(entry));
data.put("resumed_downed", resumedDowned);
data.put("adventure_mode_applied", adventureMode);
data.put("equipment_outcome", "packed");
data.put("session_receipt", "acknowledged");
data.put("outcome", "active");
var context = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
session, location(player.getLocation()));
current.put(context.playerId(), context);
sink.accept(new EventInput(id(session, "entered"), session, now, "character.entered", context, data));
} catch (RuntimeException ignored) {
// An unavailable journal cannot change the acknowledged session.
}
}
/** Thread-safe last native capture, never a live Bukkit lookup from asynchronous chat. */
public EventInput.Context lastKnown(UUID player, UUID session, String characterName) {
var value = current.get(player);
if (value == null || !value.sessionId().equals(session)) return null;
return new EventInput.Context(value.playerId(), value.playerName(), value.characterId(), characterName,
session, value.location());
}
public void forget(UUID player, UUID session) {
var value=current.get(player);
if(value!=null&&value.sessionId().equals(session))current.remove(player,value);
}
public record ExitCapture(EventInput.Context context, java.util.Map<String, Object> entryLocation, String revision) { }
public ExitCapture captureExit(Player player, UUID session, CharacterRecord record, Location entry) {
try {
return new ExitCapture(new EventInput.Context(player.getUniqueId(), player.getName(), record.id(),
record.name(), session, location(player.getLocation())), locationMap(entry), record.revision().toString());
} catch (RuntimeException ignored) { return null; }
}
public void exited(UUID session, ExitCapture capture) {
if (capture == null) return;
try {
var data = new LinkedHashMap<String, Object>();
data.put("session_id", session.toString());
data.put("character_id", capture.context().characterId().toString());
data.put("revision", capture.revision());
data.put("entry_location", capture.entryLocation());
data.put("reason", "living_exit");
data.put("equipment_outcome", "restored");
data.put("session_receipt", "completed");
data.put("outcome", "acknowledged");
forget(capture.context().playerId(),session);
sink.accept(new EventInput(id(session, "exited"), session, clock.instant(), "character.exited", capture.context(), data));
} catch (RuntimeException ignored) { }
}
private java.util.Map<String, Object> locationMap(Location value) {
return java.util.Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
value.getY(), value.getZ(), clock.instant(), "native_capture");
}
private static UUID id(UUID session, String stage) {
return UUID.nameUUIDFromBytes(("fantasystick:session:" + session + ':' + stage).getBytes(StandardCharsets.UTF_8));
}
}
@@ -47,6 +47,7 @@ public final class EquipmentSessions implements AutoCloseable {
private java.util.function.Function<UUID,DefeatCapture> defeatCapture=ignored->new DefeatCapture(false,List.of(),0,BigInteger.ZERO);
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 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 =
@@ -114,6 +115,8 @@ public final class EquipmentSessions implements AutoCloseable {
boolean uncertainSave;
boolean nativeUncertain;
boolean exitRequested;
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
boolean exitEventWritten;
Duration exitDelay;
boolean arrival;
Attempt recoveryOwner;
@@ -206,6 +209,7 @@ public final class EquipmentSessions implements AutoCloseable {
public synchronized void attachDefeatCapture(java.util.function.Function<UUID,DefeatCapture> capture){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");defeatCapture=Objects.requireNonNull(capture);}
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 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)));}
@@ -703,6 +707,7 @@ public final class EquipmentSessions implements AutoCloseable {
if(attempt.resumedRevive!=null){var recovered=attempt.resumedRevive;if(downedEvents!=null)downedEvents.outcomePublished(recovered);downedDirectory.publish(recovered.id(),recovered.outcomeOperation()).whenComplete((published,publishFailure)->{if(publishFailure!=null)diagnostic(attempt,publishFailure);else if(downedEvents!=null)downedEvents.outcomePublished(published);});}
}
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);
} 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.");
@@ -849,7 +854,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{if(!attempt.player.teleport(attempt.entryLocation.clone()))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;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;}
@@ -985,6 +990,7 @@ public final class EquipmentSessions implements AutoCloseable {
private void withdrawPresentation(Attempt attempt) {
if (!attempt.presentationInstalled) return;
attempt.presentationInstalled = false;
if(sessionEvents!=null)sessionEvents.forget(attempt.owner,attempt.session);
try { presentation.withdraw(attempt.owner, attempt.session); }
catch (RuntimeException | LinkageError failure) { diagnostic(attempt, failure); }
}
@@ -1002,6 +1008,8 @@ public final class EquipmentSessions implements AutoCloseable {
}
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
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);
active.remove(attempt.owner, attempt);
withdrawPresentation(attempt);
@@ -1122,6 +1130,10 @@ public final class EquipmentSessions implements AutoCloseable {
"Equipment restoration persistence failed before Tail conversion");
} else if (complete) {
attempt.gearAcknowledged = true;
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
attempt.exitEventWritten = true;
sessionEvents.exited(attempt.session,attempt.exitEvent);
}
if (attempt.defeated) queueTail(attempt);
release(attempt);
} else if (attempt.defeated) {
@@ -505,7 +505,7 @@ class CombatExperienceNativeTest {
f.dispatcher.accept(defeat); settle(f);
assertEquals(java.math.BigInteger.TEN, record(f, a).experience(), "Repeated intercepted callback cannot award twice");
}
var rewards=events(second).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(1,rewards.size(),"Initial downing and repeated callbacks emit no extra reward resolution");var reward=rewards.getFirst().getAsJsonObject("data");assertEquals("final_death",reward.get("stage").getAsString());assertEquals(finalVictimPlayer.toString(),reward.get("target_id").getAsString());assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("allocated_experience").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("coin_pool").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("allocated_coins").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("applied_coins").getAsString());assertEquals(finalAttackerCoins.toString(),reward.get("balance_before").getAsString());assertEquals(finalAttackerCoins.add(finalVictimCoins).toString(),reward.get("balance_after").getAsString());assertEquals("spacing_not_applicable",reward.get("resolution").getAsString());assertEquals(java.util.List.of(finalAttacker.toString()),reward.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(reward.has("reward_unit_id"));var losses=events(second).stream().filter(event->event.get("type").getAsString().endsWith("character.coins_lost")).toList();assertEquals(1,losses.size());var loss=losses.getFirst().getAsJsonObject("data");assertEquals(deathEpisode.toString(),loss.get("death_id").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("balance_before").getAsString());assertEquals("0",loss.get("balance_after").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("death_bound_escrow").getAsString());assertEquals("player_attributed_final_death",loss.get("reason").getAsString());
var rewards=events(second).stream().filter(event->event.get("type").getAsString().endsWith("combat.reward_resolved")).toList();assertEquals(1,rewards.size(),"Initial downing and repeated callbacks emit no extra reward resolution");var reward=rewards.getFirst().getAsJsonObject("data");assertEquals("final_death",reward.get("stage").getAsString());assertEquals(finalVictimPlayer.toString(),reward.get("target_id").getAsString());assertEquals("10",reward.get("experience_pool").getAsString());assertEquals("10",reward.get("allocated_experience").getAsString());assertEquals("10",reward.get("applied_experience").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("coin_pool").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("allocated_coins").getAsString());assertEquals(finalVictimCoins.toString(),reward.get("applied_coins").getAsString());assertEquals(finalAttackerCoins.toString(),reward.get("balance_before").getAsString());assertEquals(finalAttackerCoins.add(finalVictimCoins).toString(),reward.get("balance_after").getAsString());assertEquals("spacing_not_applicable",reward.get("resolution").getAsString());assertEquals(java.util.List.of(finalAttacker.toString()),reward.getAsJsonArray("direct_contributor_character_ids").asList().stream().map(com.google.gson.JsonElement::getAsString).toList());assertTrue(reward.has("reward_unit_id"));var losses=events(second).stream().filter(event->event.get("type").getAsString().endsWith("character.coins_lost")).toList();assertEquals(1,losses.size());var loss=losses.getFirst().getAsJsonObject("data");assertEquals(deathEpisode.toString(),loss.get("death_id").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("balance_before").getAsString());assertEquals("0",loss.get("balance_after").getAsString());assertEquals(finalVictimCoins.toString(),loss.get("death_bound_escrow").getAsString());assertEquals("player_attributed_final_death",loss.get("reason").getAsString());var returns=events(second).stream().filter(event->event.get("type").getAsString().endsWith("action.resolved")&&event.getAsJsonObject("data").has("action_kind")&&event.getAsJsonObject("data").get("action_kind").getAsString().equals("final_death_return")).toList();assertEquals(1,returns.size());assertEquals(deathEpisode.toString(),returns.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals("returned",returns.getFirst().getAsJsonObject("data").get("outcome").getAsString());
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture f, org.bukkit.entity.Entity source,
@@ -45,7 +45,7 @@ class DownedSessionNativeTest{
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);actor.core.location.add(7,0,3);var anchor=actor.core.location.clone();actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);actor.player.setHealth(death.getReviveHealth());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);}assertTrue(death.isCancelled());assertEquals(1d,actor.player.getHealth());var episode=directory.all().getFirst();assertEquals(actor.core.record.id(),episode.character());assertEquals(actor.core.id,episode.player());assertEquals(anchor.getWorld().getUID(),episode.world());assertEquals(anchor.getX(),episode.x());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Phase.PENDING,episode.phase());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(characters.find(actor.core.record.id()).join().orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");var active=sessions.active(actor.core.id).orElseThrow();assertFalse(sessions.current(active));assertTrue(sessions.downed(actor.core.id).isPresent());assertEquals(org.bukkit.Material.GRAY_DYE,actor.inventories.getLast().getItem(13).getType());}
}
@Test void claimedReviveRestoresFullHealthInPlaceAndAddsBoundedDamageProtection()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 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.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");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());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));}
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 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.location.add(4,0,2);var anchor=actor.core.location.clone();actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");var outcome=new java.util.concurrent.atomic.AtomicReference<games.dmg.fantasystick.session.DownedController.RescueStatus>();for(int attempt=0;attempt<80&&!controller.downed(actor.player);attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}controller.revive(actor.player,java.time.Duration.ofSeconds(5),outcome::set);for(int attempt=0;attempt<80&&outcome.get()==null;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(games.dmg.fantasystick.session.DownedController.RescueStatus.APPLIED,outcome.get());assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertEquals(anchor,actor.player.getLocation());var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");assertTrue(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertTrue(sessions.downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());var damage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(damage.getEntity()).thenReturn(actor.player);controller.damage(damage);org.mockito.Mockito.verify(damage).setCancelled(true);assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);sessions.arrive(actor.player);for(int attempt=0;attempt<120&&sessions.active(actor.core.id).isEmpty();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(sessions.downedProtected(actor.core.id),"Reconnect retains the original absolute protection deadline");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());}assertTrue(events.stream().filter(event->event.get("type").getAsString().endsWith("combat.prevented")).map(event->event.getAsJsonObject("data")).anyMatch(event->event.get("reason").getAsString().equals("revive_protection")));assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(events.stream().noneMatch(event->event.get("type").getAsString().endsWith("character.died")),"In-place rescue has no final death");}
}
@Test void downedFreezeCancelsMotionCombatHealingHungerItemsAndInventoryMutation()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);actor.core.health=0;f.dispatcher.accept(death(actor));var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");for(int attempt=0;attempt<80&&(!controller.downed(actor.player)||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}org.mockito.Mockito.verify(actor.player).setGravity(false);var from=actor.core.location.clone();var to=from.clone().add(1,0,0);var move=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerMoveEvent.class);org.mockito.Mockito.when(move.getPlayer()).thenReturn(actor.player);org.mockito.Mockito.when(move.getFrom()).thenReturn(from);org.mockito.Mockito.when(move.getTo()).thenReturn(to);controller.move(move);org.mockito.Mockito.verify(move).setTo(from);var velocity=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerVelocityEvent.class);org.mockito.Mockito.when(velocity.getPlayer()).thenReturn(actor.player);controller.velocity(velocity);org.mockito.Mockito.verify(velocity).setCancelled(true);var attack=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageByEntityEvent.class);org.mockito.Mockito.when(attack.getDamager()).thenReturn(actor.player);controller.attack(attack);org.mockito.Mockito.verify(attack).setCancelled(true);var heal=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityRegainHealthEvent.class);org.mockito.Mockito.when(heal.getEntity()).thenReturn(actor.player);controller.heal(heal);org.mockito.Mockito.verify(heal).setCancelled(true);var hunger=org.mockito.Mockito.mock(org.bukkit.event.entity.FoodLevelChangeEvent.class);org.mockito.Mockito.when(hunger.getEntity()).thenReturn(actor.player);controller.hunger(hunger);org.mockito.Mockito.verify(hunger).setCancelled(true);var consume=org.mockito.Mockito.mock(org.bukkit.event.player.PlayerItemConsumeEvent.class);org.mockito.Mockito.when(consume.getPlayer()).thenReturn(actor.player);controller.consume(consume);org.mockito.Mockito.verify(consume).setCancelled(true);var old=actor.core.inventory.getItem(4).clone();actor.core.inventory.setItem(4,new org.bukkit.inventory.ItemStack(org.bukkit.Material.DIRT));var change=new io.papermc.paper.event.player.PlayerInventorySlotChangeEvent(actor.player,4,old,actor.core.inventory.getItem(4));((games.dmg.fantasystick.session.EquipmentSessionEvents)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessionEvents")).changed(change);assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).active(actor.core.id).isPresent());controller.slot(change);var task=f.tasks.poll(1,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(task);task.run();assertEquals(old,actor.core.inventory.getItem(4));}
@@ -57,7 +57,7 @@ class DownedSessionNativeTest{
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());var characters=(games.dmg.fantasystick.persistence.CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var initial=characters.find(actor.core.record.id()).join().orElseThrow();characters.saveHealth(initial.id(),initial.revision(),java.math.BigDecimal.valueOf(4),initial.maximumHealth()).join();f.activate(actor);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();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 sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);f.awaitCharacterAndReceipt(actor);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,UUID.randomUUID(),java.time.Duration.ofSeconds(5)).join().episode().orElseThrow();assertFalse(resolved.published());sessions.arrive(actor.player);for(int attempt=0;attempt<160&&(sessions.active(actor.core.id).isEmpty()||!directory.find(actor.core.record.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(sessions.current(sessions.active(actor.core.id).orElseThrow()));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());var saved=characters.find(actor.core.record.id()).join().orElseThrow();assertEquals(0,saved.maximumHealth().compareTo(saved.health()));assertTrue(directory.find(actor.core.record.id()).join().orElseThrow().published());assertTrue(sessions.downedProtected(actor.core.id));}
}
@Test void offlineFinalizationRecordsOriginalTimeAndReconcilesTheExactSheet()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);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();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 sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));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);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.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(actor.core.record.id()).join().orElseThrow().published());}
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);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();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 sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");sessions.exit(actor.player);var resolved=directory.claim(episode.id(),games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,UUID.randomUUID(),java.time.Duration.ZERO).join().episode().orElseThrow();assertTrue(sessions.finalizeDowned(resolved));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);}var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertEquals(resolved.resolvedAt(),dead.diedAt());assertEquals(episode.id(),dead.deathId());assertEquals(org.bukkit.Material.PAPER,actor.core.inventory.getItemInOffHand().getType());sessions.arrive(actor.player);for(int attempt=0;attempt<80&&actor.core.inventory.getItemInOffHand().getType()!=org.bukkit.Material.WRITTEN_BOOK;attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType(),actor.messages.toString());for(int attempt=0;attempt<80&&!directory.find(actor.core.record.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(actor.core.record.id()).join().orElseThrow().published());f.plugin.setEnabled(false);var died=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()){var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();if(event.get("type").getAsString().endsWith("character.died"))died.add(event);}}assertFalse(died.isEmpty());assertEquals(1,died.stream().map(event->event.get("id").getAsString()).distinct().count(),"At-least-once reconciliation retains one logical death ID");assertEquals(episode.id().toString(),died.getFirst().getAsJsonObject("data").get("death_id").getAsString());assertEquals(resolved.resolvedAt().toString(),died.getFirst().getAsJsonObject("data").get("effective_death_time").getAsString());}
}
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
games.dmg.fantasystick.party.CharacterParty finalParty;
@@ -61,6 +61,62 @@ final class EventCaptureNativeTest {
var guarded=records.stream().filter(event->event.get("type").getAsString().contains("action.")).toList();assertEquals(10,guarded.size());for(var event:guarded){var value=event.getAsJsonObject("data");assertEquals(episodeId.toString(),value.get("downing_id").getAsString());assertEquals("downed",value.get("guard_reason").getAsString());}assertEquals(java.util.Set.of("inventory_drag","consume_item","inventory_click","dispense_armor","launch_projectile"),guarded.stream().map(event->event.getAsJsonObject("data").get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).anyMatch(value->value.get("item_kind").getAsString().equals("quest_fishing_rod")));assertTrue(guarded.stream().map(event->event.getAsJsonObject("data")).filter(value->value.get("item_kind").getAsString().equals("control")).allMatch(value->value.get("control_kind").getAsString().equals("basic_attack")));
}
@Test void asynchronousChatUsesCapturedSessionWithoutNativeLocationLookupOrReplacementCharacter() throws Exception {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:05:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var first=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var observed=new java.util.concurrent.CopyOnWriteArrayList<games.dmg.fantasystick.events.EventInput>();
var bridge=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(first);when(record.name()).thenReturn("Original");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
bridge.entered(player,session,record,new Location(world,1,64,2),false,true);
var view=new java.util.concurrent.atomic.AtomicReference<>(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,first,java.math.BigInteger.ONE,"Original"));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(ignored->view.get(),bridge,observed::add,clock);
var body=net.kyori.adventure.text.Component.text("signed 你好 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("signed 你好 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(true,player,new java.util.HashSet<>(),io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
var failure=new java.util.concurrent.atomic.AtomicReference<Throwable>();
var worker=new Thread(()->{try{listener.chat(chat);}catch(Throwable error){failure.set(error);}},"character-message-test");worker.start();worker.join();assertNull(failure.get());
var event=observed.stream().filter(value->value.type().equals("character.chat")).findFirst().orElseThrow();
assertEquals(first,event.context().characterId());assertEquals(session,event.context().sessionId());
assertEquals("signed 你好 🌿",event.data().get("message"));assertEquals("last_native_session_capture",event.data().get("location_quality"));
view.set(new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,UUID.randomUUID(),UUID.randomUUID(),java.math.BigInteger.ZERO,"Replacement"));
listener.chat(chat);assertEquals(1,observed.stream().filter(value->value.type().equals("character.chat")).count());
verify(player,times(1)).getLocation();assertSame(signed,chat.signedMessage());
}
@Test void privateAliasObservationsKeepFullBodiesWithoutClaimingRecipientDelivery() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:12:00Z"),ZoneOffset.UTC);var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var observed=new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();var sessions=new games.dmg.fantasystick.events.SessionEventBridge(observed::add,clock);sessions.entered(player,session,record,new Location(world,1,64,2),false,false);
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),sessions,observed::add,clock);
var aliases=java.util.List.of("msg","message","tell","w","whisper","pm","reply","r","minecraft:msg");
for(var alias:aliases){var request=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(request.getPlayer()).thenReturn(player);when(request.getMessage()).thenReturn("/"+alias+(alias.equals("reply")||alias.equals("r")?" ":" Alex ")+"full Unicode 雪 🐉 text");listener.privateMessage(request);}
var ordinary=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(ordinary.getMessage()).thenReturn("/warp nowhere");listener.privateMessage(ordinary);
var messages=observed.stream().filter(value->value.type().equals("character.private_message")).toList();assertEquals(aliases.size(),messages.size());
for(var message:messages){assertEquals("full Unicode 雪 🐉 text",message.data().get("message"));assertEquals("unverified",message.data().get("delivery"));assertFalse(message.data().containsKey("recipient_player_id"));}
assertTrue(messages.stream().filter(value->Boolean.TRUE.equals(value.data().get("reply"))).allMatch(value->!value.data().containsKey("recipient_token")));
}
@Test void journalFailureCannotBlockSessionTransitionOrMessageHandling() {
var clock=Clock.fixed(Instant.parse("2026-09-25T04:10:00Z"),ZoneOffset.UTC);
var account=UUID.randomUUID();var character=UUID.randomUUID();var session=UUID.randomUUID();var world=mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());when(world.getName()).thenReturn("world");
var player=mock(Player.class);when(player.getUniqueId()).thenReturn(account);when(player.getName()).thenReturn("Account");
when(player.getWorld()).thenReturn(world);when(player.getLocation()).thenReturn(new Location(world,1,64,2));
var record=mock(CharacterRecord.class);when(record.id()).thenReturn(character);when(record.name()).thenReturn("Hero");when(record.revision()).thenReturn(java.math.BigInteger.ONE);
var failures=new games.dmg.fantasystick.events.SessionEventBridge(event->{throw new IllegalStateException("file unavailable");},clock);
assertDoesNotThrow(()->failures.entered(player,session,record,new Location(world,1,64,2),false,false));
var capture=failures.captureExit(player,session,record,new Location(world,1,64,2));
assertDoesNotThrow(()->failures.exited(session,capture));
var listener=new games.dmg.fantasystick.events.MessageEventBridge(id->new games.dmg.fantasystick.presentation.CharacterPresentation.View(account,session,character,java.math.BigInteger.ONE,"Hero"),failures,event->{throw new IllegalStateException("file unavailable");},clock);
var command=mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);when(command.getPlayer()).thenReturn(player);when(command.getMessage()).thenReturn("/minecraft:msg Alex hello");
assertDoesNotThrow(()->listener.privateMessage(command));
verify(command,never()).setCancelled(anyBoolean());
}
private static int count(String value,String sought){int count=0;for(int at=0;(at=value.indexOf(sought,at))>=0;at+=sought.length())count++;return count;}
@Test void activeCharacterNativeInteractionProducesCorrelatedObservedAndCancelledRecords() throws Exception {
@@ -101,6 +101,17 @@ class FantasyStickLifecycleNativeTest {
assertTrue(f.order.contains("EventCaptureListener"));
assertTrue(f.order.indexOf("EventCaptureListener") < f.order.indexOf("recipe"));
var actor = new Actor(data); f.players.put(actor.core.id, actor.player); f.activate(actor);
var damage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(damage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(damage.getEntity()).thenReturn(actor.player);
when(damage.getCause()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.DamageCause.LAVA);
when(damage.getFinalDamage()).thenReturn(2.5);
f.dispatcher.accept(damage);
var refusedDamage = mock(org.bukkit.event.entity.EntityDamageEvent.class);
when(refusedDamage.getHandlers()).thenReturn(org.bukkit.event.entity.EntityDamageEvent.getHandlerList());
when(refusedDamage.getEntity()).thenReturn(actor.player);
when(refusedDamage.isCancelled()).thenReturn(true);
f.dispatcher.accept(refusedDamage);
var event = new org.bukkit.event.player.PlayerInteractEvent(actor.player,
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND);
@@ -109,6 +120,13 @@ class FantasyStickLifecycleNativeTest {
org.bukkit.event.block.Action.LEFT_CLICK_AIR, actor.core.inventory.getItem(0), null,
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.OFF_HAND);
f.dispatcher.accept(duplicate);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions) Fixture.field(f.plugin, "sessions");
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED, sessions.exit(actor.player));
for (int attempt = 0; attempt < 80 && f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending() != null; attempt++) {
var callback = f.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
if (callback != null) callback.run();
}
assertNull(f.receipts().find(actor.core.id).toCompletableFuture().get().orElseThrow().pending());
f.plugin.setEnabled(false);
String records;
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
@@ -119,6 +137,11 @@ class FantasyStickLifecycleNativeTest {
records = text.toString();
}
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.started"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.entered"), "Native saved-session activation must reach JSONL");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.character.exited"), "Living exit must follow acknowledged restoration");
assertTrue(records.contains("games.dmg.minecraft.fantasystick.combat.damage"), "Accepted environmental damage must reach JSONL");
assertTrue(records.contains("\"amount\":\"2.5\""));
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.combat.damage", -1).length - 1);
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.observed"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.action.resolved"));
assertTrue(records.contains("games.dmg.minecraft.fantasystick.game.stopped"));
@@ -131,6 +154,39 @@ class FantasyStickLifecycleNativeTest {
}
}
@Test void activePublicChatAndExplicitPrivateCommandsReachLocalJournalWithoutChangingSignedContent() throws Exception {
try(var f=new Fixture(data)) {
f.plugin.setEnabled(true);
var actor=new Actor(data);f.players.put(actor.core.id,actor.player);f.activate(actor);
var body=net.kyori.adventure.text.Component.text("Hello 雪 🌿");
var signed=net.kyori.adventure.chat.SignedMessage.system("Hello 雪 🌿",body);
var chat=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
f.dispatcher.accept(chat);
assertSame(signed,chat.signedMessage());assertSame(body,chat.originalMessage());
var cancelled=new io.papermc.paper.event.player.AsyncChatEvent(false,actor.player,new java.util.HashSet<>(),
io.papermc.paper.chat.ChatRenderer.defaultRenderer(),body,body,signed);
cancelled.setCancelled(true);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
for (var command : java.util.List.of("/msg Alex 私信 🌿", "/r thank you", "/warp secret")) {
var request = mock(org.bukkit.event.player.PlayerCommandPreprocessEvent.class);
when(request.getHandlers()).thenReturn(org.bukkit.event.player.PlayerCommandPreprocessEvent.getHandlerList());
when(request.getPlayer()).thenReturn(actor.player);
when(request.getMessage()).thenReturn(command);
when(request.isCancelled()).thenReturn(command.startsWith("/r "));
f.dispatcher.accept(request);
}
f.plugin.setEnabled(false);
}
var rows=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())rows.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}
var chat=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.chat")).toList();
assertEquals(2,chat.size());assertEquals("Hello 雪 🌿",chat.getFirst().getAsJsonObject("data").get("message").getAsString());assertTrue(chat.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
var privateMessages=rows.stream().filter(row->row.get("type").getAsString().endsWith("character.private_message")).toList();
assertEquals(2,privateMessages.size());assertEquals(java.util.Set.of("私信 🌿","thank you"),privateMessages.stream().map(row->row.getAsJsonObject("data").get("message").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertTrue(privateMessages.stream().allMatch(row->row.getAsJsonObject("data").get("delivery").getAsString().equals("unverified")));
assertTrue(privateMessages.stream().anyMatch(row->row.getAsJsonObject("data").get("cancelled").getAsBoolean()));
assertTrue(privateMessages.stream().allMatch(row->row.get("characterid").getAsString().equals(chat.getFirst().get("characterid").getAsString())));
}
@Test void registeredBookInputOpensAuthoritativeNativePagesWithoutGivingOrChangingItems() throws Exception {
try (var f = new Fixture(data)) {
f.plugin.setEnabled(true);