feat(events): record character session and message outcomes
Release / release (push) Successful in 14m5s
Release / release (push) Successful in 14m5s
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user