Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7c75b6ed | ||
|
|
39881ad6fc | ||
|
|
9737ed0ddb | ||
|
|
53366b3c50 | ||
|
|
84205c0c14 | ||
|
|
2cc053e6ff | ||
|
|
3f104cb0e2 |
No files matched your search
@@ -177,6 +177,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.events.CombatRewardEventBridge combatRewardEvents;
|
||||
private games.dmg.fantasystick.events.CoinEventBridge coinEvents;
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionAuditEvents;
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> switchAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> identityAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> equipmentAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
private games.dmg.fantasystick.events.MessageEventBridge messageAuditEvents;
|
||||
private java.util.concurrent.CompletionStage<Void> configurationDrain = java.util.concurrent.CompletableFuture.completedStage(null);
|
||||
|
||||
@@ -744,9 +752,32 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
|
||||
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
|
||||
partyAuditEvents = new games.dmg.fantasystick.events.PartyEventBridge(eventLog::emit, Clock.systemUTC());partyManagement.attachEvents(partyAuditEvents);admin.attachPartyEvents(partyAuditEvents);
|
||||
partyCombat.attachProtectionEvents(new games.dmg.fantasystick.events.PartyProtectionEventBridge(
|
||||
eventLog::emit, Clock.systemUTC()));
|
||||
combatRewardEvents = new games.dmg.fantasystick.events.CombatRewardEventBridge(eventLog::emit,Clock.systemUTC());combatExperience.attachEvents(combatRewardEvents);
|
||||
coinEvents = new games.dmg.fantasystick.events.CoinEventBridge(eventLog::emit,Clock.systemUTC());characters.attachCoinEvents(coinEvents);admin.attachCoinEvents(coinEvents);
|
||||
sessionAuditEvents = new games.dmg.fantasystick.events.SessionEventBridge(eventLog::emit,Clock.systemUTC());sessions.attachSessionEvents(sessionAuditEvents);
|
||||
switchAuditEvents = new games.dmg.fantasystick.events.SwitchDelayEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Switch deadline event intent failed", failure));
|
||||
sessions.attachSwitchEvents(switchAuditEvents);
|
||||
var identitySessions = sessions;
|
||||
identityAuditEvents = new games.dmg.fantasystick.events.CharacterIdentityEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
playerId -> identitySessions.active(playerId).map(games.dmg.fantasystick.session.EquipmentSessions.Active::session),
|
||||
failure -> getLogger().log(Level.WARNING, "Character identity event intent failed", failure));
|
||||
crafting.attachIdentityEvents(identityAuditEvents);
|
||||
anvilNames.attachIdentityEvents(identityAuditEvents);
|
||||
sessions.attachIdentityEvents(identityAuditEvents);
|
||||
equipmentAuditEvents = new games.dmg.fantasystick.events.EquipmentEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Equipment event intent failed", failure));
|
||||
sessions.attachEquipmentEvents(equipmentAuditEvents);
|
||||
sessionRecoveryAuditEvents = new games.dmg.fantasystick.events.SessionRecoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Session recovery event intent failed", failure));
|
||||
admin.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
sessions.attachSessionRecoveryEvents(sessionRecoveryAuditEvents);
|
||||
sessions.attachDownedEvents(downedEvents);
|
||||
downedController.attachEvents(downedEvents);
|
||||
revive.attachDownedEvents(downedEvents);
|
||||
@@ -756,7 +787,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
id -> presentation.snapshot().get(id),sessionAuditEvents,eventLog::emit,Clock.systemUTC());
|
||||
getServer().getPluginManager().registerEvents(messageAuditEvents,this);
|
||||
} catch (java.io.IOException failure) {
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; messageAuditEvents = null;
|
||||
eventLog = null; eventCapture = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null; switchAuditEvents = null; identityAuditEvents = null; equipmentAuditEvents = null; sessionRecoveryAuditEvents = null; messageAuditEvents = null;
|
||||
getLogger().log(Level.WARNING,"Fantasy Stick local events are unavailable; gameplay remains enabled",failure);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(crafting, this);
|
||||
@@ -831,6 +862,34 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = getServer().addRecipe(new CharacterSheets().recipe());
|
||||
if (!recipeRegistered) throw new IllegalStateException("Character-sheet recipe key is already registered");
|
||||
recipeDiscovery = new RecipeDiscovery(CharacterSheets.RECIPE, getLogger());
|
||||
if (eventLog != null) {
|
||||
var recipeEvents = new games.dmg.fantasystick.events.RecipeDiscoveryEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
|
||||
failure -> getLogger().log(Level.WARNING, "Recipe discovery event intent failed", failure));
|
||||
recipeDiscovery.attachEvents(recipeEvents);
|
||||
eventLog.ready(); // Queue startup recovery before any recipe event handoff.
|
||||
var replaySwitchEvents = switchAuditEvents;
|
||||
switchAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous switch event intent drain failed", failure);
|
||||
if (replaySwitchEvents != null) replaySwitchEvents.replay();
|
||||
});
|
||||
var replayIdentityEvents = identityAuditEvents;
|
||||
identityAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous identity event intent drain failed", failure);
|
||||
if (replayIdentityEvents != null) replayIdentityEvents.replay();
|
||||
});
|
||||
var replayEquipmentEvents = equipmentAuditEvents;
|
||||
equipmentAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous equipment event intent drain failed", failure);
|
||||
if (replayEquipmentEvents != null) replayEquipmentEvents.replay();
|
||||
});
|
||||
var replaySessionRecoveryEvents = sessionRecoveryAuditEvents;
|
||||
sessionRecoveryAuditDrain.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
|
||||
if (replaySessionRecoveryEvents != null) replaySessionRecoveryEvents.replay();
|
||||
});
|
||||
recipeEvents.replay(); // Serial worker replays old intents before new discoveries are captured.
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
|
||||
var onlinePlayers = List.copyOf(getServer().getOnlinePlayers());
|
||||
recipeDiscovery.discoverAll(onlinePlayers);
|
||||
@@ -867,6 +926,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingSwitchAuditEvents = switchAuditEvents; switchAuditEvents = null;
|
||||
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
|
||||
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
|
||||
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = 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;
|
||||
@@ -1170,6 +1233,36 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
if (closingEvents != null) cleanup("session events", closingEvents::close);
|
||||
if (closingCrafting != null) cleanup("craft attempts", closingCrafting::close);
|
||||
if (closingSessions != null) cleanup("equipment sessions", closingSessions::close);
|
||||
if (closingRecipeDiscovery != null) cleanup("recipe discovery event intents", closingRecipeDiscovery::close);
|
||||
if (closingSwitchAuditEvents != null) cleanup("switch event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
switchAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Switch exit drain failed", failure);
|
||||
closingSwitchAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSwitchAuditEvents.drained());
|
||||
});
|
||||
if (closingEquipmentAuditEvents != null) cleanup("equipment event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
equipmentAuditDrain = sessionsDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Equipment session drain failed", failure);
|
||||
closingEquipmentAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingEquipmentAuditEvents.drained());
|
||||
});
|
||||
if (closingSessionRecoveryAuditEvents != null) cleanup("recovery event intent drain", () -> {
|
||||
var sessionsDrained = closingSessions == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingSessions.drained();
|
||||
sessionRecoveryAuditDrain = java.util.concurrent.CompletableFuture.allOf(
|
||||
sessionsDrained.toCompletableFuture(), configurationDrain.toCompletableFuture())
|
||||
.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Recovery owner drain failed", failure);
|
||||
closingSessionRecoveryAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingSessionRecoveryAuditEvents.drained());
|
||||
});
|
||||
if (closingEventLog != null) cleanup("local events", closingEventLog::close);
|
||||
if (closingVitals != null) cleanup("native health hooks", closingVitals::close);
|
||||
unregister(closingModes);
|
||||
@@ -1215,6 +1308,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeRegistered = false; // Retain ownership for a later retry if removal failed.
|
||||
});
|
||||
if (closingCharacters != null) cleanup("character storage", closingCharacters::close);
|
||||
if (closingIdentityAuditEvents != null) cleanup("character identity event intent drain", () -> {
|
||||
var charactersDrained = closingCharacters == null
|
||||
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
|
||||
identityAuditDrain = charactersDrained.handle((ignored, failure) -> {
|
||||
if (failure != null) getLogger().log(Level.WARNING, "Character save drain failed", failure);
|
||||
closingIdentityAuditEvents.close();
|
||||
return null;
|
||||
}).thenCompose(ignored -> closingIdentityAuditEvents.drained());
|
||||
});
|
||||
if (closingRevivals != null) cleanup("revival storage", closingRevivals::close);
|
||||
// Never await IO on the server thread or close receipts ahead of accepted preparations.
|
||||
if (closingReceipts != null) cleanup("equipment receipt drain", () -> {
|
||||
|
||||
@@ -61,7 +61,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
private static final java.util.regex.Pattern ABILITY_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9_]{0,63})\"");
|
||||
private static final java.util.regex.Pattern RARE_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36}):([a-z][a-z0-9-]{0,63})\"");
|
||||
private static final java.util.regex.Pattern RARE_CONFIRM_PAYLOAD = java.util.regex.Pattern.compile("\"([a-f0-9-]{36})\"");
|
||||
private record View(String nonce, ControlInput input, int previous, int next) { }
|
||||
private record View(String nonce, ControlInput input, int start, int navigationPage,
|
||||
int previous, int next) { }
|
||||
private record AdminView(String nonce, UUID administrator, org.bukkit.entity.Player target,
|
||||
games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
int previous, int next) { }
|
||||
@@ -244,6 +245,10 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset, boolean userRequested) {
|
||||
load(input, offset, userRequested, null);
|
||||
}
|
||||
|
||||
private void load(ControlInput input, int offset, boolean userRequested, View from) {
|
||||
if (closed || input.kind() != ControlInput.Kind.BOOK || input.gesture() != ControlInput.Gesture.USE
|
||||
|| !input.stillCurrent()) return;
|
||||
var owner = input.session().player();
|
||||
@@ -255,7 +260,9 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
rareViews.remove(owner);
|
||||
reskillViews.remove(owner);
|
||||
var current=sessions.currentRecord(input.session()).orElse(null);
|
||||
var audit=userRequested&&bookEvents!=null&¤t!=null?bookEvents.request(input,current,offset):null;
|
||||
var navigation=from==null?null:new games.dmg.fantasystick.events.BookEventBridge.Navigation(
|
||||
from.start(),from.navigationPage(),offset);
|
||||
var audit=userRequested&&bookEvents!=null&¤t!=null?bookEvents.request(input,current,offset,navigation):null;
|
||||
var superseded=pendingBookEvents.remove(owner);if(superseded!=null)bookEvents.refused(superseded,"superseded");
|
||||
if(audit!=null)pendingBookEvents.put(owner,audit);
|
||||
Object ticket = new Object();
|
||||
@@ -398,8 +405,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
if (allOverview.size() > VOLUME_SIZE) {
|
||||
Component navigation = Component.text("Character pages\n\n" + (start + 1) + "–" + end + "/" + allOverview.size());
|
||||
if (interactive) {
|
||||
var view = new View(UUID.randomUUID().toString(), input, start > 0 ? start - VOLUME_SIZE : -1,
|
||||
end < allOverview.size() ? end : -1);
|
||||
var view = new View(UUID.randomUUID().toString(), input, start, pages.size() + 1,
|
||||
start > 0 ? start - VOLUME_SIZE : -1, end < allOverview.size() ? end : -1);
|
||||
views.put(input.session().player(), view);
|
||||
if (view.previous() >= 0) navigation = navigation.append(Component.newline())
|
||||
.append(pageButton("Previous pages", view, 0));
|
||||
@@ -775,7 +782,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|
||||
int offset = match.group(2).equals("0") ? view.previous() : view.next();
|
||||
if (offset < 0 || !views.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) { refusePage(player,"stale_or_unavailable_page"); return; }
|
||||
// Reread authoritative data, rather than retaining a stale giant character snapshot.
|
||||
load(view.input(), offset, true);
|
||||
load(view.input(), offset, true, view);
|
||||
}
|
||||
|
||||
private void refusePage(org.bukkit.entity.Player player,String reason){
|
||||
|
||||
@@ -17,6 +17,12 @@ public final class CharacterAnvilNames implements Listener {
|
||||
private final Consumer<Failure> failures;
|
||||
private final org.bukkit.inventory.ItemFactory items;
|
||||
private final Consumer<games.dmg.fantasystick.persistence.CharacterRecord> acknowledged;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (identityEvents != null) throw new IllegalStateException("Anvil identity events already attached");
|
||||
identityEvents = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
/** Diagnostics may run on the persistence worker and must not access native player state. */
|
||||
public CharacterAnvilNames(CharacterStorage storage, Consumer<Failure> failures, org.bukkit.inventory.ItemFactory items) {
|
||||
@@ -44,9 +50,19 @@ public final class CharacterAnvilNames implements Listener {
|
||||
if (name.equals(name(input))) return;
|
||||
var player = event.getPlayer().getUniqueId();
|
||||
var character = identity.orElseThrow().character();
|
||||
storage.renameCharacter(character, name, identity.orElseThrow().generation()).whenComplete((renamed, failure) -> {
|
||||
if (failure != null || renamed.isEmpty()) { failures.accept(new Failure(player, character, failure)); return; }
|
||||
try { acknowledged.accept(renamed.orElseThrow()); }
|
||||
var audit = identityEvents;
|
||||
var action = audit == null ? null : audit.rename(event.getPlayer(), character, name);
|
||||
storage.renameCharacterObserved(character, name, identity.orElseThrow().generation()).whenComplete((outcome, failure) -> {
|
||||
if (failure != null || outcome.record().isEmpty()) {
|
||||
failures.accept(new Failure(player, character, failure));
|
||||
if (audit != null) audit.resolved(action, "refused", failure == null ? outcome.status() : "save_failed");
|
||||
return;
|
||||
}
|
||||
if (audit != null) {
|
||||
if ("renamed".equals(outcome.status())) audit.renamed(action, outcome.before().orElseThrow(), outcome.record().orElseThrow());
|
||||
audit.resolved(action, "renamed".equals(outcome.status()) ? "accepted" : "refused", outcome.status());
|
||||
}
|
||||
try { acknowledged.accept(outcome.record().orElseThrow()); }
|
||||
catch (RuntimeException | LinkageError error) { failures.accept(new Failure(player, character, error)); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
private final Consumer<Failure> failures;
|
||||
private final java.util.concurrent.ConcurrentMap<UUID, Attempt> pending = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private volatile boolean closed;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private final java.util.Set<CraftItemEvent> handledCrafts =
|
||||
java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>());
|
||||
|
||||
public void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events) {
|
||||
if (identityEvents != null || !pending.isEmpty()) throw new IllegalStateException("Craft identity events already attached or attempts started");
|
||||
identityEvents = java.util.Objects.requireNonNull(events);
|
||||
}
|
||||
// Server-thread reservation outlives close/reopen invalidation and includes client-sync callbacks.
|
||||
private final java.util.Set<UUID> delivering = new java.util.HashSet<>();
|
||||
|
||||
@@ -50,22 +58,30 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
if (event.isCancelled() || !(event.getRecipe() instanceof Keyed keyed)
|
||||
|| !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
handledCrafts.add(event); // MONITOR can distinguish our containment from an existing veto.
|
||||
java.util.Optional<ManualCraftPlan> plan;
|
||||
try {
|
||||
plan = ManualCraftPlan.capture(event);
|
||||
} catch (RuntimeException failure) {
|
||||
failures.accept(new Failure(event.getWhoClicked().getUniqueId(), List.of(), "plan", failure));
|
||||
refuseCraft(event, "failed", "plan");
|
||||
return;
|
||||
} finally {
|
||||
// capture must see the original cancellation state, but exceptions must never expose the plain preview.
|
||||
event.setCancelled(true);
|
||||
}
|
||||
if (closed || plan.isEmpty() || !(event.getWhoClicked() instanceof Player player) || !player.isOnline()
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) return;
|
||||
|| !plan.orElseThrow().matches(player.getOpenInventory())) {
|
||||
refuseCraft(event, "refused", "invalid_or_stale_preflight");
|
||||
return;
|
||||
}
|
||||
var attempt = new Attempt(player.getUniqueId(), player, plan.orElseThrow());
|
||||
if (delivering.contains(attempt.id) || pending.putIfAbsent(attempt.id, attempt) != null) return;
|
||||
var audit = identityEvents;
|
||||
if (audit != null) attempt.craft = audit.craft(player, attempt.plan.route().name().toLowerCase(java.util.Locale.ROOT), attempt.plan.quantity());
|
||||
try {
|
||||
var records = java.util.stream.IntStream.range(0, attempt.plan.quantity()).mapToObj(index -> allocate.get()).toList();
|
||||
attempt.records = records;
|
||||
attempt.characters = records.stream().map(CharacterRecord::id).toList();
|
||||
attempt.preparation = storage.prepareCraft(records);
|
||||
attempt.preparation.ready().whenComplete((batch, failure) -> schedule(attempt, batch, failure));
|
||||
@@ -73,9 +89,24 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "prepare", failure));
|
||||
if (audit != null) audit.resolved(attempt.craft, "failed", "prepare", 0);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onCraftDecision(CraftItemEvent event) {
|
||||
if (!(event.getRecipe() instanceof Keyed keyed) || !CharacterSheets.RECIPE.equals(keyed.getKey())) return;
|
||||
if (handledCrafts.remove(event) || !event.isCancelled()) return;
|
||||
refuseCraft(event, "cancelled", "prior_cancellation");
|
||||
}
|
||||
|
||||
private void refuseCraft(CraftItemEvent event, String outcome, String reason) {
|
||||
var audit = identityEvents;
|
||||
if (audit == null || !(event.getWhoClicked() instanceof Player player)) return;
|
||||
var action = audit.craft(player, "unknown", 0);
|
||||
audit.resolved(action, outcome, reason, 0);
|
||||
}
|
||||
|
||||
private void schedule(Attempt attempt, CharacterStorage.CraftBatch batch, Throwable failure) {
|
||||
if (closed || pending.get(attempt.id) != attempt) {
|
||||
abandon(attempt);
|
||||
@@ -98,22 +129,45 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
}
|
||||
if (!delivering.add(attempt.id)) return;
|
||||
try {
|
||||
if (failure != null) failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
else if (attempt.player.isOnline()) {
|
||||
var audit = identityEvents;
|
||||
if (failure != null) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "save", failure));
|
||||
if (audit != null) audit.resolved(attempt.craft, "failed", "save", 0);
|
||||
} else if (attempt.player.isOnline()) {
|
||||
attempt.handoffStarted = true;
|
||||
if (attempt.plan.route() == ManualCraftPlan.Route.DROP) {
|
||||
var result = ManualDropDelivery.commit(attempt.plan, batch, attempt.player::getOpenInventory,
|
||||
() -> !closed && pending.get(attempt.id) == attempt && attempt.player.isOnline(), drops.apply(attempt.player));
|
||||
if (audit != null) {
|
||||
for (int index = 0; index < result.completed(); index++) audit.created(attempt.craft, attempt.records.get(index), index);
|
||||
var outcome = switch (result.status()) {
|
||||
case COMPLETE -> "accepted";
|
||||
case CANCELLED -> "cancelled";
|
||||
case STALE -> "stale";
|
||||
case UNCONFIRMED -> "uncertain";
|
||||
case UNAVAILABLE, UNSUPPORTED_ROUTE -> "refused";
|
||||
};
|
||||
audit.resolved(attempt.craft, outcome,
|
||||
"drop_" + result.status().name().toLowerCase(java.util.Locale.ROOT), result.completed());
|
||||
}
|
||||
if (result.status() != ManualDropDelivery.Status.COMPLETE) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "drop-" + result.status().name().toLowerCase(java.util.Locale.ROOT), null));
|
||||
}
|
||||
} else {
|
||||
var result = ManualCraftDelivery.commit(attempt.plan, batch, attempt.player.getOpenInventory());
|
||||
attempt.handoffStarted = result == ManualCraftDelivery.Result.DELIVERED;
|
||||
if (audit != null) {
|
||||
if (attempt.handoffStarted) for (int index = 0; index < attempt.records.size(); index++)
|
||||
audit.created(attempt.craft, attempt.records.get(index), index);
|
||||
audit.resolved(attempt.craft, attempt.handoffStarted ? "accepted"
|
||||
: result == ManualCraftDelivery.Result.STALE ? "stale" : "refused",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.handoffStarted ? attempt.records.size() : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException unexpected) {
|
||||
failures.accept(new Failure(attempt.id, attempt.characters, "delivery", unexpected));
|
||||
if (identityEvents != null) identityEvents.resolved(attempt.craft, "uncertain", "delivery", 0);
|
||||
} finally {
|
||||
pending.remove(attempt.id, attempt);
|
||||
abandon(attempt);
|
||||
@@ -132,6 +186,8 @@ public final class ManualCraftingController implements Listener, AutoCloseable {
|
||||
final Player player;
|
||||
final ManualCraftPlan plan;
|
||||
List<UUID> characters = List.of();
|
||||
List<CharacterRecord> records = List.of();
|
||||
games.dmg.fantasystick.events.CharacterIdentityEventBridge.Craft craft;
|
||||
CharacterStorage.CraftPreparation preparation;
|
||||
volatile boolean handoffStarted;
|
||||
final java.util.concurrent.atomic.AtomicBoolean cleanupObserved = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
@@ -11,37 +11,50 @@ import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
/** Idempotent recipe-book visibility; recipe registration and crafting remain separate authorities. */
|
||||
public final class RecipeDiscovery implements Listener {
|
||||
public final class RecipeDiscovery implements Listener, AutoCloseable {
|
||||
private final NamespacedKey recipe;
|
||||
private final Logger logger;
|
||||
private games.dmg.fantasystick.events.RecipeDiscoveryEventBridge events;
|
||||
|
||||
public RecipeDiscovery(NamespacedKey recipe, Logger logger) {
|
||||
this.recipe = Objects.requireNonNull(recipe, "recipe");
|
||||
this.logger = Objects.requireNonNull(logger, "logger");
|
||||
}
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.RecipeDiscoveryEventBridge bridge) {
|
||||
events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public void discoverAll(Iterable<? extends Player> players) {
|
||||
for (var player : players) discover(player);
|
||||
for (var player : players) discover(player, "enable_online");
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
discover(event.getPlayer());
|
||||
discover(event.getPlayer(), "join");
|
||||
}
|
||||
|
||||
public void discover(Player player) {
|
||||
public void discover(Player player) { discover(player, "join"); }
|
||||
|
||||
private void discover(Player player, String trigger) {
|
||||
Objects.requireNonNull(player, "player");
|
||||
try {
|
||||
if (player.hasDiscoveredRecipe(recipe)) return;
|
||||
player.discoverRecipes(List.of(recipe));
|
||||
int notifications = player.discoverRecipes(List.of(recipe));
|
||||
if (!player.hasDiscoveredRecipe(recipe)) {
|
||||
logger.warning("Could not discover character-sheet recipe for " + label(player));
|
||||
}
|
||||
if (events != null) events.failed(player, trigger, "native_unavailable");
|
||||
} else if (events != null) events.discovered(player, trigger, notifications);
|
||||
} catch (RuntimeException failure) {
|
||||
logger.log(Level.WARNING, "Could not discover character-sheet recipe for " + label(player), failure);
|
||||
if (events != null) events.failed(player, trigger, "native_failed");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
if (events != null) events.close();
|
||||
}
|
||||
|
||||
private static String label(Player player) {
|
||||
try { return player.getName(); }
|
||||
catch (RuntimeException ignored) { return "an unidentified player"; }
|
||||
|
||||
@@ -14,8 +14,16 @@ import org.bukkit.Location;
|
||||
|
||||
/** Stable feature-owned character-book outcomes; event delivery never authorizes or rolls back an open. */
|
||||
public final class BookEventBridge {
|
||||
public record Navigation(int sourceVolumeStart, int sourceNavigationPage, int destinationVolumeStart) {
|
||||
public Navigation {
|
||||
if (sourceVolumeStart < 0 || sourceNavigationPage < 1 || sourceNavigationPage > 100
|
||||
|| destinationVolumeStart < 0 || sourceVolumeStart == destinationVolumeStart)
|
||||
throw new IllegalArgumentException("Invalid book volume navigation");
|
||||
}
|
||||
}
|
||||
|
||||
public record Request(UUID actionId, EventInput.Context context, String actionKind, String viewKind,
|
||||
int offset, BigInteger requestedRevision) {
|
||||
int offset, BigInteger requestedRevision, Navigation navigation) {
|
||||
public Request {
|
||||
Objects.requireNonNull(actionId); Objects.requireNonNull(context); Objects.requireNonNull(actionKind);
|
||||
Objects.requireNonNull(viewKind); Objects.requireNonNull(requestedRevision);
|
||||
@@ -32,10 +40,15 @@ public final class BookEventBridge {
|
||||
}
|
||||
|
||||
public Request request(ControlInput input, CharacterRecord current, int offset) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(current);
|
||||
return request(input, current, offset, null);
|
||||
}
|
||||
|
||||
public Request request(ControlInput input, CharacterRecord current, int offset, Navigation navigation) {
|
||||
Objects.requireNonNull(input); Objects.requireNonNull(current);
|
||||
var request = new Request(UUID.randomUUID(), context(input, current.name()),
|
||||
offset == 0 ? "character_book_open" : "character_book_navigate", "active", offset,
|
||||
current.revision());
|
||||
navigation == null ? "character_book_open" : "character_book_navigate", "active", offset,
|
||||
current.revision(), navigation);
|
||||
emit("action.observed", request, "observed", null, null, null, null);
|
||||
return request;
|
||||
}
|
||||
@@ -47,7 +60,7 @@ public final class BookEventBridge {
|
||||
public void refused(org.bukkit.entity.Player player, games.dmg.fantasystick.session.EquipmentSessions.Active active,
|
||||
CharacterRecord current, String actionKind, String reason) {
|
||||
Objects.requireNonNull(player); Objects.requireNonNull(active); Objects.requireNonNull(current);
|
||||
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision());
|
||||
var request=new Request(UUID.randomUUID(),context(player,active,current.name()),actionKind,"active",-1,current.revision(),null);
|
||||
emit("action.observed",request,"observed",null,null,null,null);
|
||||
refused(request,reason);
|
||||
}
|
||||
@@ -60,7 +73,7 @@ public final class BookEventBridge {
|
||||
Objects.requireNonNull(loaded);
|
||||
emit("action.resolved", request, "accepted", "acknowledged", loaded.revision(), presentedPages,
|
||||
presentedVolumes);
|
||||
emit(request.offset() == 0 ? "character_book.opened" : "character_book.navigated", request,
|
||||
emit(request.navigation() == null ? "character_book.opened" : "character_book.navigated", request,
|
||||
"accepted", "acknowledged", loaded.revision(), presentedPages, presentedVolumes);
|
||||
}
|
||||
|
||||
@@ -68,7 +81,18 @@ public final class BookEventBridge {
|
||||
Integer pages, Integer volumes) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", request.actionKind()); data.put("view_kind", request.viewKind());
|
||||
data.put("section", "character_inspection"); data.put("control_kind", "book"); data.put("control_slot", "0");
|
||||
data.put("section", request.navigation() == null ? "overview" : "volume_navigation");
|
||||
data.put("control_kind", "book"); data.put("control_slot", "0");
|
||||
if (request.navigation() != null) {
|
||||
var navigation = request.navigation();
|
||||
data.put("source_volume_start", Integer.toString(navigation.sourceVolumeStart()));
|
||||
data.put("source_navigation_page", navigation.sourceNavigationPage());
|
||||
data.put("destination_volume_start", Integer.toString(navigation.destinationVolumeStart()));
|
||||
data.put("destination_page", "1");
|
||||
data.put("destination_section", navigation.destinationVolumeStart() == 0 ? "overview" : "volume_start");
|
||||
data.put("volume_direction", navigation.destinationVolumeStart() > navigation.sourceVolumeStart()
|
||||
? "next" : "previous");
|
||||
}
|
||||
data.put("offset", request.offset()<0?"unknown":Integer.toString(request.offset()));
|
||||
data.put("requested_revision", request.requestedRevision().toString()); data.put("outcome", outcome);
|
||||
if (reason != null) data.put(type.startsWith("character_book.") ? "native_open" : "reason", reason);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captures actual identity-creating and identity-changing native actions, never previews. */
|
||||
public final class CharacterIdentityEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
private final Function<UUID, Optional<UUID>> activeSession;
|
||||
private static final int MAX_RECENT_HOLDERS = 8192;
|
||||
private final Map<UUID, Holder> lastObserved = new LinkedHashMap<>(16, .75f, true) {
|
||||
@Override protected boolean removeEldestEntry(Map.Entry<UUID, Holder> eldest) {
|
||||
return size() > MAX_RECENT_HOLDERS; // Eviction is uncertainty, never an invented transfer.
|
||||
}
|
||||
};
|
||||
private record Holder(UUID player, UUID session, EventInput.Location entryLocation) { }
|
||||
|
||||
public CharacterIdentityEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Function<UUID, Optional<UUID>> activeSession,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.activeSession = Objects.requireNonNull(activeSession);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder),
|
||||
"character-identity-event-intents", java.util.Set.of("character.created", "character.renamed"),
|
||||
sink, Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
|
||||
/** A later acknowledged activation proves a transfer only if we observed a different prior holder. */
|
||||
public void entered(Player player, UUID session, CharacterRecord record) {
|
||||
try {
|
||||
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), record.id(), record.name(),
|
||||
session, location(player.getLocation()));
|
||||
Holder previous;
|
||||
synchronized (lastObserved) {
|
||||
previous = lastObserved.put(record.id(), new Holder(actor.playerId(), session, actor.location()));
|
||||
}
|
||||
if (previous == null || previous.player().equals(actor.playerId())) return;
|
||||
var action = id(previous.session(), session.toString());
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("character_id", record.id().toString());
|
||||
values.put("generation", record.sheetGeneration().toString());
|
||||
values.put("from_player_id", previous.player().toString());
|
||||
values.put("to_player_id", actor.playerId().toString());
|
||||
values.put("from_session_id", previous.session().toString());
|
||||
values.put("to_session_id", session.toString());
|
||||
values.put("observation_boundary", "acknowledged_activation");
|
||||
values.put("custody_quality", "activation_after_prior_holder");
|
||||
if (previous.entryLocation() != null) values.put("from_location", locationMap(previous.entryLocation(), "cached_at_previous_activation"));
|
||||
else values.put("from_location_status", "unavailable");
|
||||
if (actor.location() != null) values.put("to_location", locationMap(actor.location(), "exact"));
|
||||
else values.put("to_location_status", "unavailable");
|
||||
values.put("outcome", "observed");
|
||||
emit("character.transfer_observed", action, clock.instant(), actor, values, "transfer_observed");
|
||||
} catch (RuntimeException ignored) { /* Observation never authorizes a character session. */ }
|
||||
}
|
||||
|
||||
public record Craft(UUID action, Instant time, EventInput.Context actor, String route, int quantity,
|
||||
UUID holderSession) { }
|
||||
|
||||
public Craft craft(Player player, String route, int quantity) {
|
||||
try {
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var craft = new Craft(UUID.randomUUID(), clock.instant(), context, route, quantity,
|
||||
activeSession.apply(context.playerId()).orElse(null));
|
||||
emit("action.observed", craft.action(), craft.time(), context, Map.of(
|
||||
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
|
||||
"delivery_route", route, "quantity", Integer.toString(quantity), "outcome", "observed"), "observed");
|
||||
return craft;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public record Rename(UUID action, Instant time, EventInput.Context actor, UUID character,
|
||||
String requestedName, UUID holderSession) { }
|
||||
|
||||
public Rename rename(Player player, UUID character, String requestedName) {
|
||||
try {
|
||||
var actor = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var action = new Rename(UUID.randomUUID(), clock.instant(), actor, character, requestedName,
|
||||
activeSession.apply(actor.playerId()).orElse(null));
|
||||
emit("action.observed", action.action(), action.time(), actor, Map.of(
|
||||
"action_kind", "character_rename", "character_id", character.toString(),
|
||||
"outcome", "observed"), "observed");
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void renamed(Rename action, CharacterRecord before, CharacterRecord after) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_rename");
|
||||
values.put("anvil_action_id", action.action().toString());
|
||||
holder(values, action.actor().playerId(), action.holderSession());
|
||||
values.put("character_id", after.id().toString());
|
||||
values.put("generation", after.sheetGeneration().toString());
|
||||
values.put("before_name", before.name());
|
||||
values.put("after_name", after.name());
|
||||
values.put("revision_before", before.revision().toString());
|
||||
values.put("revision_after", after.revision().toString());
|
||||
values.put("native_item_outcome", "unverified");
|
||||
values.put("outcome", "acknowledged");
|
||||
durable("character.renamed", action.action(), action.time(), action.actor(), values, "renamed");
|
||||
}
|
||||
|
||||
public void resolved(Rename action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
emit("action.resolved", action.action(), clock.instant(), action.actor(), Map.of(
|
||||
"action_kind", "character_rename", "character_id", action.character().toString(),
|
||||
"outcome", outcome, "reason", reason), "resolved");
|
||||
}
|
||||
|
||||
public void created(Craft craft, CharacterRecord record, int index) {
|
||||
if (craft == null) return;
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_craft");
|
||||
values.put("craft_operation_id", craft.action().toString());
|
||||
holder(values, craft.actor().playerId(), craft.holderSession());
|
||||
values.put("character_id", record.id().toString());
|
||||
values.put("character_name", record.name());
|
||||
values.put("generation", record.sheetGeneration().toString());
|
||||
values.put("revision", record.revision().toString());
|
||||
values.put("recipe_id", "fantasystick:character_sheet");
|
||||
values.put("quantity", Integer.toString(craft.quantity()));
|
||||
values.put("index", index);
|
||||
values.put("delivery_route", craft.route());
|
||||
values.put("outcome", "acknowledged_delivered");
|
||||
durable("character.created", craft.action(), craft.time(), craft.actor(), values, record.id().toString());
|
||||
}
|
||||
|
||||
public void resolved(Craft craft, String outcome, String reason, int delivered) {
|
||||
if (craft == null) return;
|
||||
emit("action.resolved", craft.action(), clock.instant(), craft.actor(), Map.of(
|
||||
"action_kind", "character_craft", "recipe_id", "fantasystick:character_sheet",
|
||||
"delivery_route", craft.route(), "quantity", Integer.toString(craft.quantity()),
|
||||
"delivered", Integer.toString(delivered), "outcome", outcome, "reason", reason), "resolved");
|
||||
}
|
||||
|
||||
private void durable(String kind, UUID action, Instant time, EventInput.Context context,
|
||||
Map<String, Object> values, String stage) {
|
||||
try { outbox.accepted(new EventInput(id(action, stage), action, time, kind, context, values)); }
|
||||
catch (RuntimeException ignored) { /* Auditing never changes acknowledged gameplay. */ }
|
||||
}
|
||||
|
||||
private void emit(String kind, UUID action, Instant time, EventInput.Context context,
|
||||
Map<String, Object> values, String stage) {
|
||||
try { sink.apply(new EventInput(id(action, stage), action, time, kind, context, values)); }
|
||||
catch (RuntimeException ignored) { /* The audit cannot change native craft outcomes. */ }
|
||||
}
|
||||
|
||||
private static void holder(Map<String, Object> data, UUID player, UUID session) {
|
||||
data.put("holder_player_id", player.toString());
|
||||
data.put("holder_session_status", session == null ? "no_active_session" : "active_at_request");
|
||||
if (session != null) data.put("holder_session_id", session.toString());
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationMap(EventInput.Location location, String quality) {
|
||||
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
|
||||
"x", location.x(), "y", location.y(), "z", location.z(),
|
||||
"captured_at", location.capturedAt().toString(), "quality", quality);
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:character-identity:" + action + ':' + stage)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.items.EquipmentStorage;
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes only proved native box custody and the owning acknowledged receipt boundary. */
|
||||
public final class EquipmentEventBridge implements AutoCloseable {
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Clock clock;
|
||||
|
||||
public EquipmentEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.outbox = new FeatureEventOutbox(Objects.requireNonNull(dataFolder), "equipment-event-intents",
|
||||
Set.of("equipment.packed", "equipment.restored", "equipment.recovery_resolved"), sink,
|
||||
Objects.requireNonNull(failures));
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
|
||||
public record Capture(EventInput.Context context, EquipmentStorage.Snapshot box, Instant observedAt,
|
||||
UUID session, UUID character) { }
|
||||
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, UUID session, UUID character,
|
||||
String kind) { }
|
||||
|
||||
public Action action(Player player, UUID session, UUID character, String kind) {
|
||||
try {
|
||||
// A receipt proves the character UUID, not its current display name. Keep that UUID
|
||||
// in the payload rather than inventing an incomplete character envelope context.
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), context, session, character, kind);
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", "observed");
|
||||
emitAction(action, "action.observed", "observed", values);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
emitAction(action, "action.resolved", "resolved", values);
|
||||
}
|
||||
|
||||
public void recovery(Action action, UUID session, UUID character, String priorPhase, String currentPhase,
|
||||
String quality, String outcome, String reason, BigInteger receiptRevision) {
|
||||
if (action == null) return;
|
||||
var values = actionFields(action);
|
||||
values.put("recovery_operation_id", action.id().toString());
|
||||
values.put("session_id", session.toString());
|
||||
values.put("character_id", character.toString());
|
||||
values.put("prior_phase", priorPhase);
|
||||
values.put("current_phase", currentPhase);
|
||||
values.put("custody_quality", quality);
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
if (receiptRevision != null) values.put("receipt_revision", receiptRevision.toString());
|
||||
try { outbox.accepted(new EventInput(id(action.id(), "recovery_resolved"), action.id(), clock.instant(),
|
||||
"equipment.recovery_resolved", action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> actionFields(Action action) {
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", action.kind());
|
||||
if (action.session() != null) values.put("session_id", action.session().toString());
|
||||
if (action.character() != null) values.put("character_id", action.character().toString());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emitAction(Action action, String kind, String stage, Map<String, Object> values) {
|
||||
try { sink.apply(new EventInput(id(action.id(), stage + ':' + values.get("outcome") + ':' + values.getOrDefault("reason", "")), action.id(),
|
||||
stage.equals("observed") ? action.time() : clock.instant(), kind, action.context(), values)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
public Capture capture(Player player, UUID session, CharacterRecord character, EquipmentStorage.Snapshot box) {
|
||||
return capture(player, session, character.id(), character.name(), box);
|
||||
}
|
||||
|
||||
/** Recovery can prove a receipt's character UUID without proving a current character name. */
|
||||
public Capture capture(Player player, UUID session, UUID character, String characterName,
|
||||
EquipmentStorage.Snapshot box) {
|
||||
try {
|
||||
return new Capture(new EventInput.Context(player.getUniqueId(), player.getName(),
|
||||
characterName == null ? null : character, characterName,
|
||||
characterName == null ? null : session, location(player.getLocation())),
|
||||
box, clock.instant(), session, character);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void packed(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("box_custody", "main_storage");
|
||||
values.put("outcome", "acknowledged_packed");
|
||||
emit(captured, "equipment.packed", "packed", values);
|
||||
}
|
||||
|
||||
public void restored(Capture captured, BigInteger revision) {
|
||||
if (captured == null || revision == null) return;
|
||||
var values = fields(captured, revision);
|
||||
values.put("displaced_head_handling", captured.box().displacedHead()
|
||||
? captured.box().boxSource().equals("cursor") ? "cursor" : "freed_box_slot" : "none");
|
||||
values.put("outcome", "acknowledged_restored");
|
||||
emit(captured, "equipment.restored", "restored", values);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Capture captured, BigInteger revision) {
|
||||
var box = captured.box();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("session_id", captured.session().toString());
|
||||
values.put("character_id", captured.character().toString());
|
||||
values.put("equipment_operation_id", captured.session().toString());
|
||||
values.put("receipt_revision", revision.toString());
|
||||
values.put("slot_count", box.packedSlots());
|
||||
values.put("slot_classes", box.slotClasses());
|
||||
values.put("hotbar_slot_count", box.hotbarSlots());
|
||||
values.put("armor_slot_count", box.armorSlots());
|
||||
values.put("box_source", box.boxSource());
|
||||
values.put("box_slot", box.boxSlot());
|
||||
return values;
|
||||
}
|
||||
|
||||
private void emit(Capture captured, String type, String phase, Map<String, Object> values) {
|
||||
try { outbox.accepted(new EventInput(id(captured.session(), phase), captured.session(), captured.observedAt(),
|
||||
type, captured.context(), values)); }
|
||||
catch (RuntimeException ignored) { /* Audit failures cannot change native equipment custody. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private static UUID id(UUID session, String stage) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:equipment:" + session + ':' + stage)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/** Per-feature recoverable intent for acknowledged domain outcomes. Never replays gameplay. */
|
||||
final class FeatureEventOutbox implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Set<String> types;
|
||||
private final Path directory;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-feature-event-intents"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
FeatureEventOutbox(Path data, String folder, Set<String> types,
|
||||
Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Consumer<Throwable> failures) {
|
||||
if (!folder.matches("[a-z-]+")) throw new IllegalArgumentException("Unsafe event intent folder");
|
||||
directory = data.toAbsolutePath().resolve(folder);
|
||||
this.types = Set.copyOf(types);
|
||||
this.sink = sink;
|
||||
this.failures = failures;
|
||||
}
|
||||
|
||||
void accepted(EventInput event) {
|
||||
try { worker.execute(() -> {
|
||||
try { publish(write(event)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
void replay() {
|
||||
if (worker.isShutdown()) return;
|
||||
try { worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
try (var files = Files.list(directory)) {
|
||||
for (var path : files.filter(value -> value.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); } catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private Path path(EventInput event) { return directory.resolve(event.id() + ".pending"); }
|
||||
|
||||
private EventInput write(EventInput event) throws IOException {
|
||||
if (!types.contains(event.type())) throw new IOException("Unsupported character intent type");
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event directory");
|
||||
Files.createDirectories(directory);
|
||||
var target = path(event);
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
|
||||
var fields = new Properties();
|
||||
fields.setProperty("id", event.id().toString());
|
||||
fields.setProperty("action", event.actionId().toString());
|
||||
fields.setProperty("time", event.time().toString());
|
||||
fields.setProperty("type", event.type());
|
||||
var context = event.context();
|
||||
if (context != null) {
|
||||
fields.setProperty("player", context.playerId().toString());
|
||||
fields.setProperty("player_name", context.playerName());
|
||||
}
|
||||
if (context != null && context.characterId() != null) {
|
||||
fields.setProperty("character", context.characterId().toString());
|
||||
fields.setProperty("character_name", context.characterName());
|
||||
fields.setProperty("session", context.sessionId().toString());
|
||||
}
|
||||
if (context != null && context.location() != null) {
|
||||
var location = context.location();
|
||||
fields.setProperty("world", location.worldId().toString());
|
||||
fields.setProperty("world_name", location.worldName());
|
||||
fields.setProperty("x", Double.toString(location.x()));
|
||||
fields.setProperty("y", Double.toString(location.y()));
|
||||
fields.setProperty("z", Double.toString(location.z()));
|
||||
fields.setProperty("captured_at", location.capturedAt().toString());
|
||||
fields.setProperty("quality", location.quality());
|
||||
}
|
||||
event.data().forEach((key, value) -> {
|
||||
if (value instanceof String text) fields.setProperty("data." + key, "s" + text);
|
||||
else if (value instanceof Boolean bool) fields.setProperty("data." + key, "b" + bool);
|
||||
else if (value instanceof Integer number) fields.setProperty("data." + key, "i" + number);
|
||||
else if (value instanceof Double number && Double.isFinite(number))
|
||||
fields.setProperty("data." + key, "d" + number);
|
||||
else if (value instanceof java.util.List<?> list && list.stream().allMatch(item -> item instanceof String text
|
||||
&& (text.equals("hotbar") || text.equals("armor"))))
|
||||
fields.setProperty("data." + key, "l" + String.join(",", list.stream().map(String.class::cast).toList()));
|
||||
else throw new IllegalArgumentException("Unsupported feature intent value");
|
||||
});
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged character identity event");
|
||||
var bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
|
||||
var temporary = Files.createTempFile(directory, ".identity-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return event;
|
||||
}
|
||||
|
||||
private EventInput read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe character event intent");
|
||||
var fields = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Character event intent too large");
|
||||
fields.load(new ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var id = UUID.fromString(fields.getProperty("id"));
|
||||
var action = UUID.fromString(fields.getProperty("action"));
|
||||
var type = fields.getProperty("type");
|
||||
if (!path.equals(directory.resolve(id + ".pending")) || !types.contains(type))
|
||||
throw new IOException("Invalid character event intent identity");
|
||||
EventInput.Location location = fields.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
|
||||
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
|
||||
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("captured_at")),
|
||||
fields.getProperty("quality")) : null;
|
||||
var character = fields.containsKey("character") ? UUID.fromString(fields.getProperty("character")) : null;
|
||||
if (!fields.containsKey("player") && (character != null || fields.containsKey("session") || location != null))
|
||||
throw new IOException("Unbound event context");
|
||||
var context = fields.containsKey("player")
|
||||
? new EventInput.Context(UUID.fromString(fields.getProperty("player")),
|
||||
fields.getProperty("player_name"), character, fields.getProperty("character_name"),
|
||||
fields.containsKey("session") ? UUID.fromString(fields.getProperty("session")) : null, location)
|
||||
: null;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
for (var key : fields.stringPropertyNames()) if (key.startsWith("data.")) {
|
||||
var value = fields.getProperty(key);
|
||||
if (value.isEmpty()) throw new IOException("Invalid character event value");
|
||||
data.put(key.substring(5), switch (value.charAt(0)) {
|
||||
case 's' -> value.substring(1);
|
||||
case 'b' -> {
|
||||
if (!value.equals("btrue") && !value.equals("bfalse")) throw new IOException("Invalid boolean");
|
||||
yield Boolean.valueOf(value.substring(1));
|
||||
}
|
||||
case 'i' -> Integer.valueOf(value.substring(1));
|
||||
case 'd' -> {
|
||||
var number = Double.valueOf(value.substring(1));
|
||||
if (!Double.isFinite(number)) throw new IOException("Non-finite event coordinate");
|
||||
yield number;
|
||||
}
|
||||
case 'l' -> {
|
||||
var encoded = value.substring(1);
|
||||
var list = encoded.isEmpty() ? java.util.List.<String>of() : java.util.List.of(encoded.split(","));
|
||||
if (!list.stream().allMatch(item -> item.equals("hotbar") || item.equals("armor")))
|
||||
throw new IOException("Invalid slot class");
|
||||
yield list;
|
||||
}
|
||||
default -> throw new IOException("Invalid feature event value type");
|
||||
});
|
||||
}
|
||||
return new EventInput(id, action, Instant.parse(fields.getProperty("time")), type, context, data);
|
||||
} catch (RuntimeException failure) { throw new IOException("Invalid character event intent", failure); }
|
||||
}
|
||||
|
||||
private void publish(EventInput event) {
|
||||
try { sink.apply(event).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(path(event))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private void report(Throwable error) {
|
||||
try { failures.accept(error); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Character event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
CompletionStage<Void> drained() { return drained; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.party.CombatSources;
|
||||
import games.dmg.fantasystick.party.PartyRoster;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
|
||||
/** Captures native protection decisions without changing the native damage/event authority. */
|
||||
public final class PartyProtectionEventBridge {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
|
||||
private final Consumer<EventInput> sink;
|
||||
private final Clock clock;
|
||||
|
||||
public PartyProtectionEventBridge(Consumer<EventInput> sink, Clock clock) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
}
|
||||
|
||||
public Action damage(EntityDamageEvent event, CombatSources.Origin origin,
|
||||
CombatSources.Origin recipient, Player target,
|
||||
PartyRoster.Party sourceParty, PartyRoster.Party targetParty, String reason) {
|
||||
try {
|
||||
var id = UUID.randomUUID();
|
||||
var time = clock.instant();
|
||||
Entity causing = event.getDamageSource().getCausingEntity();
|
||||
Entity direct = event.getDamageSource().getDirectEntity();
|
||||
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
|
||||
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
|
||||
null, null, null, location(source.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "party_damage_protection");
|
||||
if (context == null) data.put("actor_kind", "system");
|
||||
data.put("damage_cause", event.getCause().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_kind", causing == null ? "provenance" : causing instanceof Player ? "player"
|
||||
: causing.getType() == null ? "unavailable" : causing.getType().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("source_player_id", origin.player().toString());
|
||||
if (causing != null && causing.getUniqueId() != null)
|
||||
data.put("causing_entity_id", causing.getUniqueId().toString());
|
||||
if (direct != null && direct.getUniqueId() != null)
|
||||
data.put("direct_entity_id", direct.getUniqueId().toString());
|
||||
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
|
||||
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
|
||||
data.put("target_player_id", target.getUniqueId().toString());
|
||||
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
|
||||
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
|
||||
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
|
||||
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
|
||||
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
|
||||
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
|
||||
data.put("provenance_age_status", "unavailable");
|
||||
data.put("requested_amount", event.getDamage());
|
||||
data.put("prior_cancelled", event.isCancelled());
|
||||
data.put("proposed_reason", reason);
|
||||
var sourceLocation = causing == null ? null : location(causing.getLocation());
|
||||
var targetLocation = location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(id, time, context, Map.copyOf(data));
|
||||
var observed = new LinkedHashMap<String, Object>(data);
|
||||
observed.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", observed);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public Action nativeAttempt(String kind, Entity causing, Player target, CombatSources.Origin origin,
|
||||
CombatSources.Origin recipient, PartyRoster.Party sourceParty,
|
||||
PartyRoster.Party targetParty, String reason, String nativeKind) {
|
||||
try {
|
||||
var id = UUID.randomUUID();
|
||||
var now = clock.instant();
|
||||
var source = causing instanceof Player player && player.getUniqueId().equals(origin.player()) ? player : null;
|
||||
var context = source == null ? null : new EventInput.Context(source.getUniqueId(), source.getName(),
|
||||
null, null, null, location(source.getLocation()));
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", kind);
|
||||
if (context == null) data.put("actor_kind", "system");
|
||||
data.put("native_kind", nativeKind);
|
||||
data.put("source_player_id", origin.player().toString());
|
||||
if (causing != null && causing.getUniqueId() != null)
|
||||
data.put("direct_entity_id", causing.getUniqueId().toString());
|
||||
if (origin.character() != null) data.put("source_character_id", origin.character().toString());
|
||||
if (origin.session() != null) data.put("source_session_id", origin.session().toString());
|
||||
if (recipient != null) {
|
||||
data.put("target_player_id", recipient.player().toString());
|
||||
if (recipient.character() != null) data.put("target_character_id", recipient.character().toString());
|
||||
if (recipient.session() != null) data.put("target_session_id", recipient.session().toString());
|
||||
} else data.put("target_status", "not_single_proved_player");
|
||||
if (sourceParty != null) data.put("source_party_id", sourceParty.id().toString());
|
||||
if (targetParty != null) data.put("target_party_id", targetParty.id().toString());
|
||||
data.put("provenance_quality", origin.session() == null ? "ordinary_or_unproved"
|
||||
: reason.equals("stale_source") ? "claimed_session_unverified" : "matching_session_id");
|
||||
data.put("provenance_age_status", "unavailable");
|
||||
data.put("requested_amount_status", "not_applicable");
|
||||
data.put("prior_cancelled", false);
|
||||
var sourceLocation = causing == null ? null : location(causing.getLocation());
|
||||
var targetLocation = target == null ? null : location(target.getLocation());
|
||||
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
|
||||
else data.put("source_location_status", "unavailable");
|
||||
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
|
||||
else data.put("target_location_status", "unavailable");
|
||||
var action = new Action(id, now, context, Map.copyOf(data));
|
||||
var observed = new LinkedHashMap<String, Object>(data);
|
||||
observed.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", observed);
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void resolved(Action action, String outcome, String reason, boolean cancelled, Double finalAmount) {
|
||||
if (action == null) return;
|
||||
var values = new LinkedHashMap<String, Object>(action.data());
|
||||
values.put("outcome", outcome);
|
||||
values.put("reason", reason);
|
||||
values.put("final_cancelled", cancelled);
|
||||
if (finalAmount != null) values.put("final_amount", finalAmount);
|
||||
else values.put("final_amount_status", "not_applicable");
|
||||
emit(action, "action.resolved", "resolved", values);
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(),
|
||||
value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private Map<String, Object> coordinates(EventInput.Location value) {
|
||||
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(),
|
||||
"x", value.x(), "y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, Map<String, Object> data) {
|
||||
try { sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:party-protection:" + action.id()
|
||||
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
|
||||
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.actor(), data)); }
|
||||
catch (RuntimeException ignored) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** An acknowledged native discovery has one recoverable, account-keyed event intent. */
|
||||
public final class RecipeDiscoveryEventBridge implements AutoCloseable {
|
||||
private static final String RECIPE = "fantasystick:character_sheet";
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final Path pending;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-recipe-discovery-events"));
|
||||
|
||||
public RecipeDiscoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("recipe-event-intents");
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public void discovered(Player player, String trigger, int notifications) {
|
||||
try {
|
||||
var record = capture(player, trigger, notifications > 0);
|
||||
worker.execute(() -> {
|
||||
try { publish(write(record)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
});
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
/** Called only after the shared writer's startup recovery is ready. */
|
||||
public void replay() {
|
||||
try {
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
|
||||
try (var entries = Files.list(pending)) {
|
||||
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
});
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
public void failed(Player player, String trigger, String reason) {
|
||||
try {
|
||||
var action = UUID.randomUUID();
|
||||
var context = context(player);
|
||||
emit(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + action + ":failed")
|
||||
.getBytes(StandardCharsets.UTF_8)), action, clock.instant(), "action.resolved", context,
|
||||
Map.of("action_kind", "recipe_discovery", "recipe_id", RECIPE, "trigger", trigger,
|
||||
"outcome", "failed", "reason", reason)));
|
||||
} catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
private record Record(UUID account, UUID action, Instant time, String name, String trigger,
|
||||
boolean notification, EventInput.Location location) { }
|
||||
|
||||
private Record capture(Player player, String trigger, boolean notification) {
|
||||
var context = context(player);
|
||||
return new Record(context.playerId(), id(context.playerId()), clock.instant(), context.playerName(),
|
||||
trigger, notification, context.location());
|
||||
}
|
||||
|
||||
private EventInput.Context context(Player player) {
|
||||
var location = player.getLocation();
|
||||
EventInput.Location where = location == null || location.getWorld() == null ? null
|
||||
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), clock.instant(), "exact");
|
||||
return new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, where);
|
||||
}
|
||||
|
||||
private void publish(Record record) {
|
||||
var fields = new LinkedHashMap<String, Object>();
|
||||
fields.put("action_kind", "recipe_discovery");
|
||||
fields.put("recipe_id", RECIPE);
|
||||
fields.put("recipe_category", "misc");
|
||||
fields.put("trigger", record.trigger());
|
||||
fields.put("outcome", "acknowledged");
|
||||
fields.put("first_discovery_notification", record.notification());
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:recipe:" + record.action()
|
||||
+ ":recipe.discovered").getBytes(StandardCharsets.UTF_8)), record.action(), record.time(),
|
||||
"recipe.discovered", new EventInput.Context(record.account(), record.name(), null, null, null,
|
||||
record.location()), fields);
|
||||
emit(input).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try {
|
||||
if (Files.deleteIfExists(file(record.account()))) forceDirectory();
|
||||
} catch (IOException deleteFailure) { report(deleteFailure); }
|
||||
});
|
||||
}
|
||||
|
||||
private CompletionStage<LocalEventLog.Delivery> emit(EventInput input) {
|
||||
try { return sink.apply(input); }
|
||||
catch (RuntimeException failure) {
|
||||
report(failure);
|
||||
return java.util.concurrent.CompletableFuture.completedStage(LocalEventLog.Delivery.REJECTED);
|
||||
}
|
||||
}
|
||||
|
||||
private Path file(UUID account) { return pending.resolve(account + ".pending"); }
|
||||
|
||||
private Record write(Record record) throws IOException {
|
||||
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intents");
|
||||
Files.createDirectories(pending);
|
||||
Path target = file(record.account());
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
|
||||
return read(target); // Preserve the first native acknowledgment and its original identity.
|
||||
}
|
||||
var values = new Properties();
|
||||
values.setProperty("account", record.account().toString());
|
||||
values.setProperty("action", record.action().toString());
|
||||
values.setProperty("time", record.time().toString());
|
||||
values.setProperty("name", record.name());
|
||||
values.setProperty("trigger", record.trigger());
|
||||
values.setProperty("notification", Boolean.toString(record.notification()));
|
||||
if (record.location() != null) {
|
||||
var location = record.location();
|
||||
values.setProperty("world", location.worldId().toString());
|
||||
values.setProperty("world_name", location.worldName());
|
||||
values.setProperty("x", Double.toString(location.x()));
|
||||
values.setProperty("y", Double.toString(location.y()));
|
||||
values.setProperty("z", Double.toString(location.z()));
|
||||
values.setProperty("location_time", location.capturedAt().toString());
|
||||
}
|
||||
var output = new ByteArrayOutputStream();
|
||||
values.store(output, "acknowledged native recipe discovery");
|
||||
var bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
|
||||
Path temporary = Files.createTempFile(pending, ".recipe-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return record;
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private Record read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe recipe event intent");
|
||||
var values = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Recipe event intent too large");
|
||||
values.load(new java.io.ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var account = UUID.fromString(values.getProperty("account"));
|
||||
var action = UUID.fromString(values.getProperty("action"));
|
||||
if (!path.equals(file(account)) || !action.equals(id(account))) throw new IOException("Invalid recipe event identity");
|
||||
EventInput.Location location = values.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(values.getProperty("world")), values.getProperty("world_name"),
|
||||
Double.parseDouble(values.getProperty("x")), Double.parseDouble(values.getProperty("y")),
|
||||
Double.parseDouble(values.getProperty("z")), Instant.parse(values.getProperty("location_time")), "exact")
|
||||
: null;
|
||||
var trigger = values.getProperty("trigger");
|
||||
if (!"join".equals(trigger) && !"enable_online".equals(trigger)) throw new IOException("Invalid recipe event trigger");
|
||||
var notification = values.getProperty("notification");
|
||||
if (!"true".equals(notification) && !"false".equals(notification)) throw new IOException("Invalid recipe notification");
|
||||
return new Record(account, action, Instant.parse(values.getProperty("time")),
|
||||
Objects.requireNonNull(values.getProperty("name")), trigger, Boolean.parseBoolean(notification), location);
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid recipe event intent", invalid); }
|
||||
}
|
||||
|
||||
public static UUID id(UUID player) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:recipe:character_sheet:" + player)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) report(new IOException("Recipe event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.AdventureSettings;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured administrative/session recovery observations; never authorizes native recovery. */
|
||||
public final class SessionRecoveryEventBridge implements AutoCloseable {
|
||||
public record Action(UUID id, Instant observedAt, EventInput.Context context, String actorKind,
|
||||
String locationStatus, UUID target) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final FeatureEventOutbox outbox;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final Clock clock;
|
||||
|
||||
public SessionRecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
|
||||
Clock clock, Path data, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
outbox = new FeatureEventOutbox(data, "session-recovery-event-intents",
|
||||
Set.of("recovery.resolved", "config.changed"), sink, failures);
|
||||
}
|
||||
|
||||
public void replay() { outbox.replay(); }
|
||||
public CompletionStage<Void> drained() { return outbox.drained(); }
|
||||
@Override public void close() { outbox.close(); }
|
||||
|
||||
public Action capture(CommandSender sender, UUID target) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
if (sender instanceof Player player) {
|
||||
var location = player.getLocation();
|
||||
EventInput.Location origin = location == null || location.getWorld() == null ? null
|
||||
: new EventInput.Location(location.getWorld().getUID(), location.getWorld().getName(),
|
||||
location.getX(), location.getY(), location.getZ(), now, "exact");
|
||||
return new Action(UUID.randomUUID(), now,
|
||||
new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null, origin),
|
||||
"player", origin == null ? "unavailable" : null, target);
|
||||
}
|
||||
return new Action(UUID.randomUUID(), now, null,
|
||||
sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console",
|
||||
"not_applicable", target);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void observed(Action action, String kind) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", "observed");
|
||||
emit(action, "action.observed", "observed", data);
|
||||
}
|
||||
|
||||
public void resolved(Action action, String kind, String outcome, String reason) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
if (outcome.equals("refused")) data.remove("target_player_id");
|
||||
data.put("action_kind", kind);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
emit(action, "action.resolved", "resolved:" + outcome + ':' + reason, data);
|
||||
}
|
||||
|
||||
public void inspected(Action action, Optional<EquipmentSessionStore.State> state,
|
||||
String nativePhase, boolean online) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("action_kind", "equipment_inspect");
|
||||
data.put("outcome", "authorized");
|
||||
var saved = state.orElse(null);
|
||||
var receipt = saved == null ? null : saved.pending();
|
||||
data.put("receipt_phase", receipt != null ? "pending" : saved == null ? "absent" : "completed");
|
||||
if (saved != null) data.put("receipt_revision", saved.revision().toString());
|
||||
if (receipt != null) {
|
||||
data.put("session_id", receipt.sessionId().toString());
|
||||
data.put("character_id", receipt.characterId().toString());
|
||||
}
|
||||
data.put("native_phase", online ? nativePhase : "offline_unavailable");
|
||||
data.put("custody_quality", online ? "receipt_and_native_observation" : "receipt_only");
|
||||
emit(action, "recovery.inspected", "inspected", data);
|
||||
}
|
||||
|
||||
/** One result per proof-based reconciliation attempt, distinct from native equipment custody. */
|
||||
public void reconciled(Action action, UUID session, UUID character, String priorPhase,
|
||||
String currentPhase, String outcome, String quality, String reason,
|
||||
java.math.BigInteger priorRevision, java.math.BigInteger afterRevision,
|
||||
UUID equipmentOperation, EventInput.Location original, EventInput.Location current) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("recovery_operation_id", action.id().toString());
|
||||
if (session != null) data.put("session_id", session.toString());
|
||||
if (character != null) data.put("character_id", character.toString());
|
||||
data.put("prior_phase", priorPhase);
|
||||
data.put("current_phase", currentPhase);
|
||||
data.put("custody_quality", quality);
|
||||
data.put("outcome", outcome);
|
||||
data.put("reason", reason);
|
||||
if (priorRevision != null) data.put("prior_receipt_revision", priorRevision.toString());
|
||||
if (afterRevision != null) data.put("receipt_revision", afterRevision.toString());
|
||||
if (equipmentOperation != null) data.put("equipment_recovery_operation_id", equipmentOperation.toString());
|
||||
nativeLocation(data, "original", original);
|
||||
nativeLocation(data, "current", current);
|
||||
data.put("changed_components", outcome.equals("restored") && quality.equals("verified_box_and_marker")
|
||||
? "verified_native_equipment" : quality.equals("native_restored_receipt_pending")
|
||||
? "native_equipment_unacknowledged" : "not_independently_verified");
|
||||
emit(action, "recovery.resolved", "reconciled", data);
|
||||
if (!action.actorKind().equals("system")) resolved(action, "equipment_recovery", outcome, reason);
|
||||
}
|
||||
|
||||
/** Must be called on the native thread before any asynchronous receipt acknowledgment. */
|
||||
public EventInput.Location captureLocation(Location value) {
|
||||
if (value == null || value.getWorld() == null) return null;
|
||||
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
|
||||
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private void nativeLocation(LinkedHashMap<String, Object> data, String name, EventInput.Location location) {
|
||||
if (location == null) {
|
||||
data.put(name + "_location_status", "unavailable");
|
||||
return;
|
||||
}
|
||||
data.put(name + "_world_id", location.worldId().toString());
|
||||
data.put(name + "_world_name", location.worldName());
|
||||
data.put(name + "_x", location.x());
|
||||
data.put(name + "_y", location.y());
|
||||
data.put(name + "_z", location.z());
|
||||
data.put(name + "_captured_at", location.capturedAt().toString());
|
||||
data.put(name + "_location_status", name.equals("original") ? "captured_at_entry" : "captured_at_reconciliation");
|
||||
}
|
||||
|
||||
public Action system(Player player) {
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var location = player.getLocation();
|
||||
var action = new Action(UUID.randomUUID(), now, null, "system",
|
||||
location == null || location.getWorld() == null ? "unavailable" : "captured_current_only",
|
||||
player.getUniqueId());
|
||||
return action;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
public void configRead(Action action, boolean effective, Optional<String> revision) {
|
||||
if (action == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
if (revision.isPresent()) data.put("persistence_revision", revision.orElseThrow());
|
||||
else data.put("persistence_revision_status", "not_verified_at_read");
|
||||
data.put("outcome", "authorized");
|
||||
emit(action, "config.read", "read", data);
|
||||
}
|
||||
|
||||
public void configChanged(Action action, AdventureSettings.Saved saved) {
|
||||
if (action == null || saved == null) return;
|
||||
var data = fields(action);
|
||||
data.put("key", "adventure-mode");
|
||||
data.put("before_value", Boolean.toString(saved.beforeValue()));
|
||||
data.put("after_value", Boolean.toString(saved.value()));
|
||||
data.put("effective_value", Boolean.toString(saved.value()));
|
||||
data.put("persistence_revision", saved.revision());
|
||||
data.put("application", "future_sessions");
|
||||
data.put("outcome", "acknowledged");
|
||||
emit(action, "config.changed", "changed", data);
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, Object> fields(Action action) {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("actor_kind", action.actorKind());
|
||||
if (action.locationStatus() != null) data.put("location_status", action.locationStatus());
|
||||
if (action.target() != null) data.put("target_player_id", action.target().toString());
|
||||
return data;
|
||||
}
|
||||
|
||||
private void emit(Action action, String type, String stage, LinkedHashMap<String, Object> data) {
|
||||
try {
|
||||
var event = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:session-recovery:" + action.id()
|
||||
+ ':' + stage).getBytes(StandardCharsets.UTF_8)), action.id(),
|
||||
stage.equals("observed") ? action.observedAt() : clock.instant(), type, action.context(), data);
|
||||
if (type.equals("config.changed") || type.equals("recovery.resolved")) outbox.accepted(event);
|
||||
else sink.apply(event);
|
||||
}
|
||||
catch (RuntimeException failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.CharacterRecord;
|
||||
import games.dmg.fantasystick.persistence.EquipmentSessionStore;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Observes persisted switch deadlines and pre-pack admission without authorizing either. */
|
||||
public final class SwitchDelayEventBridge implements AutoCloseable {
|
||||
private static final int MAX_BYTES = 16_384;
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final Path pending;
|
||||
private final Consumer<Throwable> failures;
|
||||
private final ExecutorService worker = Executors.newSingleThreadExecutor(task ->
|
||||
new Thread(task, "fantasy-switch-delay-events"));
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public SwitchDelayEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
Path dataFolder, Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.pending = Objects.requireNonNull(dataFolder).toAbsolutePath().resolve("switch-event-intents");
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public record ExitContext(UUID player, String playerName, UUID character, String characterName,
|
||||
UUID session, EventInput.Location location, String reason) { }
|
||||
|
||||
public ExitContext exit(Player player, UUID session, CharacterRecord character, String reason) {
|
||||
try {
|
||||
return new ExitContext(player.getUniqueId(), player.getName(), character.id(), character.name(),
|
||||
session, location(player.getLocation()), reason);
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
private record SavedStart(ExitContext capture, EquipmentSessionStore.Exit exit,
|
||||
String revisionBefore, String revisionAfter, Instant time) { }
|
||||
|
||||
public void started(ExitContext captured, EquipmentSessionStore.State saved) {
|
||||
if (captured == null || saved == null || saved.pending() != null || saved.lastExit() == null
|
||||
|| !captured.session().equals(saved.lastExit().sessionId())) return;
|
||||
var start = new SavedStart(captured, saved.lastExit(),
|
||||
saved.revision().subtract(java.math.BigInteger.ONE).toString(), saved.revision().toString(),
|
||||
saved.lastExit().exitedAt());
|
||||
try { worker.execute(() -> {
|
||||
try { publish(write(start)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
|
||||
private void publish(SavedStart start) {
|
||||
var captured = start.capture();
|
||||
var exit = start.exit();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("outcome", "acknowledged");
|
||||
values.put("exit_reason", captured.reason());
|
||||
values.put("exit_operation_id", captured.session().toString());
|
||||
values.put("character_id", exit.characterId().toString());
|
||||
values.put("session_id", exit.sessionId().toString());
|
||||
values.put("duration_seconds", Long.toString(Duration.between(exit.exitedAt(), exit.deadline()).getSeconds()));
|
||||
values.put("exited_at", exit.exitedAt().toString());
|
||||
values.put("deadline", exit.deadline().toString());
|
||||
values.put("revision_before", start.revisionBefore());
|
||||
values.put("revision_after", start.revisionAfter());
|
||||
values.put("zero_duration", exit.deadline().equals(exit.exitedAt()));
|
||||
var input = new EventInput(id(captured.session(), "started"), captured.session(), start.time(),
|
||||
"character.switch_delay_started", new EventInput.Context(captured.player(), captured.playerName(),
|
||||
captured.character(), captured.characterName(), captured.session(), captured.location()), values);
|
||||
try { sink.apply(input).whenComplete((delivery, failure) -> {
|
||||
if (failure != null) { report(failure); return; }
|
||||
if (delivery == LocalEventLog.Delivery.REJECTED) return;
|
||||
try { if (Files.deleteIfExists(file(captured.session()))) forceDirectory(); }
|
||||
catch (IOException error) { report(error); }
|
||||
}); } catch (RuntimeException failure) { report(failure); }
|
||||
}
|
||||
|
||||
/** Replay only persisted, previously acknowledged exits, before any new native session observations. */
|
||||
public void replay() {
|
||||
if (worker.isShutdown()) return;
|
||||
try { worker.execute(() -> {
|
||||
try {
|
||||
if (!Files.exists(pending, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (!Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
|
||||
try (var entries = Files.list(pending)) {
|
||||
for (var path : entries.filter(p -> p.getFileName().toString().endsWith(".pending")).sorted().toList()) {
|
||||
try { publish(read(path)); }
|
||||
catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) { report(failure); }
|
||||
}); } catch (RuntimeException rejected) { report(rejected); }
|
||||
}
|
||||
|
||||
private Path file(UUID session) { return pending.resolve(session + ".pending"); }
|
||||
|
||||
private SavedStart write(SavedStart value) throws IOException {
|
||||
if (Files.exists(pending, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(pending, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intents");
|
||||
Files.createDirectories(pending);
|
||||
Path target = file(value.capture().session());
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return read(target);
|
||||
var fields = new Properties();
|
||||
var capture = value.capture(); var exit = value.exit();
|
||||
fields.setProperty("player", capture.player().toString());
|
||||
fields.setProperty("player_name", capture.playerName());
|
||||
fields.setProperty("character", capture.character().toString());
|
||||
fields.setProperty("character_name", capture.characterName());
|
||||
fields.setProperty("session", capture.session().toString());
|
||||
fields.setProperty("reason", capture.reason());
|
||||
fields.setProperty("exited_at", exit.exitedAt().toString());
|
||||
fields.setProperty("deadline", exit.deadline().toString());
|
||||
fields.setProperty("revision_before", value.revisionBefore());
|
||||
fields.setProperty("revision_after", value.revisionAfter());
|
||||
fields.setProperty("time", value.time().toString());
|
||||
if (capture.location() != null) {
|
||||
var location = capture.location();
|
||||
fields.setProperty("world", location.worldId().toString());
|
||||
fields.setProperty("world_name", location.worldName());
|
||||
fields.setProperty("x", Double.toString(location.x()));
|
||||
fields.setProperty("y", Double.toString(location.y()));
|
||||
fields.setProperty("z", Double.toString(location.z()));
|
||||
fields.setProperty("location_time", location.capturedAt().toString());
|
||||
}
|
||||
var output = new ByteArrayOutputStream();
|
||||
fields.store(output, "acknowledged switch deadline");
|
||||
byte[] bytes = output.toByteArray();
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
|
||||
Path temporary = Files.createTempFile(pending, ".switch-", ".tmp");
|
||||
try {
|
||||
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) channel.write(buffer);
|
||||
channel.force(true);
|
||||
}
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
forceDirectory();
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
return value;
|
||||
}
|
||||
|
||||
private void forceDirectory() throws IOException {
|
||||
try (var channel = FileChannel.open(pending, StandardOpenOption.READ)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private SavedStart read(Path path) throws IOException {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe switch event intent");
|
||||
var fields = new Properties();
|
||||
try (var input = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var bytes = input.readNBytes(MAX_BYTES + 1);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Switch event intent too large");
|
||||
fields.load(new java.io.ByteArrayInputStream(bytes));
|
||||
}
|
||||
try {
|
||||
var player = UUID.fromString(fields.getProperty("player"));
|
||||
var character = UUID.fromString(fields.getProperty("character"));
|
||||
var session = UUID.fromString(fields.getProperty("session"));
|
||||
if (!path.equals(file(session))) throw new IOException("Switch event identity mismatch");
|
||||
var reason = fields.getProperty("reason");
|
||||
if (!java.util.Set.of("voluntary", "logout", "defeat", "shutdown").contains(reason))
|
||||
throw new IOException("Invalid switch exit reason");
|
||||
EventInput.Location location = fields.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(fields.getProperty("world")), fields.getProperty("world_name"),
|
||||
Double.parseDouble(fields.getProperty("x")), Double.parseDouble(fields.getProperty("y")),
|
||||
Double.parseDouble(fields.getProperty("z")), Instant.parse(fields.getProperty("location_time")), "exact")
|
||||
: null;
|
||||
var before = fields.getProperty("revision_before"); var after = fields.getProperty("revision_after");
|
||||
if (!before.matches("0|[1-9][0-9]*") || !after.matches("[1-9][0-9]*"))
|
||||
throw new IOException("Invalid switch revision");
|
||||
var exit = new EquipmentSessionStore.Exit(session, character,
|
||||
Instant.parse(fields.getProperty("exited_at")), Instant.parse(fields.getProperty("deadline")));
|
||||
return new SavedStart(new ExitContext(player, Objects.requireNonNull(fields.getProperty("player_name")),
|
||||
character, Objects.requireNonNull(fields.getProperty("character_name")), session, location, reason),
|
||||
exit, before, after, Instant.parse(fields.getProperty("time")));
|
||||
} catch (RuntimeException invalid) { throw new IOException("Invalid switch event intent", invalid); }
|
||||
}
|
||||
|
||||
private void report(Throwable failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
/** Shutdown does not wait for file I/O on the Minecraft thread. */
|
||||
@Override public void close() {
|
||||
worker.shutdown();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, TimeUnit.SECONDS))
|
||||
report(new IOException("Switch event intents did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt(); report(interrupted);
|
||||
} finally { drained.complete(null); }
|
||||
});
|
||||
}
|
||||
|
||||
public CompletionStage<Void> drained() { return drained; }
|
||||
|
||||
public void evaluated(Player player, UUID requested, EquipmentSessionStore.State state,
|
||||
Duration configured, Instant now, boolean allowed) {
|
||||
try {
|
||||
var action = UUID.randomUUID();
|
||||
var values = new LinkedHashMap<String, Object>();
|
||||
values.put("action_kind", "character_switch_preflight");
|
||||
values.put("requested_character_id", requested.toString());
|
||||
values.put("configured_duration_seconds", Long.toString(configured.getSeconds()));
|
||||
values.put("preflight_order", "before_equipment_pack");
|
||||
values.put("outcome", "observed");
|
||||
var exit = state == null ? null : state.lastExit();
|
||||
boolean same = exit != null && requested.equals(exit.characterId());
|
||||
values.put("same_character", same);
|
||||
values.put("last_exited_character_id", exit == null ? "none" : exit.characterId().toString());
|
||||
if (exit != null) {
|
||||
values.put("deadline", exit.deadline().toString());
|
||||
var remaining = Duration.between(now, exit.deadline());
|
||||
values.put("remaining_seconds", Long.toString(Math.max(0,
|
||||
remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0))));
|
||||
values.put("receipt_revision", state.revision().toString());
|
||||
}
|
||||
var context = new EventInput.Context(player.getUniqueId(), player.getName(), null, null, null,
|
||||
location(player.getLocation()));
|
||||
emit(new EventInput(id(action, "observed"), action, clock.instant(), "action.observed", context, values));
|
||||
values.put("outcome", allowed ? "allowed" : "refused");
|
||||
values.put("reason", allowed ? (same ? "same_character" : "prepack_allowed") : "cooldown");
|
||||
emit(new EventInput(id(action, "resolved"), action, clock.instant(), "action.resolved", context, values));
|
||||
} catch (RuntimeException ignored) { /* Logging cannot change entry admission. */ }
|
||||
}
|
||||
|
||||
private EventInput.Location location(Location value) {
|
||||
return value == null || value.getWorld() == null ? null
|
||||
: new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
|
||||
value.getZ(), clock.instant(), "exact");
|
||||
}
|
||||
|
||||
private void emit(EventInput event) {
|
||||
try { sink.apply(event); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
|
||||
private static UUID id(UUID action, String kind) {
|
||||
return UUID.nameUUIDFromBytes(("fantasystick:switch-delay:" + action + ':' + kind)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,41 @@ public final class EquipmentStorage {
|
||||
return new PackPlan(PackResult.PACKED, item, available);
|
||||
}
|
||||
|
||||
/** Bounded custody/slot-class proof; never exposes the box's items or metadata to event payloads. */
|
||||
public record Snapshot(String boxSource, int boxSlot, int hotbarSlots, int armorSlots, boolean displacedHead) {
|
||||
public int packedSlots() { return hotbarSlots + armorSlots; }
|
||||
public java.util.List<String> slotClasses() {
|
||||
var result = new java.util.ArrayList<String>(2);
|
||||
if (hotbarSlots > 0) result.add("hotbar");
|
||||
if (armorSlots > 0) result.add("armor");
|
||||
return java.util.List.copyOf(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only native proof at the packing/restoration boundary, including moved or cursor custody. */
|
||||
public java.util.Optional<Snapshot> snapshot(PlayerInventory inventory, UUID owner, UUID session, ItemStack cursor) {
|
||||
Snapshot found = null;
|
||||
for (int slot = 9; slot <= 41; slot++) {
|
||||
if (slot >= 36 && slot < 40) continue;
|
||||
var item = slot == 41 ? cursor : inventory.getItem(slot);
|
||||
if (!matches(item, owner, session)) continue;
|
||||
if (found != null || item.getAmount() != 1 || !(item.getItemMeta() instanceof BlockStateMeta meta)
|
||||
|| !(meta.getBlockState() instanceof ShulkerBox box)) return java.util.Optional.empty();
|
||||
var fields = meta.getPersistentDataContainer();
|
||||
if (!fields.has(VERSION, PersistentDataType.INTEGER) || fields.get(VERSION, PersistentDataType.INTEGER) != 1
|
||||
|| !Arrays.equals(EQUIPMENT_SLOTS, fields.get(SLOTS, PersistentDataType.INTEGER_ARRAY)))
|
||||
return java.util.Optional.empty();
|
||||
var contents = box.getInventory();
|
||||
int hotbar = 0, armor = 0;
|
||||
for (int index = 0; index < EQUIPMENT_SLOTS.length; index++) {
|
||||
if (!empty(contents.getItem(index))) { if (index < 9) hotbar++; else armor++; }
|
||||
}
|
||||
found = new Snapshot(slot == 41 ? "cursor" : slot == 40 ? "offhand" : "main",
|
||||
slot, hotbar, armor, !empty(inventory.getItem(39)));
|
||||
}
|
||||
return java.util.Optional.ofNullable(found);
|
||||
}
|
||||
|
||||
public enum RestoreResult { RESTORED, NOT_FOUND, AMBIGUOUS, INVALID_BOX, OCCUPIED_SLOT }
|
||||
|
||||
/** Caller removes validated fantasy controls first. Unexpected slot occupants are never overwritten. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import java.util.WeakHashMap;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
@@ -17,12 +18,24 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
private boolean closed;
|
||||
private final PartyRoster parties;
|
||||
private final CombatSources sources;
|
||||
private games.dmg.fantasystick.events.PartyProtectionEventBridge protectionEvents;
|
||||
private final WeakHashMap<EntityDamageEvent, DamageAudit> damageAudits = new WeakHashMap<>();
|
||||
private final WeakHashMap<org.bukkit.event.Event, NativeAudit> nativeAudits = new WeakHashMap<>();
|
||||
private record NativeAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
|
||||
String reason) { }
|
||||
private record DamageAudit(games.dmg.fantasystick.events.PartyProtectionEventBridge.Action action,
|
||||
String decision) { }
|
||||
|
||||
public PartyCombat(PartyRoster parties, EquipmentSessions sessions) {
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.sources = new CombatSources(sessions);
|
||||
}
|
||||
|
||||
public void attachProtectionEvents(games.dmg.fantasystick.events.PartyProtectionEventBridge events) {
|
||||
if (protectionEvents != null || closed) throw new IllegalStateException("Party protection events already attached");
|
||||
protectionEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public void install(org.bukkit.plugin.Plugin plugin) {
|
||||
cleanup = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
|
||||
if (!closed) for (var player : plugin.getServer().getOnlinePlayers()) OngoingDamage.sweep(player);
|
||||
@@ -31,6 +44,8 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
damageAudits.clear();
|
||||
nativeAudits.clear();
|
||||
if (cleanup != null) cleanup.cancel();
|
||||
}
|
||||
|
||||
@@ -40,8 +55,21 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void impact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && (!sources.current(origin) || !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
&& event.getHitEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
if (origin == null) return;
|
||||
var target = event.getHitEntity() instanceof Player player ? player : null;
|
||||
var reason = !sources.current(origin) ? "stale_source"
|
||||
: target != null && !(event.getEntity() instanceof org.bukkit.entity.ThrownPotion)
|
||||
? protectionReason(origin, target) : null;
|
||||
if (reason != null) {
|
||||
captureNative(event, "party_projectile_impact_protection", event.getEntity(), target, origin,
|
||||
reason, "projectile_impact");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedImpact(org.bukkit.event.entity.ProjectileHitEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -50,13 +78,33 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
// Revoke stale continuation here. Valid healing and unrelated recipients are untouched;
|
||||
// actual harmful applications and instantaneous damage have their own guards below.
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
if (origin != null && !sources.current(origin)) {
|
||||
var recipients = event.getAffectedEntities().stream().filter(Player.class::isInstance)
|
||||
.map(Player.class::cast).toList();
|
||||
captureNative(event, "party_splash_protection", event.getEntity(),
|
||||
recipients.size() == 1 ? recipients.getFirst() : null, origin, "stale_source", "potion_splash");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedSplash(org.bukkit.event.entity.PotionSplashEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void staleLingering(LingeringPotionSplashEvent event) {
|
||||
var origin = sources.read(event.getEntity());
|
||||
if (origin != null && !sources.current(origin)) event.setCancelled(true);
|
||||
if (origin != null && !sources.current(origin)) {
|
||||
captureNative(event, "party_lingering_protection", event.getEntity(), null,
|
||||
origin, "stale_source", "lingering_splash");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedLingering(LingeringPotionSplashEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -64,12 +112,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
sources.store(event.getAreaEffectCloud(), sources.read(event.getEntity()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void observeDamage(EntityDamageEvent event) {
|
||||
if (protectionEvents == null || !(event.getEntity() instanceof Player target)) return;
|
||||
// OngoingDamage.read reconciles native PDC. Only the original HIGHEST guard may do so;
|
||||
// the LOWEST audit must not change its timing or mutate an earlier protection's state.
|
||||
if (event.getCause() == EntityDamageEvent.DamageCause.POISON
|
||||
|| event.getCause() == EntityDamageEvent.DamageCause.WITHER
|
||||
|| event.getCause() == EntityDamageEvent.DamageCause.FIRE_TICK) return;
|
||||
var origin = sources.source(event);
|
||||
if (origin == null) return;
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
String reason = ongoing != null && !sources.current(ongoing.target()) ? "stale_target"
|
||||
: protectionReason(origin, target);
|
||||
if (reason == null) return;
|
||||
captureDamage(event, target, origin, reason);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedDamage(EntityDamageEvent event) {
|
||||
if (protectionEvents == null) return;
|
||||
var audit = damageAudits.remove(event);
|
||||
if (audit == null) return;
|
||||
boolean prior = Boolean.TRUE.equals(audit.action().data().get("prior_cancelled"));
|
||||
String reason = prior && event.isCancelled() ? "prior_cancelled"
|
||||
: !event.isCancelled() && audit.decision() != null ? "protection_overridden"
|
||||
: event.isCancelled() && audit.decision() != null ? audit.decision()
|
||||
: event.isCancelled() ? "external_cancelled" : "protection_not_applied";
|
||||
String outcome = !event.isCancelled() ? "accepted" : prior || audit.decision() == null
|
||||
? "cancelled" : "refused";
|
||||
protectionEvents.resolved(audit.action(), outcome, reason, event.isCancelled(),
|
||||
event.isCancelled() ? 0 : event.getFinalDamage());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void damage(EntityDamageEvent event) {
|
||||
if (games.dmg.fantasystick.ability.GuardianBond.redirectedDamage()) return;
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var ongoing = OngoingDamage.read(target, event.getCause());
|
||||
if (ongoing != null && (!sources.current(ongoing.target()) || blocked(ongoing.source(), target))) {
|
||||
var reason = !sources.current(ongoing.target()) ? "stale_target"
|
||||
: protectionReason(ongoing.source(), target);
|
||||
captureDamage(event, target, ongoing.source(), reason);
|
||||
markDamageDecision(event, reason);
|
||||
event.setCancelled(true); return;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +162,14 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
if (origin == null && event instanceof EntityDamageByEntityEvent entity) origin = sources.read(entity.getDamager());
|
||||
if (origin == null) origin = sources.read(event.getDamageSource().getCausingEntity());
|
||||
if (origin != null && (!sources.current(origin)
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) event.setCancelled(true);
|
||||
|| event.getEntity() instanceof Player target && blocked(origin, target))) {
|
||||
if (event.getEntity() instanceof Player target) {
|
||||
var reason = protectionReason(origin, target);
|
||||
captureDamage(event, target, origin, reason);
|
||||
markDamageDecision(event, reason);
|
||||
}
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -85,7 +177,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
if (!(event.getEntity() instanceof Player target) || event.getNewEffect() == null
|
||||
|| OngoingDamage.cause(event.getModifiedType()) == null) return;
|
||||
var origin = sources.read(event.getSource());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
if (origin != null && blocked(origin, target)) {
|
||||
captureNative(event, "party_effect_application_protection", event.getSource(), target,
|
||||
origin, protectionReason(origin, target), event.getModifiedType().getKey().getKey());
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedPotion(org.bukkit.event.entity.EntityPotionEffectEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -105,7 +206,16 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
public void combust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) return;
|
||||
var origin = sources.read(event.getCombuster());
|
||||
if (origin != null && blocked(origin, target)) event.setCancelled(true);
|
||||
if (origin != null && blocked(origin, target)) {
|
||||
captureNative(event, "party_ignite_protection", event.getCombuster(), target,
|
||||
origin, protectionReason(origin, target), "combust");
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void resolvedCombust(org.bukkit.event.entity.EntityCombustByEntityEvent event) {
|
||||
resolveNative(event, event);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -118,11 +228,49 @@ public final class PartyCombat implements Listener, AutoCloseable {
|
||||
: new OngoingDamage.Application(origin, sources.player(target.getUniqueId())));
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
private void captureNative(org.bukkit.event.Event event, String kind, org.bukkit.entity.Entity causing,
|
||||
Player target, CombatSources.Origin origin, String reason, String nativeKind) {
|
||||
if (protectionEvents == null || reason == null) return;
|
||||
var recipient = target == null ? null : sources.player(target.getUniqueId());
|
||||
var action = protectionEvents.nativeAttempt(kind, causing, target, origin, recipient,
|
||||
parties.partyOf(origin.player()).orElse(null),
|
||||
recipient == null ? null : parties.partyOf(recipient.player()).orElse(null),
|
||||
reason, nativeKind);
|
||||
if (action != null) nativeAudits.put(event, new NativeAudit(action, reason));
|
||||
}
|
||||
|
||||
private void resolveNative(org.bukkit.event.Event event, org.bukkit.event.Cancellable cancellable) {
|
||||
if (protectionEvents == null) return;
|
||||
var audit = nativeAudits.remove(event);
|
||||
if (audit != null) protectionEvents.resolved(audit.action(), cancellable.isCancelled() ? "refused" : "accepted",
|
||||
cancellable.isCancelled() ? audit.reason() : "external_uncancelled", cancellable.isCancelled(), null);
|
||||
}
|
||||
|
||||
private void captureDamage(EntityDamageEvent event, Player target, CombatSources.Origin origin, String reason) {
|
||||
if (protectionEvents == null || damageAudits.containsKey(event) || reason == null) return;
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
var action = protectionEvents.damage(event, origin, recipient, target,
|
||||
parties.partyOf(origin.player()).orElse(null), parties.partyOf(recipient.player()).orElse(null), reason);
|
||||
if (action != null) damageAudits.put(event, new DamageAudit(action, null));
|
||||
}
|
||||
|
||||
private void markDamageDecision(EntityDamageEvent event, String reason) {
|
||||
var audit = damageAudits.get(event);
|
||||
if (audit != null && reason != null) damageAudits.put(event, new DamageAudit(audit.action(), reason));
|
||||
}
|
||||
|
||||
private String protectionReason(CombatSources.Origin origin, Player target) {
|
||||
if (!sources.current(origin)) return "stale_source";
|
||||
var recipient = sources.player(target.getUniqueId());
|
||||
boolean fantasy = origin.session() != null;
|
||||
return !sources.current(origin) || fantasy != (recipient.session() != null) || fantasy
|
||||
&& !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent();
|
||||
if (fantasy != (recipient.session() != null)) return "mixed_mode";
|
||||
if (fantasy && !origin.player().equals(recipient.player())
|
||||
&& parties.partyOf(origin.player()).filter(group -> group.members().contains(recipient.player())).isPresent())
|
||||
return "same_party";
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean blocked(CombatSources.Origin origin, Player target) {
|
||||
return protectionReason(origin, target) != null;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,9 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-configuration-persistence"));
|
||||
private volatile boolean enabled;
|
||||
private volatile String revision;
|
||||
public java.util.Optional<String> revision() { return java.util.Optional.ofNullable(revision); }
|
||||
public record Saved(boolean beforeValue, boolean value, String revision) { }
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
@@ -53,13 +56,32 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
|
||||
public boolean enabled() { return enabled; }
|
||||
|
||||
public synchronized CompletionStage<Boolean> set(boolean value) {
|
||||
public CompletionStage<Boolean> set(boolean value) {
|
||||
return setRecorded(value).thenApply(Saved::value);
|
||||
}
|
||||
|
||||
/** The digest identifies the exact acknowledged config bytes, not a cross-file transaction. */
|
||||
public CompletionStage<Saved> setRecorded(boolean value) {
|
||||
return setRecorded(value, ignored -> { });
|
||||
}
|
||||
|
||||
/** Capture an acknowledged audit intent before the configuration worker finishes draining. */
|
||||
public synchronized CompletionStage<Saved> setRecorded(boolean value, java.util.function.Consumer<Saved> onSaved) {
|
||||
Objects.requireNonNull(onSaved);
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Configuration store is closed"));
|
||||
pending++;
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try { save(value); enabled = value; return value; }
|
||||
catch (IOException | RuntimeException failure) {
|
||||
try {
|
||||
boolean before = enabled;
|
||||
var revision = save(value);
|
||||
enabled = value;
|
||||
this.revision = revision;
|
||||
var saved = new Saved(before, value, revision);
|
||||
try { onSaved.accept(saved); }
|
||||
catch (RuntimeException ignored) { /* An audit observer cannot roll back an acknowledged policy. */ }
|
||||
return saved;
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
// No parser contents, filesystem paths or configuration values in diagnostics.
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Adventure configuration save refused or failed; effective setting unchanged"));
|
||||
@@ -71,7 +93,7 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void save(boolean value) throws IOException {
|
||||
private String save(boolean value) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe directory");
|
||||
var file = directory.resolve("config.yml");
|
||||
@@ -111,7 +133,15 @@ public final class AdventureSettings implements AutoCloseable {
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
String revision;
|
||||
try {
|
||||
revision = "sha256:" + java.util.HexFormat.of().formatHex(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
} catch (java.security.NoSuchAlgorithmException missing) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", missing);
|
||||
}
|
||||
replacement.move(temporary, file);
|
||||
return revision;
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
} catch (OverlappingFileLockException busy) { throw new IOException("Configuration busy"); }
|
||||
}
|
||||
|
||||
@@ -370,20 +370,29 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
|
||||
public CompletableFuture<Optional<CharacterRecord>> renameCharacter(
|
||||
UUID characterId, String name, BigInteger expectedSheetGeneration) {
|
||||
return renameCharacterObserved(characterId, name, expectedSheetGeneration).thenApply(RenameOutcome::record);
|
||||
}
|
||||
|
||||
/** Capture the authoritative before/after pair in the same serialized save, not from stale item text. */
|
||||
public record RenameOutcome(Optional<CharacterRecord> record, Optional<CharacterRecord> before, String status) { }
|
||||
|
||||
public CompletableFuture<RenameOutcome> renameCharacterObserved(
|
||||
UUID characterId, String name, BigInteger expectedSheetGeneration) {
|
||||
Objects.requireNonNull(characterId);
|
||||
Objects.requireNonNull(name);
|
||||
return submit(() -> {
|
||||
var found = repository.load(characterId);
|
||||
if (found.isEmpty() || found.orElseThrow().dead()) return Optional.empty();
|
||||
if (found.isEmpty() || found.orElseThrow().dead())
|
||||
return new RenameOutcome(Optional.empty(), Optional.empty(), "unavailable");
|
||||
var old = found.orElseThrow();
|
||||
if (expectedSheetGeneration != null && !old.sheetGeneration().equals(expectedSheetGeneration))
|
||||
return Optional.empty();
|
||||
if (old.name().equals(name)) return Optional.of(old);
|
||||
return new RenameOutcome(Optional.empty(), Optional.of(old), "stale_generation");
|
||||
if (old.name().equals(name)) return new RenameOutcome(Optional.of(old), Optional.of(old), "unchanged");
|
||||
var renamed = new CharacterRecord(old.id(), name, old.revision().add(BigInteger.ONE), old.stats(), old.level(),
|
||||
old.experience(), old.unspentPoints(), old.health(), old.learned(), old.loadout(), old.passives(), old.cooldowns(),
|
||||
old.restrictedKills(), false, old.deathId(), old.diedAt(), old.coins(), old.coinsInitialized(), old.rewardReceipts(),old.pvpCoinEscrow(),old.gear(),old.sheetGeneration());
|
||||
repository.replace(renamed, old.revision());
|
||||
return Optional.of(renamed);
|
||||
return new RenameOutcome(Optional.of(renamed), Optional.of(old), "renamed");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -882,6 +891,19 @@ public final class CharacterStorage implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Observe accepted save callbacks without waiting for file I/O on the native thread. */
|
||||
public java.util.concurrent.CompletionStage<Void> drained() {
|
||||
return java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (!worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS))
|
||||
throw new java.util.concurrent.CompletionException(new java.io.IOException("Character storage did not drain"));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.util.concurrent.CompletionException(interrupted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop accepting work without blocking shutdown; already queued file writes may finish. */
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
@@ -109,6 +109,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private games.dmg.fantasystick.book.CharacterBookController characterBooks;
|
||||
private games.dmg.fantasystick.persistence.CharacterStorage characters;
|
||||
private AdminCharacterRecovery characterRecovery;
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
|
||||
|
||||
public EquipmentAdminCommand(EquipmentSessionStore receipts, EquipmentSessions sessions, Server server,
|
||||
Consumer<Runnable> nextTick, Consumer<Failure> diagnostics) {
|
||||
@@ -412,6 +413,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
characterRecovery = Objects.requireNonNull(recovery);
|
||||
}
|
||||
|
||||
public void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events) {
|
||||
if (closed || sessionRecoveryEvents != null) throw new IllegalStateException("Session recovery events already attached or command closed");
|
||||
sessionRecoveryEvents = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public void attachPartyEvents(games.dmg.fantasystick.events.PartyEventBridge events){if(closed||partyEvents!=null)throw new IllegalStateException("Party events already attached or command closed");partyEvents=Objects.requireNonNull(events);}
|
||||
|
||||
public void attachPartyRegistrars(games.dmg.fantasystick.party.PartyRegistrarDirectory directory,
|
||||
@@ -441,6 +447,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (closed) return true;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
if (sessionRecoveryEvents != null && args.length > 0
|
||||
&& (args[0].equals("inspect") || args[0].equals("recover"))) {
|
||||
var action = sessionRecoveryEvents.capture(sender, null);
|
||||
var kind = args[0].equals("inspect") ? "equipment_inspect" : "equipment_recovery";
|
||||
sessionRecoveryEvents.observed(action, kind);
|
||||
sessionRecoveryEvents.resolved(action, kind, "refused", "unauthorized");
|
||||
}
|
||||
sender.sendMessage("Permission denied: you need fantasystick.admin to administer Fantasy Stick.");
|
||||
return true;
|
||||
}
|
||||
@@ -497,24 +510,41 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
return true;
|
||||
}
|
||||
if (args[0].equals("recover")) {
|
||||
var request = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(request, "equipment_recovery");
|
||||
var player = server.getPlayer(owner);
|
||||
if (player == null || !player.isOnline()) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(request,
|
||||
"equipment_recovery", "refused", "target_offline");
|
||||
sender.sendMessage("Recovery refused: target " + owner + " must be online. No items changed.");
|
||||
return true;
|
||||
}
|
||||
sessions.recover(player);
|
||||
sessions.recover(player, request);
|
||||
sender.sendMessage("Validated equipment recovery requested for " + owner
|
||||
+ ". This is not confirmation of restoration; ambiguous items and receipts are retained.");
|
||||
return true;
|
||||
}
|
||||
var inspection = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, owner);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(inspection, "equipment_inspect");
|
||||
receipts.find(owner).whenComplete((state, failure) -> {
|
||||
if (failure != null) report(owner, "inspect", failure);
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)) return;
|
||||
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
if (closed) return;
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "refused", "permission_changed");
|
||||
return;
|
||||
}
|
||||
if (sender instanceof org.bukkit.entity.Player player && !player.isOnline()) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "cancelled", "sender_offline");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(inspection,
|
||||
"equipment_inspect", "failed", "receipt_unavailable");
|
||||
sender.sendMessage("Equipment inspection failed for " + owner + "; retained state requires inspection. See server diagnostics.");
|
||||
return;
|
||||
}
|
||||
@@ -530,9 +560,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
+ " character=" + exit.characterId() + " exited=" + exit.exitedAt() + " deadline=" + exit.deadline());
|
||||
}
|
||||
var player = server.getPlayer(owner);
|
||||
var marker = player == null || !player.isOnline()
|
||||
? java.util.Optional.<EquipmentSessionMarker.Token>empty() : EquipmentSessionMarker.read(player);
|
||||
if (sessionRecoveryEvents != null) {
|
||||
sessionRecoveryEvents.inspected(inspection, state,
|
||||
marker.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT))
|
||||
.orElse(player == null || !player.isOnline() ? "offline_unavailable" :
|
||||
EquipmentSessionMarker.present(player) ? "malformed" : "absent"),
|
||||
player != null && player.isOnline());
|
||||
sessionRecoveryEvents.resolved(inspection, "equipment_inspect", "accepted", "read_completed");
|
||||
}
|
||||
if (player == null || !player.isOnline()) sender.sendMessage("Native state: offline; not inspected.");
|
||||
else {
|
||||
var marker = EquipmentSessionMarker.read(player);
|
||||
sender.sendMessage("Online native marker=" + marker.map(token -> "session=" + token.session()
|
||||
+ " character=" + token.character() + " phase=" + token.phase()).orElseGet(() ->
|
||||
EquipmentSessionMarker.present(player) ? "malformed (retained)" : "none"));
|
||||
@@ -1686,22 +1725,39 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
private void adminReviveConfiguration(CommandSender sender,String[] args){if(adminReviveSettings==null){configurationUsage(sender);return;}if(args.length==2){sender.sendMessage(games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+adminReviveSettings.value()+" (applies to later admin revival selections only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.AdminReviveSettings.Update> operation;try{operation=adminReviveSettings.set(args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Admin revival timeout is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-admin-revive",failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null)adminReviveSettings.publish(saved);if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+games.dmg.fantasystick.persistence.AdminReviveSettings.KEY+"="+saved.value()+" for later selections; open selections retain their deadline.":"Admin revival configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
|
||||
|
||||
private void adventureConfiguration(CommandSender sender, String[] args) {
|
||||
var action = sessionRecoveryEvents == null ? null : sessionRecoveryEvents.capture(sender, null);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.observed(action, "adventure_mode_config");
|
||||
if (args.length == 3 && !args[2].equals("true") && !args[2].equals("false")) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
|
||||
"adventure_mode_config", "refused", "invalid_value");
|
||||
sender.sendMessage("Usage: /fstickadmin config adventure-mode [true|false]"); return;
|
||||
}
|
||||
if (settings == null) { sender.sendMessage("Adventure configuration is unavailable."); return; }
|
||||
if (settings == null) {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action,
|
||||
"adventure_mode_config", "failed", "settings_unavailable");
|
||||
sender.sendMessage("Adventure configuration is unavailable."); return;
|
||||
}
|
||||
if (args.length == 2) {
|
||||
if (sessionRecoveryEvents != null) {
|
||||
sessionRecoveryEvents.configRead(action, settings.enabled(), settings.revision());
|
||||
sessionRecoveryEvents.resolved(action, "adventure_mode_config", "accepted", "read_completed");
|
||||
}
|
||||
sender.sendMessage("adventure-mode=" + settings.enabled() + " (applies to new sessions only)"); return;
|
||||
}
|
||||
settings.set(Boolean.parseBoolean(args[2])).whenComplete((saved, failure) -> {
|
||||
settings.setRecorded(Boolean.parseBoolean(args[2]), saved -> {
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.configChanged(action, saved);
|
||||
}).whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-adventure-mode", failure);
|
||||
if (sessionRecoveryEvents != null) sessionRecoveryEvents.resolved(action, "adventure_mode_config",
|
||||
failure == null ? "accepted" : "failed", failure == null ? "save_acknowledged" : "save_failed");
|
||||
if (closed) return;
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed || !sender.hasPermission(PERMISSION)
|
||||
if (closed) return;
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved adventure-mode=" + saved + " for new sessions only; active sessions are unchanged."
|
||||
? "Saved adventure-mode=" + saved.value() + " for new sessions only; active sessions are unchanged."
|
||||
: "Adventure configuration save failed; the effective setting is unchanged.");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
|
||||
@@ -166,7 +166,7 @@ public final class EquipmentSessionEvents implements Listener, AutoCloseable {
|
||||
queued.remove(owner);
|
||||
swaps.remove(owner);
|
||||
boolean reserved = writing.add(owner);
|
||||
try { sessions.invalidateContext(owner); sessions.exit(player); }
|
||||
try { sessions.invalidateContext(owner); sessions.exit(player, "logout"); }
|
||||
finally { if (reserved) writing.remove(owner); observed.remove(owner); }
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
private java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> downedRewardAllocation=ignored->List.of();
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.SessionEventBridge sessionEvents;
|
||||
private games.dmg.fantasystick.events.SwitchDelayEventBridge switchEvents;
|
||||
private games.dmg.fantasystick.events.CharacterIdentityEventBridge identityEvents;
|
||||
private games.dmg.fantasystick.events.EquipmentEventBridge equipmentEvents;
|
||||
private games.dmg.fantasystick.events.SessionRecoveryEventBridge sessionRecoveryEvents;
|
||||
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 =
|
||||
@@ -106,6 +110,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
games.dmg.fantasystick.persistence.OrdinaryVitals ordinaryVitals;
|
||||
boolean vitalsStarted;
|
||||
boolean healthSaveRequested;
|
||||
boolean healthSaveFailed;
|
||||
boolean healthAcknowledged = true;
|
||||
boolean nativeRestored;
|
||||
boolean gearRestoredForModeRecovery;
|
||||
@@ -117,6 +122,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
boolean exitRequested;
|
||||
games.dmg.fantasystick.events.SessionEventBridge.ExitCapture exitEvent;
|
||||
boolean exitEventWritten;
|
||||
games.dmg.fantasystick.events.SwitchDelayEventBridge.ExitContext switchExit;
|
||||
boolean switchEventWritten;
|
||||
String exitReason = "voluntary";
|
||||
java.math.BigInteger packReceiptRevision;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Capture restoreEvent;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action restoreAction;
|
||||
games.dmg.fantasystick.events.EquipmentEventBridge.Action recoveryAction;
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action sessionRecoveryAction;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryOriginalLocation;
|
||||
games.dmg.fantasystick.events.EventInput.Location recoveryCurrentLocation;
|
||||
String recoveryPriorPhase = "unavailable";
|
||||
java.math.BigInteger recoveryReceiptRevision;
|
||||
boolean recoveryResolved;
|
||||
boolean sessionRecoveryResolved;
|
||||
boolean restoreEventWritten;
|
||||
Duration exitDelay;
|
||||
boolean arrival;
|
||||
Attempt recoveryOwner;
|
||||
@@ -210,6 +230,10 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void attachDownedRewardAllocation(java.util.function.Function<DownedEpisode,List<DownedEpisode.Reward>> allocation){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedRewardAllocation=Objects.requireNonNull(allocation);}
|
||||
public synchronized void attachDownedEvents(games.dmg.fantasystick.events.DownedEventBridge events){if(!owned.isEmpty()||downedEvents!=null)throw new IllegalStateException("Downed events already attached or sessions started");downedEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSessionEvents(games.dmg.fantasystick.events.SessionEventBridge events){if(!owned.isEmpty()||sessionEvents!=null)throw new IllegalStateException("Session events already attached or sessions started");sessionEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSwitchEvents(games.dmg.fantasystick.events.SwitchDelayEventBridge events){if(!owned.isEmpty()||switchEvents!=null)throw new IllegalStateException("Switch events already attached or sessions started");switchEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachIdentityEvents(games.dmg.fantasystick.events.CharacterIdentityEventBridge events){if(!owned.isEmpty()||identityEvents!=null)throw new IllegalStateException("Identity events already attached or sessions started");identityEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachEquipmentEvents(games.dmg.fantasystick.events.EquipmentEventBridge events){if(!owned.isEmpty()||equipmentEvents!=null)throw new IllegalStateException("Equipment events already attached or sessions started");equipmentEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachSessionRecoveryEvents(games.dmg.fantasystick.events.SessionRecoveryEventBridge events){if(!owned.isEmpty()||sessionRecoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or sessions started");sessionRecoveryEvents=Objects.requireNonNull(events);}
|
||||
public synchronized void attachDownedDefeatRewards(java.util.function.Function<DownedEpisode,CompletionStage<?>> settlement){if(!owned.isEmpty())throw new IllegalStateException("Sessions already started");downedDefeatRewards=Objects.requireNonNull(settlement);}
|
||||
public synchronized List<DownedEpisode.Reward> finalRewards(DownedEpisode episode){return List.copyOf(downedRewardAllocation.apply(Objects.requireNonNull(episode)));}
|
||||
|
||||
@@ -610,19 +634,29 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
refuse(attempt, "RECOVERY_REQUIRED", "An unresolved equipment receipt requires validated recovery."); return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!state.canEnter(attempt.character, switchDelay.get(), now)) {
|
||||
boolean admitted = state.canEnter(attempt.character, switchDelay.get(), now);
|
||||
if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
|
||||
state, switchDelay.get(), now, admitted);
|
||||
if (!admitted) {
|
||||
Duration remaining = Duration.between(now, state.lastExit().deadline());
|
||||
long seconds = remaining.getSeconds() + (remaining.getNano() == 0 ? 0 : 1);
|
||||
refuse(attempt, "COOLDOWN", "Wait " + seconds
|
||||
+ " seconds to switch characters; the same character may be resumed now."); return;
|
||||
}
|
||||
}
|
||||
} else if (switchEvents != null) switchEvents.evaluated(attempt.player, attempt.character,
|
||||
null, switchDelay.get(), clock.instant(), true);
|
||||
if (!entryNativeSafe(attempt, loaded.state().orElse(null))) {
|
||||
refuse(attempt, "NATIVE_RECOVERY_REQUIRED", "Native equipment marker or control/box remnants require inspection; entry did not change items.");
|
||||
return;
|
||||
}
|
||||
var refusal = equipment.refusal(attempt.player.getInventory(), attempt.owner, UUID.randomUUID());
|
||||
if (refusal.isPresent()) { refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return; }
|
||||
if (refusal.isPresent()) {
|
||||
if (equipmentEvents != null) {
|
||||
var action = equipmentEvents.action(attempt.player, null, attempt.character, "equipment_pack_preflight");
|
||||
equipmentEvents.resolved(action, "refused", refusal.orElseThrow().name().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
refuse(attempt, refusal.orElseThrow().name(), "Equipment entry refused: " + refusal.orElseThrow()); return;
|
||||
}
|
||||
// Restart receipts can belong to dead/missing characters, so they cannot acquire a
|
||||
// live SessionClaims token. Their unresolved equipment outcome still excludes entry.
|
||||
if (owned.values().stream().anyMatch(other -> attempt.character.equals(other.character))) {
|
||||
@@ -639,6 +673,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.saving = false;
|
||||
if (error != null) { attempt.uncertainSave = true; fail(attempt, error); return; }
|
||||
attempt.acknowledged = true;
|
||||
attempt.packReceiptRevision = saved.revision();
|
||||
if (!current(attempt)) { abandon(attempt); return; }
|
||||
// Queue the cross-worker read under lifecycle ownership: close cannot finish
|
||||
// its drain boundary and then have this callback submit fresh character IO.
|
||||
@@ -687,6 +722,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
if(gear!=null)gear.install(attempt.player.getInventory(),record,attempt.owner,attempt.session);
|
||||
mark(attempt, EquipmentSessionMarker.Phase.PACKED);
|
||||
if (equipmentEvents != null) try {
|
||||
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).ifPresent(box -> equipmentEvents.packed(
|
||||
equipmentEvents.capture(attempt.player, attempt.session, record, box), attempt.packReceiptRevision));
|
||||
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
|
||||
if (vitals != null) {
|
||||
attempt.vitalsStarted = true;
|
||||
attempt.healthAcknowledged = false;
|
||||
@@ -708,6 +748,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
attempt.player.updateInventory();
|
||||
if(active.get(attempt.owner)==attempt&&sessionEvents!=null)sessionEvents.entered(attempt.player,attempt.session,record,attempt.entryLocation,attempt.resumedDowned!=null,attempt.previousGameMode!=null);
|
||||
if(active.get(attempt.owner)==attempt&&identityEvents!=null)identityEvents.entered(attempt.player,attempt.session,record);
|
||||
} catch (RuntimeException error) {
|
||||
fail(attempt, error); // Effects may have started: never roll back or replay them.
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Equipment mutation failed; items and receipt require inspection.");
|
||||
@@ -737,9 +778,14 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
* A native exception returns INVALID_BOX with NATIVE_UNCERTAIN notice/diagnostic; never retry it.
|
||||
*/
|
||||
public synchronized EquipmentStorage.RestoreResult exit(Player player) {
|
||||
return exit(player, "voluntary");
|
||||
}
|
||||
|
||||
public synchronized EquipmentStorage.RestoreResult exit(Player player, String reason) {
|
||||
UUID owner = player.getUniqueId();
|
||||
var transition = transitioning.get(owner);
|
||||
if (transition != null) {
|
||||
transition.exitReason = Objects.requireNonNull(reason);
|
||||
captureExitDelay(transition);
|
||||
active.remove(owner, transition);
|
||||
withdrawPresentation(transition);
|
||||
@@ -750,6 +796,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
cancelEntry(owner);
|
||||
var attempt = active.remove(owner);
|
||||
if (attempt == null) { invalidateCursor(owner); return EquipmentStorage.RestoreResult.NOT_FOUND; }
|
||||
attempt.exitReason = Objects.requireNonNull(reason);
|
||||
captureExitDelay(attempt);
|
||||
return restoreExit(attempt);
|
||||
}
|
||||
@@ -785,6 +832,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
withdrawPresentation(attempt);
|
||||
attempt.defeated = true;
|
||||
attempt.exitReason = "defeat";
|
||||
event.setCancelled(true);
|
||||
boolean reserved = transitioning.putIfAbsent(attempt.owner, attempt) == null;
|
||||
// Submit first: any subsequent native getter/setter/notice can reenter disable, which
|
||||
@@ -854,7 +902,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
public synchronized void reviveDowned(DownedEpisode episode,Consumer<Boolean> completion){Objects.requireNonNull(episode);Objects.requireNonNull(completion);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.REVIVED){completion.accept(false);return;}var attempt=active.get(episode.player());if(attempt==null||attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character())){completion.accept(false);return;}var expected=attempt.record.revision();var maximum=attempt.record.maximumHealth().min(NativeHealthLimit.read());characters.saveHealth(attempt.character,expected,maximum,maximum).whenComplete((saved,failure)->persistReturn(()->{synchronized(EquipmentSessions.this){if(failure!=null||saved==null||saved.isEmpty()||active.get(attempt.owner)!=attempt||attempt.downed==null||!attempt.downed.id().equals(episode.id())){if(failure!=null)diagnostic(attempt,failure);completion.accept(false);return;}var record=saved.orElseThrow();try{if(vitals!=null&&!record.revision().equals(attempt.record.revision()))vitals.updateProgress(attempt.player,attempt.session,attempt.record,record);attempt.record=record;attempt.downed=null;attempt.downing=false;attempt.downedProtectionUntil=episode.protectionUntil();double nativeMaximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(Math.min(nativeMaximum,record.health().doubleValue()));refreshControls(attempt,new Active(attempt.owner,attempt.session,attempt.character));attempt.player.closeInventory();notice(attempt.owner,"REVIVED","§aRevive restored your downed character.");completion.accept(true);}catch(RuntimeException publicationFailure){diagnostic(attempt,publicationFailure);completion.accept(false);}}}));}
|
||||
|
||||
/** Publish one already-durable final-death outcome through the existing restoration/Tail custody path. */
|
||||
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
|
||||
public synchronized boolean finalizeDowned(DownedEpisode episode){Objects.requireNonNull(episode);if(episode.phase()!=DownedEpisode.Phase.RESOLVED||episode.outcome()!=DownedEpisode.Outcome.FINAL_DEATH)return false;var attempt=active.get(episode.player());if(attempt==null){persist(()->recordOfflineFinalDeath(episode),(dead,failure)->{if(failure!=null)diagnostic(new Failure(episode.player(),episode.session(),failure));else if(dead.isEmpty())diagnostic(new Failure(episode.player(),episode.session(),new IllegalStateException("Offline final death conflicted with saved character")));});return true;}if(attempt.downed==null||!attempt.downed.id().equals(episode.id())||!attempt.session.equals(episode.session())||!attempt.character.equals(episode.character()))return false;active.remove(attempt.owner,attempt);attempt.finalDowned=episode;attempt.downed=null;withdrawPresentation(attempt);attempt.defeated=true;attempt.exitReason="defeat";boolean reserved=transitioning.putIfAbsent(attempt.owner,attempt)==null;boolean loseCoins=episode.attribution().playerDefeat();persist(()->characters.recordFinalDeath(attempt.character,attempt.record.revision(),episode.id(),episode.resolvedAt(),loseCoins),(dead,failure)->{if(failure!=null){diagnostic(attempt,failure);conversionUncertain(attempt,"Final death save failed or is uncertain");return;}if(dead.isEmpty()){conversionUncertain(attempt,"Character was missing or final death conflicted; no Tail authorized");return;}attempt.deathResolved=true;attempt.deathRecord=dead.orElseThrow();if(downedEvents!=null)downedEvents.finalDeathRecorded(episode);if(closed){conversionUncertain(attempt,"Shutdown before reward, party defeat and Tail conversion");return;}java.util.concurrent.CompletionStage<?> partyMutation=finalConsequences(episode);partyMutation.whenComplete((ignored,partyFailure)->{synchronized(EquipmentSessions.this){if(partyFailure!=null){diagnostic(attempt,partyFailure);conversionUncertain(attempt,"Party defeat mutation failed");return;}attempt.partyDefeatAcknowledged=true;queueTail(attempt);}});});try{var restored=restoreExit(attempt);if(restored==EquipmentStorage.RestoreResult.RESTORED){var retained=attempt.player.getInventory().getItemInOffHand();attempt.deathReference=copy(retained);attempt.deathNativeReference=nativeIdentity(retained);attempt.deathView=attempt.player.getOpenInventory();}double maximum=Objects.requireNonNull(attempt.player.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH)).getValue();attempt.player.setHealth(maximum);notice(attempt.owner,"DEFEAT","§cYour character was defeated.");attempt.defeatReturnTeleport=true;try{boolean returned;try{returned=attempt.player.teleport(attempt.entryLocation.clone());}catch(RuntimeException uncertain){if(downedEvents!=null)downedEvents.returnAttempted(episode,"uncertain");throw uncertain;}if(downedEvents!=null)downedEvents.returnAttempted(episode,returned?"returned":"refused");if(!returned)notice(attempt.owner,"DEFEAT_RETURN_FAILED","Return to the session entry location was refused; you remain here.");}finally{attempt.defeatReturnTeleport=false;if(!attempt.deliveryInvalidated&&owned.get(attempt.owner)==attempt&&attempt.player.isOnline())attempt.deathView=attempt.player.getOpenInventory();}}catch(RuntimeException failure){diagnostic(attempt,failure);conversionUncertain(attempt,"Native final-death transition failed; inspect retained equipment and death state");}finally{if(reserved)transitioning.remove(attempt.owner,attempt);finishDrain();}return true;}
|
||||
|
||||
private CompletionStage<Void> finalConsequences(DownedEpisode episode){var result=new CompletableFuture<Void>();try{nextTick.accept(()->{CompletionStage<?> work;try{work=downedDefeatRewards.apply(episode).thenCompose(ignored->partyDefeat.apply(episode.character()));}catch(RuntimeException failure){result.completeExceptionally(failure);return;}work.whenComplete((ignored,failure)->{if(failure==null)result.complete(null);else result.completeExceptionally(failure);});});}catch(RuntimeException rejected){result.completeExceptionally(rejected);}return result;}
|
||||
|
||||
@@ -1008,6 +1056,8 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
private EquipmentStorage.RestoreResult restoreExit(Attempt attempt) {
|
||||
if(switchEvents!=null&&attempt.switchExit==null&&attempt.record!=null)
|
||||
attempt.switchExit=switchEvents.exit(attempt.player,attempt.session,attempt.record,attempt.exitReason);
|
||||
if(sessionEvents!=null&&attempt.exitEvent==null&&!attempt.defeated&&attempt.downed==null&&!closed&&attempt.player.isOnline()&&attempt.record!=null)
|
||||
attempt.exitEvent=sessionEvents.captureExit(attempt.player,attempt.session,attempt.record,attempt.entryLocation);
|
||||
captureExitDelay(attempt);
|
||||
@@ -1018,12 +1068,27 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
invalidateCursor(attempt.owner);
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
if (equipmentEvents != null) attempt.restoreAction = equipmentEvents.action(
|
||||
attempt.player, attempt.session, attempt.character, "equipment_restore");
|
||||
EquipmentStorage.RestoreResult result;
|
||||
try {
|
||||
result = restoreMarked(attempt);
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.nativeUncertain = true;
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "uncertain", "native_exception");
|
||||
if (attempt.recoveryAction != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
restoreVitals(attempt); // Proven health ownership is independent of uncertain equipment writes.
|
||||
captureRecoveryLocations(attempt);
|
||||
persistExit(attempt, false);
|
||||
diagnostic(attempt, failure);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Restoration failed; items and receipt require inspection.");
|
||||
@@ -1033,10 +1098,28 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED && (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged)) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
persistExit(attempt, result == EquipmentStorage.RestoreResult.RESTORED && modeRestored && vitalsRestored);
|
||||
if (result != EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (equipmentEvents != null) {
|
||||
var reason = result.name().toLowerCase(java.util.Locale.ROOT);
|
||||
equipmentEvents.resolved(attempt.restoreAction, "refused", reason);
|
||||
if (attempt.recoveryAction != null) equipmentEvents.resolved(attempt.recoveryAction, "retained", reason);
|
||||
if (attempt.recoveryAction != null) equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "pending",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"retained", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result);
|
||||
} else sync(attempt);
|
||||
} else {
|
||||
if ((!modeRestored || !vitalsRestored) && equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "pending", "receipt_incomplete");
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "pending", "receipt_incomplete");
|
||||
}
|
||||
sync(attempt);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (reserved) transitioning.remove(attempt.owner, attempt);
|
||||
@@ -1066,6 +1149,18 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
+ ". Items and receipt retained. RESTORED receipts require manual reconciliation too.");
|
||||
return EquipmentStorage.RestoreResult.INVALID_BOX;
|
||||
}
|
||||
if (equipmentEvents != null && attempt.restoreEvent == null) try {
|
||||
equipment.snapshot(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
attempt.player.getItemOnCursor()).ifPresent(box -> {
|
||||
var head = attempt.player.getInventory().getItem(39);
|
||||
boolean displaced = box.displacedHead() && !controls.owned(head, attempt.owner, attempt.session)
|
||||
&& (gear == null || !gear.owned(head, attempt.owner, attempt.session));
|
||||
attempt.restoreEvent = equipmentEvents.capture(attempt.player, attempt.session,
|
||||
attempt.character, attempt.record == null ? null : attempt.record.name(),
|
||||
new EquipmentStorage.Snapshot(box.boxSource(), box.boxSlot(), box.hotbarSlots(),
|
||||
box.armorSlots(), displaced));
|
||||
});
|
||||
} catch (RuntimeException observationFailure) { diagnostic(attempt, observationFailure); }
|
||||
mark(attempt, EquipmentSessionMarker.Phase.RESTORING);
|
||||
var result = equipment.restore(attempt.player.getInventory(), attempt.owner, attempt.session,
|
||||
item -> controls.owned(item, attempt.owner, attempt.session)||(gear!=null&&gear.owned(item,attempt.owner,attempt.session)),
|
||||
@@ -1091,11 +1186,46 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
persist(() -> characters.saveHealth(attempt.character, remaining, limit), (saved, failure) -> {
|
||||
if (failure != null || saved.isEmpty()) {
|
||||
diagnostic(attempt, failure != null ? failure : new IllegalStateException("Character unavailable for health save"));
|
||||
attempt.healthSaveFailed = true;
|
||||
if (attempt.nativeRestored) recoveryIncomplete(attempt, "health_unverified");
|
||||
return;
|
||||
}
|
||||
attempt.healthAcknowledged = true;
|
||||
if (attempt.nativeRestored) persistExit(attempt, true);
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
attempt.healthSaveFailed = true;
|
||||
diagnostic(attempt, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void recoveryIncomplete(Attempt attempt, String reason) {
|
||||
if (attempt.recoveryResolved && attempt.sessionRecoveryResolved) return;
|
||||
if (equipmentEvents != null && attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restored", "native_restored_receipt_pending",
|
||||
"ambiguous", reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "ambiguous", "native_restored_receipt_pending",
|
||||
reason, attempt.recoveryReceiptRevision);
|
||||
}
|
||||
|
||||
private void captureRecoveryLocations(Attempt attempt) {
|
||||
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null) return;
|
||||
attempt.recoveryOriginalLocation = sessionRecoveryEvents.captureLocation(attempt.entryLocation);
|
||||
attempt.recoveryCurrentLocation = sessionRecoveryEvents.captureLocation(attempt.player.getLocation());
|
||||
}
|
||||
|
||||
private void sessionReconciled(Attempt attempt, String phase, String outcome, String quality,
|
||||
String reason, java.math.BigInteger revision) {
|
||||
if (sessionRecoveryEvents == null || attempt.sessionRecoveryAction == null || attempt.sessionRecoveryResolved) return;
|
||||
attempt.sessionRecoveryResolved = true;
|
||||
try {
|
||||
sessionRecoveryEvents.reconciled(attempt.sessionRecoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, phase, outcome, quality, reason, attempt.recoveryReceiptRevision,
|
||||
revision, attempt.recoveryAction == null ? null : attempt.recoveryAction.id(),
|
||||
attempt.recoveryOriginalLocation, attempt.recoveryCurrentLocation);
|
||||
} catch (RuntimeException failure) { diagnostic(attempt, failure); }
|
||||
}
|
||||
|
||||
@@ -1125,11 +1255,38 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
: receipts.recordExit(attempt.owner, attempt.session, attempt.exitDelay), (state, failure) -> {
|
||||
attempt.saving = false;
|
||||
if (failure != null) {
|
||||
sessionReconciled(attempt,
|
||||
attempt.nativeRestored ? "restored" : "pending",
|
||||
attempt.nativeRestored ? "ambiguous" : "failed",
|
||||
attempt.nativeRestored ? "native_restored_receipt_pending" : "receipt_unavailable",
|
||||
"receipt_save_failed", attempt.recoveryReceiptRevision);
|
||||
diagnostic(attempt, failure);
|
||||
if (attempt.defeated) conversionUncertain(attempt,
|
||||
"Equipment restoration persistence failed before Tail conversion");
|
||||
} else if (complete) {
|
||||
attempt.gearAcknowledged = true;
|
||||
if (!attempt.restoreEventWritten && equipmentEvents != null && attempt.restoreEvent != null) {
|
||||
attempt.restoreEventWritten = true;
|
||||
equipmentEvents.restored(attempt.restoreEvent, state.revision());
|
||||
}
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.restoreAction, "accepted", "receipt_completed");
|
||||
if (attempt.recoveryAction != null && !attempt.recoveryResolved) {
|
||||
attempt.recoveryResolved = true;
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "accepted", "receipt_completed");
|
||||
equipmentEvents.recovery(attempt.recoveryAction,
|
||||
attempt.session, attempt.character, attempt.recoveryPriorPhase, "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"restored", "receipt_completed", state.revision());
|
||||
}
|
||||
}
|
||||
sessionReconciled(attempt, "restored", "restored",
|
||||
attempt.restoreEvent == null ? "receipt_and_marker_only" : "verified_box_and_marker",
|
||||
"receipt_completed", state.revision());
|
||||
if (!attempt.switchEventWritten && switchEvents != null) {
|
||||
attempt.switchEventWritten = true;
|
||||
switchEvents.started(attempt.switchExit, state);
|
||||
}
|
||||
if (!attempt.defeated && !attempt.exitEventWritten && attempt.exitEvent != null && sessionEvents != null) {
|
||||
attempt.exitEventWritten = true;
|
||||
sessionEvents.exited(attempt.session,attempt.exitEvent);
|
||||
@@ -1243,16 +1400,21 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
|
||||
/** Explicit proof-based reconciliation; never infer restoration from an absent box. */
|
||||
public synchronized void recover(Player player) { recover(player, false); }
|
||||
public synchronized void recover(Player player) { recover(player, false, null); }
|
||||
|
||||
public synchronized void recover(Player player, games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
recover(player, false, action);
|
||||
}
|
||||
|
||||
/** Join reconciliation: recover a pending receipt, otherwise request normal entry. */
|
||||
public synchronized void arrive(Player player) {
|
||||
var directory=downedDirectory;
|
||||
if(directory!=null){var death=directory.all().stream().filter(value->value.player().equals(player.getUniqueId())&&value.phase()==DownedEpisode.Phase.RESOLVED&&value.outcome()==DownedEpisode.Outcome.FINAL_DEATH&&!value.published()).findFirst().orElse(null);if(death!=null){reconcileOfflineFinalDeath(player,death);return;}}
|
||||
recover(player,true);
|
||||
recover(player,true,null);
|
||||
}
|
||||
|
||||
private void recover(Player player, boolean arrival) {
|
||||
private void recover(Player player, boolean arrival,
|
||||
games.dmg.fantasystick.events.SessionRecoveryEventBridge.Action action) {
|
||||
UUID owner = player.getUniqueId();
|
||||
invalidateCursor(owner);
|
||||
if (closed || !player.isOnline()) return;
|
||||
@@ -1271,6 +1433,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
cancelEntry(owner);
|
||||
var attempt = new Attempt(player, owner, null);
|
||||
attempt.recoveryOwner = previous;
|
||||
attempt.sessionRecoveryAction = action;
|
||||
// A recovered receipt has no process-local never-packed proof.
|
||||
attempt.effectsStarted = true;
|
||||
attempt.arrival = arrival;
|
||||
@@ -1287,7 +1450,16 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
notice(attempt.owner, "STALE_RECOVERY", "Session ownership changed after the recovery read; no old receipt was replayed.");
|
||||
return;
|
||||
}
|
||||
if (failure != null) { diagnostic(attempt, failure); return; }
|
||||
if (failure != null) {
|
||||
if (sessionRecoveryEvents != null) {
|
||||
var action = attempt.sessionRecoveryAction == null
|
||||
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
|
||||
sessionRecoveryEvents.reconciled(action, null, null, "unavailable", "unavailable",
|
||||
"failed", "receipt_unreadable", "receipt_read_failed", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
diagnostic(attempt, failure); return;
|
||||
}
|
||||
var receipt = state.map(EquipmentSessionStore.State::pending).orElse(null);
|
||||
if (receipt == null) {
|
||||
if (attempt.arrival) {
|
||||
@@ -1298,10 +1470,25 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
return; // Ordinary no-sheet/no-receipt arrivals are not rejected entry attempts.
|
||||
}
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(
|
||||
equipmentEvents.action(attempt.player, null, null, "equipment_recovery"), "refused", "no_receipt");
|
||||
if (sessionRecoveryEvents != null) {
|
||||
var action = attempt.sessionRecoveryAction == null
|
||||
? sessionRecoveryEvents.system(attempt.player) : attempt.sessionRecoveryAction;
|
||||
sessionRecoveryEvents.reconciled(action, null, null, "absent", "absent", "refused",
|
||||
"receipt_missing", "no_receipt", null, null, null, null,
|
||||
sessionRecoveryEvents.captureLocation(attempt.player.getLocation()));
|
||||
}
|
||||
notice(attempt.owner, "NO_RECEIPT", "No saved equipment receipt is available; items were left unchanged."); return;
|
||||
}
|
||||
attempt.character = receipt.characterId();
|
||||
attempt.session = receipt.sessionId();
|
||||
attempt.recoveryReceiptRevision = state.orElseThrow().revision();
|
||||
if (sessionRecoveryEvents != null && attempt.sessionRecoveryAction == null)
|
||||
attempt.sessionRecoveryAction = sessionRecoveryEvents.system(attempt.player);
|
||||
if (equipmentEvents != null) attempt.recoveryAction = equipmentEvents.action(
|
||||
attempt.player, attempt.session, attempt.character,
|
||||
attempt.arrival ? "equipment_arrival_recovery" : "equipment_recovery");
|
||||
attempt.previousGameMode = receipt.previousGameMode();
|
||||
attempt.ordinaryVitals = receipt.ordinaryVitals();
|
||||
attempt.vitalsStarted = attempt.ordinaryVitals != null;
|
||||
@@ -1309,6 +1496,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
attempt.modeStarted = attempt.previousGameMode != null;
|
||||
var previous = owned.get(attempt.owner);
|
||||
if (previous != null && !attempt.session.equals(previous.session)) {
|
||||
if (equipmentEvents != null) equipmentEvents.resolved(attempt.recoveryAction, "refused", "session_mismatch");
|
||||
notice(attempt.owner, "SESSION_MISMATCH", "Saved and process-local sessions disagree; items were left unchanged."); return;
|
||||
}
|
||||
if (previous != null && (previous.defeated || previous.vitalsStarted)) {
|
||||
@@ -1316,6 +1504,11 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
// proof. Keep the original death authority and its pending worker completion;
|
||||
// do not replace it with a recovery Attempt that could release its claim early.
|
||||
previous.deliveryInvalidated = true;
|
||||
previous.recoveryAction = attempt.recoveryAction;
|
||||
previous.sessionRecoveryAction = attempt.sessionRecoveryAction;
|
||||
previous.recoveryReceiptRevision = attempt.recoveryReceiptRevision;
|
||||
previous.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
restoreExit(previous);
|
||||
return;
|
||||
}
|
||||
@@ -1325,17 +1518,47 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
}
|
||||
owned.put(attempt.owner, attempt);
|
||||
transitioning.put(attempt.owner, attempt);
|
||||
attempt.recoveryPriorPhase = EquipmentSessionMarker.read(attempt.player)
|
||||
.map(token -> token.phase().name().toLowerCase(java.util.Locale.ROOT)).orElse("unavailable");
|
||||
try {
|
||||
saveRemainingHealth(attempt);
|
||||
boolean modeRestored = restoreGameMode(attempt);
|
||||
var result = restoreMarked(attempt);
|
||||
boolean vitalsRestored = restoreVitals(attempt);
|
||||
captureRecoveryLocations(attempt);
|
||||
if (result == EquipmentStorage.RestoreResult.RESTORED) {
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) attempt.gearRestoredForModeRecovery = true;
|
||||
if (!modeRestored || !vitalsRestored || !attempt.healthAcknowledged) {
|
||||
attempt.gearRestoredForModeRecovery = true;
|
||||
if (equipmentEvents != null) {
|
||||
var reason = !attempt.healthAcknowledged ? "health_unverified" : "native_restore_incomplete";
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "pending", reason);
|
||||
if (attempt.healthSaveFailed || !modeRestored || !vitalsRestored)
|
||||
recoveryIncomplete(attempt, reason);
|
||||
}
|
||||
}
|
||||
persistExit(attempt, modeRestored && vitalsRestored);
|
||||
sync(attempt);
|
||||
} else notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
} else {
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "retained", result.name().toLowerCase(java.util.Locale.ROOT));
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "pending", "receipt_and_marker_only", "retained",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), state.orElseThrow().revision());
|
||||
}
|
||||
sessionReconciled(attempt, "pending", "retained", "receipt_and_marker_only",
|
||||
result.name().toLowerCase(java.util.Locale.ROOT), attempt.recoveryReceiptRevision);
|
||||
notice(attempt.owner, result.name(), "Equipment recovery refused: " + result + "; receipt and items retained.");
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
if (equipmentEvents != null) {
|
||||
equipmentEvents.resolved(attempt.recoveryAction, "uncertain", "native_exception");
|
||||
equipmentEvents.recovery(attempt.recoveryAction, attempt.session, attempt.character,
|
||||
attempt.recoveryPriorPhase, "restoring", "native_uncertain", "ambiguous",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
}
|
||||
captureRecoveryLocations(attempt);
|
||||
sessionReconciled(attempt, "restoring", "ambiguous", "native_uncertain",
|
||||
"native_exception", attempt.recoveryReceiptRevision);
|
||||
fail(attempt, error);
|
||||
notice(attempt.owner, "NATIVE_UNCERTAIN", "Recovery mutation failed; native phase, items and receipt retained for inspection.");
|
||||
} finally {
|
||||
@@ -1357,7 +1580,7 @@ public final class EquipmentSessions implements AutoCloseable {
|
||||
if (attempt.defeated && !attempt.bookDelivered) conversionUncertain(attempt, "Shutdown before confirmed Tail conversion");
|
||||
}
|
||||
for (var owner : java.util.List.copyOf(pending.keySet())) cancelEntry(owner);
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player);
|
||||
for (var attempt : java.util.List.copyOf(active.values())) exit(attempt.player, "shutdown");
|
||||
characterRecoveries.clear();
|
||||
recoveryRecipients.clear();
|
||||
claims.close();
|
||||
|
||||
@@ -232,6 +232,52 @@ class CharacterBookNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void previousVolumeReturnsToOverviewWithoutInventingAnotherBookOpen() 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); f.activate(actor);
|
||||
var old = actor.core.record;
|
||||
var stats = new java.util.EnumMap<games.dmg.fantasystick.progression.Stat, java.math.BigInteger>(games.dmg.fantasystick.progression.Stat.class);
|
||||
stats.putAll(old.stats()); stats.put(games.dmg.fantasystick.progression.Stat.STR,
|
||||
new java.math.BigInteger("1234567890".repeat(4000)));
|
||||
var saved = new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
|
||||
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(),
|
||||
java.math.BigInteger.ZERO, old.health(), old.learned(), old.loadout(), old.cooldowns(), false, null, null);
|
||||
new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).replace(saved, old.revision());
|
||||
click(f, actor); drainReads(f); runQueued(f);
|
||||
var next = PartyBookNativeTest.button(actor, "[Next pages]");
|
||||
PartyBookNativeTest.click(f, actor, next); drainReads(f); runQueued(f);
|
||||
assertTrue(actor.books.size() >= 2);
|
||||
var previous = PartyBookNativeTest.button(actor, "[Previous pages]");
|
||||
PartyBookNativeTest.click(f, actor, previous); drainReads(f); runQueued(f);
|
||||
assertEquals(3, actor.books.size());
|
||||
PartyBookNativeTest.click(f, actor, previous);
|
||||
assertEquals(3, actor.books.size(), "Replayed nonce cannot reopen a volume");
|
||||
}
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var opened = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.opened"))
|
||||
.map(e -> e.get("id").getAsString()).distinct().toList();
|
||||
assertEquals(1, opened.size());
|
||||
var navigations = records.stream().filter(e -> e.get("type").getAsString().endsWith("character_book.navigated"))
|
||||
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
|
||||
(first, duplicate) -> first, java.util.LinkedHashMap::new));
|
||||
assertEquals(2, navigations.size());
|
||||
var previous = navigations.values().stream().map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("volume_direction").getAsString().equals("previous")).findFirst().orElseThrow();
|
||||
assertEquals("30", previous.get("source_volume_start").getAsString());
|
||||
assertEquals("0", previous.get("destination_volume_start").getAsString());
|
||||
assertEquals("overview", previous.get("destination_section").getAsString());
|
||||
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("stale_or_forged_payload")));
|
||||
}
|
||||
|
||||
@Test void largeRecordsRemainCompleteThroughBoundedSingleUseNavigation() throws Exception {
|
||||
int expectedNavigations;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
@@ -279,6 +325,16 @@ class CharacterBookNativeTest {
|
||||
assertArrayEquals(bytes, Files.readAllBytes(data.resolve("characters").resolve(saved.id() + ".yml")));
|
||||
}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")).count());assertEquals(expectedNavigations,events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated")&&event.getAsJsonObject("data").get("native_open").getAsString().equals("acknowledged")&&event.getAsJsonObject("data").get("offset").getAsInt()>0).count());
|
||||
var navigation=events.stream().filter(event->event.get("type").getAsString().endsWith("character_book.navigated"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("0",navigation.get("source_volume_start").getAsString());
|
||||
assertEquals("30",navigation.get("destination_volume_start").getAsString());
|
||||
assertEquals("next",navigation.get("volume_direction").getAsString());
|
||||
assertTrue(navigation.get("source_navigation_page").getAsInt()>0);
|
||||
assertEquals("1",navigation.get("destination_page").getAsString());
|
||||
assertEquals("volume_start",navigation.get("destination_section").getAsString());
|
||||
assertFalse(navigation.has("ability_id"));assertFalse(navigation.has("passive_id"));
|
||||
assertTrue(navigation.has("location"));
|
||||
}
|
||||
|
||||
@Test void acknowledgedRenameTransferAndReloadInspectTheCurrentRecordWithoutCarryingTheFormerHoldersView() throws Exception {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.persistence.YamlCharacterRepository;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
import org.bukkit.event.inventory.CraftItemEvent;
|
||||
import org.bukkit.event.inventory.InventoryAction;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class CharacterIdentityEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredManualCraftOnlyPublishesAnAcknowledgedDeliveredCharacter() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
var world = mock(CraftWorld.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("world");
|
||||
actor.eyeLocation = new Location(world, 12, 70, 31);
|
||||
var preview = fixture.recipes.get(CharacterSheets.RECIPE);
|
||||
var event = new CraftItemEvent(preview, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(event);
|
||||
assertTrue(event.isCancelled());
|
||||
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(complete);
|
||||
complete.run();
|
||||
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
|
||||
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
|
||||
complete.run(); // A repeated completion cannot create or deliver a second logical success.
|
||||
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var created = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created")).filter(entry -> entry.getAsJsonObject("data")
|
||||
.get("character_id").getAsString().equals(id.toString())).toList();
|
||||
assertEquals(1, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
var values = created.getFirst().getAsJsonObject("data");
|
||||
assertEquals(saved.revision().toString(), values.get("revision").getAsString());
|
||||
assertEquals("fantasystick:character_sheet", values.get("recipe_id").getAsString());
|
||||
assertEquals("cursor", values.get("delivery_route").getAsString());
|
||||
assertEquals(actor.id.toString(), values.get("holder_player_id").getAsString());
|
||||
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
|
||||
assertFalse(values.has("holder_session_id"));
|
||||
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void priorCancellationAndStaleCraftDoNotInventCreation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
var recipe = fixture.recipes.get(CharacterSheets.RECIPE);
|
||||
var cancelled = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
cancelled.setCancelled(true);
|
||||
fixture.dispatcher.accept(cancelled);
|
||||
assertTrue(fixture.tasks.isEmpty());
|
||||
var stale = new CraftItemEvent(recipe, actor.fixture.view, InventoryType.SlotType.RESULT, 0,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(stale);
|
||||
assertTrue(stale.isCancelled());
|
||||
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
actor.fixture.grid.getMatrix()[0].setAmount(7);
|
||||
complete.run();
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
|
||||
var resolutions = entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.filter(details -> details.has("action_kind")
|
||||
&& details.get("action_kind").getAsString().equals("character_craft")).toList();
|
||||
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("cancelled")));
|
||||
assertTrue(resolutions.stream().anyMatch(details -> details.get("outcome").getAsString().equals("stale")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeCraftSaveNeverReportsCreatedOrDeliversASheet() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
var before = actor.fixture.grid.getMatrix()[0].getAmount();
|
||||
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
|
||||
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
Files.createDirectories(data.resolve("characters"));
|
||||
if (!Files.exists(data.resolve("characters/.records.lock"))) Files.createFile(data.resolve("characters/.records.lock"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
|
||||
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.dispatcher.accept(event);
|
||||
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete);
|
||||
complete.run();
|
||||
}
|
||||
assertTrue(event.isCancelled());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("character.created")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(values -> values.has("action_kind") && values.get("action_kind").getAsString()
|
||||
.equals("character_craft") && values.get("reason").getAsString().equals("save")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledNativeDropSpawnRemainsUnconfirmedWithoutClaimingACreatedSheet() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
var world = new PlayerSheetDropsNativeTest.WorldFixture();
|
||||
actor.eyeLocation = new Location(world.world, 3, 72, 8);
|
||||
world.onSpawn = spawn -> spawn.setCancelled(true);
|
||||
var before = actor.fixture.grid.getMatrix()[0].getAmount();
|
||||
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
|
||||
InventoryType.SlotType.RESULT, 0, ClickType.DROP, InventoryAction.DROP_ONE_SLOT);
|
||||
fixture.dispatcher.accept(event);
|
||||
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
|
||||
assertEquals(1, world.spawnCalls);
|
||||
assertEquals(before, actor.fixture.grid.getMatrix()[0].getAmount());
|
||||
assertTrue(actor.fixture.view.getCursor().isEmpty());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created")), "Unconfirmed spawn is not a delivered sheet");
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("character_craft") && value.get("reason").getAsString().equals("drop_unconfirmed")
|
||||
&& value.get("outcome").getAsString().equals("uncertain")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredNativeAnvilPublishesOnlyItsAcknowledgedUnicodeRename() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
repository.create(original);
|
||||
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
|
||||
var world = mock(CraftWorld.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("world");
|
||||
anvil.actor.eyeLocation = new Location(world, 4, 64, 9);
|
||||
var sheet = new CharacterSheets().create(original);
|
||||
var output = CharacterAnvilNamesNativeTest.named(sheet,
|
||||
net.kyori.adventure.text.Component.text("雪 🐉 <literal>"));
|
||||
anvil.grid.setItem(0, sheet);
|
||||
anvil.grid.setItem(2, output);
|
||||
var event = new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(anvil.actor.player, anvil.view, output);
|
||||
fixture.dispatcher.accept(event);
|
||||
fixture.dispatcher.accept(event);
|
||||
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (repository.load(original.id()).orElseThrow().revision().equals(original.revision())
|
||||
&& System.nanoTime() < deadline) Thread.sleep(5);
|
||||
var saved = repository.load(original.id()).orElseThrow();
|
||||
assertEquals("雪 🐉 <literal>", saved.name());
|
||||
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.renamed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var renamed = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.renamed")).toList();
|
||||
assertEquals(1, renamed.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
var values = renamed.getFirst().getAsJsonObject("data");
|
||||
assertEquals(original.name(), values.get("before_name").getAsString());
|
||||
assertEquals(saved.name(), values.get("after_name").getAsString());
|
||||
assertEquals(original.revision().toString(), values.get("revision_before").getAsString());
|
||||
assertEquals(saved.revision().toString(), values.get("revision_after").getAsString());
|
||||
assertEquals(saved.sheetGeneration().toString(), values.get("generation").getAsString());
|
||||
assertEquals("unverified", values.get("native_item_outcome").getAsString());
|
||||
assertEquals(anvil.actor.id.toString(), values.get("holder_player_id").getAsString());
|
||||
assertEquals("no_active_session", values.get("holder_session_status").getAsString());
|
||||
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCraftAndRenameReplayOriginalEventsAfterWriterOutage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new ManualCraftingControllerNativeTest.Actor(2, 2);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var event = new CraftItemEvent(fixture.recipes.get(CharacterSheets.RECIPE), actor.fixture.view,
|
||||
InventoryType.SlotType.RESULT, 0, ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
fixture.dispatcher.accept(event);
|
||||
var complete = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(complete); complete.run();
|
||||
var id = new CharacterSheets().identify(actor.fixture.view.getCursor()).orElseThrow();
|
||||
var saved = new YamlCharacterRepository(data).load(id).orElseThrow();
|
||||
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
|
||||
var sheet = new CharacterSheets().create(saved);
|
||||
var result = CharacterAnvilNamesNativeTest.named(sheet,
|
||||
net.kyori.adventure.text.Component.text("Archived 雪 🐉"));
|
||||
anvil.grid.setItem(0, sheet); anvil.grid.setItem(2, result);
|
||||
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
|
||||
anvil.actor.player, anvil.view, result));
|
||||
var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (new YamlCharacterRepository(data).load(id).orElseThrow().name().equals(saved.name())
|
||||
&& System.nanoTime() < deadline) Thread.sleep(5);
|
||||
assertEquals("Archived 雪 🐉", new YamlCharacterRepository(data).load(id).orElseThrow().name());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pendingIds;
|
||||
try (var files = Files.list(data.resolve("character-identity-event-intents"))) {
|
||||
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(2, pendingIds.size(), "Each acknowledged mutation has independent recoverable intent");
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 100 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.renamed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created") || entry.get("type").getAsString().endsWith("character.renamed")).toList();
|
||||
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()), "Recovery keeps the original accepted event IDs");
|
||||
assertEquals(id.toString(), domain.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created")).findFirst().orElseThrow().getAsJsonObject("data")
|
||||
.get("character_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void distinctNativeHolderActivationObservesOnlyProvenCharacterTransfer() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var first = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var second = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(first.core.id, first.player);
|
||||
fixture.players.put(second.core.id, second.player);
|
||||
var world = mock(CraftWorld.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("other-world");
|
||||
second.core.location.setWorld(world);
|
||||
fixture.activate(first);
|
||||
var firstSession = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions"))
|
||||
.active(first.core.id).orElseThrow().session();
|
||||
var sheet = first.core.inventory.getItemInOffHand().clone();
|
||||
((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions")).exit(first.player);
|
||||
fixture.awaitCharacterAndReceipt(first);
|
||||
first.core.inventory.setItemInOffHand(null);
|
||||
second.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(second);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var secondSession = sessions.active(second.core.id).orElseThrow().session();
|
||||
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
|
||||
sessions.exit(second.player);
|
||||
fixture.awaitCharacterAndReceipt(second);
|
||||
fixture.reenter(second);
|
||||
assertEquals(first.core.record.id(), sessions.active(second.core.id).orElseThrow().character());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var transfers = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.transfer_observed")).toList();
|
||||
assertEquals(1, transfers.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
var values = transfers.getFirst().getAsJsonObject("data");
|
||||
assertEquals(first.core.id.toString(), values.get("from_player_id").getAsString());
|
||||
assertEquals(second.core.id.toString(), values.get("to_player_id").getAsString());
|
||||
assertEquals(firstSession.toString(), values.get("from_session_id").getAsString());
|
||||
assertEquals(secondSession.toString(), values.get("to_session_id").getAsString());
|
||||
assertEquals("activation_after_prior_holder", values.get("custody_quality").getAsString());
|
||||
assertEquals(first.world.getUID().toString(), values.getAsJsonObject("from_location")
|
||||
.get("world_id").getAsString());
|
||||
assertEquals("cached_at_previous_activation", values.getAsJsonObject("from_location")
|
||||
.get("quality").getAsString());
|
||||
assertEquals(world.getUID().toString(), values.getAsJsonObject("to_location")
|
||||
.get("world_id").getAsString());
|
||||
assertEquals(world.getUID().toString(), values.getAsJsonObject("location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void firstActivationAfterRestartDoesNotInventUnseenSheetTransfer() throws Exception {
|
||||
org.bukkit.inventory.ItemStack transferred;
|
||||
try (var firstBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
firstBoot.plugin.setEnabled(true);
|
||||
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
firstBoot.players.put(owner.core.id, owner.player);
|
||||
firstBoot.activate(owner);
|
||||
transferred = owner.core.inventory.getItemInOffHand().clone();
|
||||
((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(firstBoot.plugin, "sessions")).exit(owner.player);
|
||||
firstBoot.awaitCharacterAndReceipt(owner);
|
||||
firstBoot.plugin.setEnabled(false);
|
||||
}
|
||||
try (var secondBoot = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
secondBoot.plugin.setEnabled(true);
|
||||
var newHolder = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
secondBoot.players.put(newHolder.core.id, newHolder.player);
|
||||
newHolder.core.inventory.setItemInOffHand(transferred);
|
||||
secondBoot.activate(newHolder);
|
||||
secondBoot.plugin.setEnabled(false);
|
||||
}
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.transfer_observed")), "Offline custody was not seen by this plugin lifetime");
|
||||
}
|
||||
|
||||
@Test void staleGenerationAndSaveFailureResolveAnvilAttemptsWithoutRenameSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var repository = new YamlCharacterRepository(data);
|
||||
var original = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(original);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters");
|
||||
var operation = UUID.randomUUID();
|
||||
storage.beginRecovery(new games.dmg.fantasystick.persistence.CharacterStorage.RecoveryRequest(
|
||||
operation, original.id(), UUID.randomUUID(), UUID.randomUUID(), original.revision(),
|
||||
original.sheetGeneration(), false, null, java.math.BigDecimal.valueOf(20))).join();
|
||||
storage.completeRecovery(original.id(), operation).join();
|
||||
var anvil = new CharacterAnvilNamesNativeTest.AnvilFixture();
|
||||
var stale = new CharacterSheets().create(original);
|
||||
anvil.grid.setItem(0, stale);
|
||||
var result = CharacterAnvilNamesNativeTest.named(stale,
|
||||
net.kyori.adventure.text.Component.text("forged revision 雪"));
|
||||
anvil.grid.setItem(2, result);
|
||||
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
|
||||
anvil.actor.player, anvil.view, result));
|
||||
storage.find(original.id()).join();
|
||||
|
||||
var second = new games.dmg.fantasystick.crafting.CharacterFactory()
|
||||
.create(games.dmg.fantasystick.progression.ProgressionRules.defaults());
|
||||
repository.create(second);
|
||||
var live = new CharacterSheets().create(second);
|
||||
anvil.grid.setItem(0, live);
|
||||
var attempted = CharacterAnvilNamesNativeTest.named(live,
|
||||
net.kyori.adventure.text.Component.text("not persisted"));
|
||||
anvil.grid.setItem(2, attempted);
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
|
||||
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.dispatcher.accept(new org.purpurmc.purpur.event.inventory.AnvilTakeResultEvent(
|
||||
anvil.actor.player, anvil.view, attempted));
|
||||
storage.find(second.id()).handle((ignored, failure) -> null).join();
|
||||
}
|
||||
for (int i = 0; i < 100 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved")).count() < 2; i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(second.name(), repository.load(second.id()).orElseThrow().name());
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.renamed")));
|
||||
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
|
||||
.filter(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("character_rename")).toList();
|
||||
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("stale_generation")));
|
||||
assertTrue(resolved.stream().anyMatch(value -> value.get("reason").getAsString().equals("save_failed")));
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var results = new ArrayList<JsonObject>();
|
||||
try (var dates = Files.list(data.resolve("events"))) {
|
||||
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
|
||||
.toList()) {
|
||||
try (var files = Files.list(date)) {
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(file);
|
||||
var complete = content.lastIndexOf('\n');
|
||||
if (complete < 0) continue;
|
||||
for (var line : content.substring(0, complete).lines().toList())
|
||||
if (!line.isBlank()) results.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,12 @@ class DownedSessionNativeTest{
|
||||
}
|
||||
@Test void deathMenuAcceptanceFinalizesTheSameEpisodeAndDeliversOneTail()throws Exception{
|
||||
games.dmg.fantasystick.party.CharacterParty finalParty;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
|
||||
games.dmg.fantasystick.events.SwitchDelayEventBridge switchAudit;
|
||||
UUID exitedSession;
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);switchAudit=(games.dmg.fantasystick.events.SwitchDelayEventBridge)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"switchAuditEvents");var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var persistentParties=PartyLifecycleNativeTest.directory(f);finalParty=persistentParties.create(actor.core.record.id(),"Finalists").join().party().orElseThrow();var retainedPhoenix=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");retainedPhoenix.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();actor.core.health=0;f.dispatcher.accept(death(actor));var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();exitedSession=episode.session();assertEquals(finalParty.id(),persistentParties.partyOf(actor.core.record.id()).orElseThrow().id());var menu=actor.inventories.getLast();assertTrue(plain(menu.getItem(11)).contains("Accept Death"));assertTrue(plain(menu.getItem(13)).contains("Phoenix Down"));assertTrue(plain(menu.getItem(15)).contains("Wait for Revive"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(11);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<120&&!new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}PartyLifecycleNativeTest.directory(f).defeat(UUID.randomUUID()).join();f.awaitCharacterAndReceipt(actor);PartyBookNativeTest.drain(f);var dead=new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow();assertTrue(dead.dead());assertEquals(episode.id(),dead.deathId());assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.FINAL_DEATH,directory.find(dead.id()).join().orElseThrow().outcome());for(int attempt=0;attempt<80&&!directory.find(dead.id()).join().orElseThrow().published();attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}assertTrue(directory.find(dead.id()).join().orElseThrow().published());assertEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());assertEquals(java.math.BigInteger.ONE,retainedPhoenix.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertTrue(persistentParties.partyOf(actor.core.record.id()).isEmpty());}
|
||||
switchAudit.drained().toCompletableFuture().get(5,java.util.concurrent.TimeUnit.SECONDS);
|
||||
try(var restarted=new FantasyStickLifecycleNativeTest.Fixture(data)){restarted.plugin.setEnabled(true);for(int i=0;i<100&&!java.nio.file.Files.exists(data.resolve("events"));i++)Thread.sleep(10);for(int i=0;i<100;i++){boolean found=false;try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())if(java.nio.file.Files.readString(file).contains("character.switch_delay_started")){found=true;break;}}if(found)break;Thread.sleep(10);}}
|
||||
var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var switchStarts=events.stream().filter(event->event.get("type").getAsString().endsWith("character.switch_delay_started")).filter(event->event.getAsJsonObject("data").get("session_id").getAsString().equals(exitedSession.toString())).toList();assertEquals(1,switchStarts.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals("defeat",switchStarts.getFirst().getAsJsonObject("data").get("exit_reason").getAsString());assertEquals(1,events.stream().filter(event->event.get("type").getAsString().contains(".party.")).count());var ended=events.stream().filter(event->event.get("type").getAsString().endsWith("party.disbanded")&&event.getAsJsonObject("data").has("reason")&&event.getAsJsonObject("data").get("reason").getAsString().equals("final_death")).toList();assertFalse(ended.isEmpty());assertEquals(1,ended.stream().map(event->event.get("id").getAsString()).distinct().count());assertEquals(finalParty.id().toString(),ended.getFirst().getAsJsonObject("data").get("party_id").getAsString());
|
||||
}
|
||||
@Test void explicitPhoenixDownChoiceConsumesOneAndRescuesTheExactDownedCharacter()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);org.mockito.Mockito.when(actor.world.getUID()).thenReturn(UUID.randomUUID());f.activate(actor);var inventory=(games.dmg.fantasystick.shop.RareInventoryDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"rareInventory");inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();inventory.add(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN,UUID.randomUUID()).join();PartyBookNativeTest.open(f,actor);String inventoryText=PartyBookNativeTest.text(actor);assertTrue(inventoryText.contains("Phoenix Down: 2"),inventoryText);assertTrue(inventoryText.contains("Use Phoenix Down"),inventoryText);assertFalse(inventoryText.contains("[Use]"),inventoryText);actor.core.health=0;var death=death(actor);f.dispatcher.accept(death);var directory=(games.dmg.fantasystick.session.DownedDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downed");for(int attempt=0;attempt<80&&(directory.all().isEmpty()||actor.inventories.isEmpty());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var episode=directory.all().getFirst();var menu=actor.inventories.getLast();assertEquals(org.bukkit.Material.FEATHER,menu.getItem(13).getType());assertTrue(plain(menu.getItem(13)).contains("Use Phoenix Down"));assertTrue(plain(menu.getItem(13)).contains("2"));var view=org.mockito.Mockito.mock(org.bukkit.inventory.InventoryView.class);org.mockito.Mockito.when(view.getTopInventory()).thenReturn(menu);var click=org.mockito.Mockito.mock(org.bukkit.event.inventory.InventoryClickEvent.class);org.mockito.Mockito.when(click.getView()).thenReturn(view);org.mockito.Mockito.when(click.getWhoClicked()).thenReturn(actor.player);org.mockito.Mockito.when(click.getRawSlot()).thenReturn(13);var controller=(games.dmg.fantasystick.session.DownedController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"downedController");controller.click(click);controller.click(click);for(int attempt=0;attempt<160&&(!directory.find(actor.core.record.id()).join().orElseThrow().published()||inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN).compareTo(java.math.BigInteger.ONE)!=0||actor.player.getHealth()!=actor.maximumHealth.getValue());attempt++){var task=f.tasks.poll(50,java.util.concurrent.TimeUnit.MILLISECONDS);if(task!=null)task.run();Thread.sleep(5);}var resolved=directory.find(actor.core.record.id()).join().orElseThrow();assertEquals(games.dmg.fantasystick.session.DownedEpisode.Outcome.REVIVED,resolved.outcome());assertEquals("phoenix_down",resolved.decision().source());assertEquals(episode.id(),resolved.id());assertEquals(java.math.BigInteger.ONE,inventory.quantity(actor.core.record.id(),games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));assertEquals(actor.maximumHealth.getValue(),actor.player.getHealth());assertTrue(((games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions")).downedProtected(actor.core.id));assertFalse(new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(actor.core.record.id()).orElseThrow().dead());assertNotEquals(org.bukkit.Material.WRITTEN_BOOK,actor.core.inventory.getItemInOffHand().getType());var protectedDamage=org.mockito.Mockito.mock(org.bukkit.event.entity.EntityDamageEvent.class);org.mockito.Mockito.when(protectedDamage.getEntity()).thenReturn(actor.player);org.mockito.Mockito.when(protectedDamage.getFinalDamage()).thenReturn(3d);controller.damage(protectedDamage);org.mockito.Mockito.verify(protectedDamage).setCancelled(true);f.plugin.setEnabled(false);var events=new java.util.ArrayList<com.google.gson.JsonObject>();try(var files=java.nio.file.Files.walk(data.resolve("events"))){for(var file:files.filter(path->path.getFileName().toString().endsWith(".jsonl")).toList())for(var line:java.nio.file.Files.readAllLines(file))if(!line.isBlank())events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());}var phoenixEvents=events.stream().filter(event->event.get("type").getAsString().endsWith("character.downed_resolved")&&event.getAsJsonObject("data").get("outcome").getAsString().equals("phoenix_down")).toList();assertFalse(phoenixEvents.isEmpty());assertEquals(1,phoenixEvents.stream().map(event->event.get("id").getAsString()).distinct().count());var phoenixData=phoenixEvents.getFirst().getAsJsonObject("data");assertEquals("downed_menu",phoenixData.get("selection_entry_point").getAsString());assertEquals("2",phoenixData.get("quantity_before").getAsString());assertEquals("1",phoenixData.get("quantity_after").getAsString());assertEquals("10.0",phoenixData.get("maximum_health").getAsString());assertTrue(events.stream().filter(event->event.get("type").getLine truncated
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EquipmentEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void acknowledgedNativePackAndExactRestorationProduceOwnJsonlWithoutDuplicatingSessionEvents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var active = sessions.active(actor.core.id).orElseThrow();
|
||||
var pending = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertEquals(active.session(), pending.pending().sessionId());
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
var completed = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
assertNull(completed.pending());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var packed = entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.packed")).toList();
|
||||
var restored = entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).toList();
|
||||
assertEquals(1, packed.stream().map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(1, restored.stream().map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(active.session().toString(), packed.getFirst().getAsJsonObject("data")
|
||||
.get("session_id").getAsString());
|
||||
assertEquals(active.session().toString(), restored.getFirst().getAsJsonObject("data")
|
||||
.get("session_id").getAsString());
|
||||
assertEquals(pending.revision().toString(), packed.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_revision").getAsString());
|
||||
assertEquals(completed.revision().toString(), restored.getFirst().getAsJsonObject("data")
|
||||
.get("receipt_revision").getAsString());
|
||||
assertEquals("main", restored.getFirst().getAsJsonObject("data").get("box_source").getAsString());
|
||||
assertEquals("none", restored.getFirst().getAsJsonObject("data")
|
||||
.get("displaced_head_handling").getAsString());
|
||||
assertTrue(packed.getFirst().getAsJsonObject("data").get("slot_classes").getAsJsonArray().size() >= 1);
|
||||
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("character.entered")).map(event -> event.get("id").getAsString()).distinct().count());
|
||||
assertEquals(1, entries.stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("character.exited")).map(event -> event.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedReceiptCompletionCannotTurnNativeRestoreIntoAcknowledgedEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("receipt-backup.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
sessions.exit(actor.player));
|
||||
((games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "characters"))
|
||||
.find(actor.core.record.id()).get(5, TimeUnit.SECONDS);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
var callback = fixture.tasks.poll(300, TimeUnit.MILLISECONDS);
|
||||
if (callback == null) break;
|
||||
callback.run();
|
||||
}
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> fixture.receipts().find(actor.core.id)
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
assertTrue(records().stream().noneMatch(event -> event.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedRestorationKeepsOriginalIdAcrossClosedWriterAndRestart() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pendingIds;
|
||||
try (var files = Files.list(data.resolve("equipment-event-intents"))) {
|
||||
pendingIds = files.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pendingIds.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitDomain(fixture, "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var domain = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).toList();
|
||||
assertEquals(pendingIds, domain.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(domain.stream().allMatch(entry -> entry.getAsJsonObject("data")
|
||||
.get("session_id").getAsString().equals(session.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cursorHeldGenuineBoxAndDisplacedHeadAreCapturedWithoutItemContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.inventory.setHelmet(new org.bukkit.inventory.ItemStack(org.bukkit.Material.CARVED_PUMPKIN));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.RESTORED,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertEquals(org.bukkit.Material.CARVED_PUMPKIN, actor.core.cursor.getType());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var restored = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("cursor", restored.get("box_source").getAsString());
|
||||
assertEquals("cursor", restored.get("displaced_head_handling").getAsString());
|
||||
assertFalse(restored.toString().contains("CARVED_PUMPKIN"));
|
||||
assertFalse(restored.toString().contains("DIAMOND_SWORD"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartedArrivalDoesNotInventHealthAcknowledgmentOrCompletedRestore() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitDomain(fixture, "equipment.packed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
actor.core.inventory.setItem(0, null);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertEquals(0, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).count());
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).findFirst().orElseThrow();
|
||||
var value = recovery.getAsJsonObject("data");
|
||||
assertEquals(session.toString(), value.get("session_id").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertEquals("restored", value.get("current_phase").getAsString());
|
||||
assertEquals("native_restored_receipt_pending", value.get("custody_quality").getAsString());
|
||||
assertEquals("ambiguous", value.get("outcome").getAsString());
|
||||
assertEquals("health_unverified", value.get("reason").getAsString());
|
||||
assertTrue(value.has("location"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainCursorWriteReportsAmbiguousRecoveryWithoutFalseRestore() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Native cursor write uncertain"); };
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.INVALID_BOX,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
actor.core.beforeCursorMutation = () -> {};
|
||||
sessions.recover(actor.player);
|
||||
assertTrue(fixture.tasks.isEmpty(), "Uncertain native writes cannot be replayed automatically");
|
||||
awaitAction(fixture, "equipment_restore", "uncertain");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_restore") && value.get("outcome").getAsString().equals("uncertain")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ambiguousNativeBoxCopiesRetainReceiptWithoutInventingRestoration() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
sessions.recover(actor.player);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).toList();
|
||||
assertEquals(1, recovery.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
assertEquals("retained", recovery.getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals("ambiguous", recovery.getFirst().getAsJsonObject("data").get("reason").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedOccupiedSlotThenProofBasedRecoveryReportsRetainedAndRestoredOutcomes() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
actor.core.inventory.setItem(0, null);
|
||||
sessions.recover(actor.player);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
awaitDomain(fixture, "equipment.packed", "equipment.restored", "equipment.recovery_resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var recovery = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.recovery_resolved")).toList();
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved")).map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_restore") && value.get("reason").getAsString().equals("occupied_slot")));
|
||||
assertTrue(recovery.stream().noneMatch(entry -> entry.getAsJsonObject("data").get("outcome")
|
||||
.getAsString().equals("retained")), "Initial refusal is not a second recovery attempt");
|
||||
assertTrue(recovery.stream().anyMatch(entry -> entry.getAsJsonObject("data").get("outcome")
|
||||
.getAsString().equals("restored") && entry.getAsJsonObject("data").get("session_id")
|
||||
.getAsString().equals(session.toString())));
|
||||
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")).map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitAction(FantasyStickLifecycleNativeTest.Fixture fixture, String kind, String outcome) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
if (records().stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(data -> data.has("action_kind") && data.get("action_kind").getAsString().equals(kind)
|
||||
&& data.get("outcome").getAsString().equals(outcome))) return;
|
||||
}
|
||||
fail("Action resolution did not reach JSONL before shutdown");
|
||||
}
|
||||
|
||||
private void awaitDomain(FantasyStickLifecycleNativeTest.Fixture fixture, String... types) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
var entries = records();
|
||||
if (java.util.Arrays.stream(types).allMatch(type -> entries.stream()
|
||||
.anyMatch(entry -> entry.get("type").getAsString().endsWith(type)))) return;
|
||||
}
|
||||
fail("Accepted equipment events did not reach JSONL before shutdown");
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
try (var dates = Files.list(data.resolve("events"))) {
|
||||
for (var date : dates.filter(path -> path.getFileName().toString().matches("[0-9]{4}-[0-9]{2}-[0-9]{2}"))
|
||||
.toList()) try (var files = Files.list(date)) {
|
||||
for (var file : files.filter(path -> path.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(file);
|
||||
int complete = content.lastIndexOf('\n');
|
||||
if (complete < 0) continue;
|
||||
for (var line : content.substring(0, complete).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback"))
|
||||
.map(e -> e.get("id").getAsString()).distinct().count(),
|
||||
"At-least-once file export may repeat a line, not a logical preference change");
|
||||
}
|
||||
|
||||
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
|
||||
|
||||
@@ -149,8 +149,13 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(records.contains("\"item_kind\":\"control\""));
|
||||
assertTrue(records.contains("\"control_kind\":\"book\""));
|
||||
assertTrue(records.contains("\"guard_reason\":\"protected_item\""));
|
||||
assertEquals(1, records.split("games.dmg.minecraft.fantasystick.action.observed", -1).length - 1,
|
||||
"The duplicate offhand callback must not create another logical observation");
|
||||
assertEquals(1, records.lines().filter(line -> !line.isBlank())
|
||||
.map(line -> com.google.gson.JsonParser.parseString(line).getAsJsonObject())
|
||||
.filter(entry -> entry.get("type").getAsString().endsWith("action.observed"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.filter(details -> details.has("guard_reason")
|
||||
&& details.get("guard_reason").getAsString().equals("protected_item"))
|
||||
.count(), "The duplicate offhand callback must not create another logical observation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +436,23 @@ class FantasyStickLifecycleNativeTest {
|
||||
if (route >= 4) assertEquals(quantity, world.spawnCalls);
|
||||
complete.run();
|
||||
assertEquals(1, actor.syncs);
|
||||
java.util.List<com.google.gson.JsonObject> created = java.util.List.of();
|
||||
for (int attempt = 0; attempt < 100; attempt++) {
|
||||
created = localRecords(directory).stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.created")).toList();
|
||||
if (created.size() == quantity) break;
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertEquals(quantity, created.stream().map(entry -> entry.get("id").getAsString()).distinct().count());
|
||||
assertEquals(java.util.stream.IntStream.range(0, quantity).boxed().collect(java.util.stream.Collectors.toSet()),
|
||||
created.stream().map(entry -> entry.getAsJsonObject("data").get("index").getAsInt())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(1, created.stream().map(entry -> entry.getAsJsonObject("data").get("action_id").getAsString())
|
||||
.distinct().count(), "All outputs share their actual craft action");
|
||||
assertEquals(new java.util.HashSet<>(ids), created.stream().map(entry ->
|
||||
java.util.UUID.fromString(entry.getAsJsonObject("data").get("character_id").getAsString()))
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(created.stream().allMatch(entry -> entry.getAsJsonObject("data").get("quantity").getAsInt() == quantity));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,13 +602,17 @@ class FantasyStickLifecycleNativeTest {
|
||||
|
||||
@Test void packagedRemovalSwapQuitAndDisableRestoreExactlyOnce() throws Exception {
|
||||
for (String route : List.of("removal", "swap", "quit", "disable")) {
|
||||
java.util.UUID exitedSession;
|
||||
games.dmg.fantasystick.events.SwitchDelayEventBridge audit;
|
||||
try (var f = new Fixture(data.resolve(route))) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data.resolve(route)); f.players.put(a.core.id, a.player);
|
||||
var viewer = f.viewer(a, data.resolve(route)); f.activate(a);
|
||||
audit = (games.dmg.fantasystick.events.SwitchDelayEventBridge) Fixture.field(f.plugin, "switchAuditEvents");
|
||||
assertNativeName(f, a, viewer, a.core.record.name()); assertChat(f, a, a.core.record.name());
|
||||
var receipts = f.receipts();
|
||||
var session = receipts.find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).orElseThrow().pending();
|
||||
exitedSession = session.sessionId();
|
||||
var characters = (CharacterStorage) Fixture.field(f.plugin, "characters");
|
||||
var worker = (java.util.concurrent.ExecutorService) Fixture.field(characters, "worker");
|
||||
var entered = new java.util.concurrent.CountDownLatch(1);
|
||||
@@ -643,11 +669,46 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
audit.drained().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
Path directory = data.resolve(route);
|
||||
if (route.equals("disable")) {
|
||||
var intent = directory.resolve("switch-event-intents").resolve(exitedSession + ".pending");
|
||||
assertTrue(java.nio.file.Files.exists(intent), "Shutdown exit intent survives closed event writer");
|
||||
}
|
||||
try (var restarted = new Fixture(directory)) {
|
||||
restarted.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 100 && localRecords(directory).stream()
|
||||
.noneMatch(entry -> entry.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
|
||||
Thread.sleep(10);
|
||||
}
|
||||
var events = localRecords(directory);
|
||||
var starts = events.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.switch_delay_started"))
|
||||
.filter(entry -> entry.getAsJsonObject("data").get("session_id").getAsString()
|
||||
.equals(exitedSession.toString())).toList();
|
||||
assertEquals(1, starts.stream().map(entry -> entry.get("id").getAsString()).distinct().count(), route);
|
||||
var start = starts.getFirst().getAsJsonObject("data");
|
||||
assertEquals(route.equals("quit") ? "logout" : route.equals("disable") ? "shutdown" : "voluntary",
|
||||
start.get("exit_reason").getAsString());
|
||||
assertEquals("900", start.get("duration_seconds").getAsString());
|
||||
assertEquals(starts.getFirst().get("time").getAsString(), start.get("exited_at").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> localRecords(Path directory) throws IOException {
|
||||
var events = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
@Test void packagedRefusalsExplainSpaceAndIneligibleEquipmentWithoutMutation() throws Exception {
|
||||
for (boolean full : new boolean[]{false, true}) try (var f = new Fixture(data.resolve("refusal-" + full))) {
|
||||
for (boolean full : new boolean[]{false, true}) {
|
||||
Path directory = data.resolve("refusal-" + full);
|
||||
try (var f = new Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new Actor(data.resolve("refusal-" + full)); f.players.put(a.core.id, a.player);
|
||||
if (full) for (int slot = 9; slot < 36; slot++) a.core.inventory.setItem(slot, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE, 64));
|
||||
@@ -658,6 +719,25 @@ class FantasyStickLifecycleNativeTest {
|
||||
assertTrue(a.messages.getFirst().contains(full ? "free one slot" : "vanilla shulker"), a.messages::toString);
|
||||
assertEquals(1, a.particles.size()); assertTrue(a.particles.getFirst() > 0 && a.particles.getFirst() <= 16);
|
||||
assertTrue(f.receipts().find(a.core.id).toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS).isEmpty());
|
||||
boolean actionWritten = false;
|
||||
for (int i = 0; i < 300; i++) {
|
||||
if (localRecords(directory).stream().anyMatch(entry -> entry.get("type").getAsString().endsWith("action.resolved")
|
||||
&& entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("equipment_pack_preflight"))) {
|
||||
actionWritten = true; break;
|
||||
}
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertTrue(actionWritten, "Preflight refusal must reach local JSONL");
|
||||
}
|
||||
var entries = localRecords(directory);
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("equipment.packed")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("action_kind") && value.get("action_kind").getAsString()
|
||||
.equals("equipment_pack_preflight") && value.get("reason").getAsString()
|
||||
.equals(full ? "no_space" : "ineligible_equipment") && value.get("outcome").getAsString()
|
||||
.equals("refused")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,6 +1093,21 @@ class FantasyStickLifecycleNativeTest {
|
||||
}
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var started = records.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
|
||||
.map(e -> e.getAsJsonObject("data")).findFirst().orElseThrow();
|
||||
assertEquals(value.equals("default") ? "900" : value, started.get("duration_seconds").getAsString());
|
||||
assertEquals("logout", started.get("exit_reason").getAsString());
|
||||
assertEquals(value.equals("0"), started.get("zero_duration").getAsBoolean());
|
||||
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("action_kind")
|
||||
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("character_switch_preflight")
|
||||
&& e.getAsJsonObject("data").get("outcome").getAsString().equals(value.equals("0") ? "allowed" : "refused")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1483,6 +1578,16 @@ class FantasyStickLifecycleNativeTest {
|
||||
try {
|
||||
for (var worker : workers) assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS),
|
||||
"Accepted persistence must drain before fixture disposal; production shutdown is nonblocking");
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "switchAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "identityAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "equipmentAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((java.util.concurrent.CompletionStage<?>) field(plugin, "sessionRecoveryAuditDrain"))
|
||||
.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException failure) {
|
||||
throw new IOException("Switch event intent worker did not drain before fixture disposal", failure);
|
||||
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); }
|
||||
}
|
||||
@Override public void close() throws IOException {
|
||||
|
||||
@@ -438,6 +438,7 @@ class ManualCraftingControllerNativeTest {
|
||||
case "getUniqueId" -> id;
|
||||
case "getOpenInventory" -> current;
|
||||
case "getEyeLocation" -> java.util.Objects.requireNonNull(eyeLocation).clone();
|
||||
case "getLocation" -> eyeLocation == null ? null : eyeLocation.clone();
|
||||
case "isOnline" -> online;
|
||||
case "updateInventory" -> { syncs++; onSync.run(); yield null; }
|
||||
case "getName" -> "CraftingActor";
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Current persistent-party native damage decision -> generic local JSONL action. */
|
||||
class PartyProtectionEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void currentCharacterPartyNativeDirectDamageRefusalHasOneCorrelatedFinalDecision() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture);
|
||||
var group = parties.create(attacker.core.id).orElseThrow();
|
||||
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, invitation.id()));
|
||||
var damage = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
|
||||
assertTrue(damage.isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party")); i++)
|
||||
Thread.sleep(20);
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("combat.damage")), "A protected hit cannot become an accepted damage record");
|
||||
assertTrue(parties.leave(target.core.id, group.id()));
|
||||
assertFalse(PartyCombatNativeTest.hit(fixture, attacker.player, target.player).isCancelled());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var observed = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.observed") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.toList();
|
||||
var resolved = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("same_party"))
|
||||
.toList();
|
||||
assertEquals(1, observed.size()); assertEquals(1, resolved.size());
|
||||
assertEquals(observed.getFirst().getAsJsonObject("data").get("action_id").getAsString(),
|
||||
resolved.getFirst().getAsJsonObject("data").get("action_id").getAsString());
|
||||
var value = resolved.getFirst().getAsJsonObject("data");
|
||||
assertEquals(group.id().toString(), value.get("source_party_id").getAsString());
|
||||
assertEquals(group.id().toString(), value.get("target_party_id").getAsString());
|
||||
assertEquals("refused", value.get("outcome").getAsString());
|
||||
assertTrue(value.has("source_location")); assertTrue(value.has("target_location"));
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("party.created")), "Protection cannot resurrect transient lifecycle records");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void observationDoesNotReconcileNativeOngoingProvenanceBeforeProtectionPriority() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(effect.getType());
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
|
||||
null, effect, attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true));
|
||||
var before = target.core.pdc.serializeToBytes();
|
||||
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(effect.getType());
|
||||
var atNormalPriority = new org.bukkit.event.Listener() {
|
||||
byte[] observed;
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.NORMAL)
|
||||
public void inspect(org.bukkit.event.entity.EntityDamageEvent ignored) {
|
||||
observed = assertDoesNotThrow(() -> target.core.pdc.serializeToBytes());
|
||||
}
|
||||
};
|
||||
fixture.dispatcher.register(atNormalPriority, fixture.plugin);
|
||||
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
|
||||
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
|
||||
fixture.dispatcher.accept(tick);
|
||||
assertNotNull(atNormalPriority.observed, "NORMAL-priority observer must execute");
|
||||
assertArrayEquals(before, atNormalPriority.observed,
|
||||
"Audit must not reconcile native effect PDC earlier than its protection guard");
|
||||
assertFalse(tick.isCancelled());
|
||||
assertFalse(java.util.Arrays.equals(before, target.core.pdc.serializeToBytes()),
|
||||
"Original HIGHEST protection read still reconciles an ended effect");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void poisonWitherAndFireTickRecheckBoundSourceWithoutInventedNativeAttacker() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
|
||||
org.bukkit.potion.PotionEffectType.WITHER)) {
|
||||
var effect = new org.bukkit.potion.PotionEffect(type, 200, 0);
|
||||
org.mockito.Mockito.doReturn(effect).when(target.player).getPotionEffect(type);
|
||||
var apply = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
|
||||
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
|
||||
fixture.dispatcher.accept(apply);
|
||||
assertFalse(apply.isCancelled());
|
||||
}
|
||||
org.mockito.Mockito.doReturn(200).when(target.player).getFireTicks();
|
||||
var ignite = new org.bukkit.event.entity.EntityCombustByEntityEvent(attacker.player, target.player, 10.0f);
|
||||
fixture.dispatcher.accept(ignite); assertFalse(ignite.isCancelled());
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture);
|
||||
var group = parties.create(attacker.core.id).orElseThrow();
|
||||
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, invitation.id()));
|
||||
var beforeVeto = target.core.pdc.serializeToBytes();
|
||||
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
|
||||
var vetoedTick = new org.bukkit.event.entity.EntityDamageEvent(target.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
|
||||
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
|
||||
vetoedTick.setCancelled(true); fixture.dispatcher.accept(vetoedTick);
|
||||
assertArrayEquals(beforeVeto, target.core.pdc.serializeToBytes(),
|
||||
"A prior veto must not reconcile or clear owned native provenance");
|
||||
org.mockito.Mockito.doReturn(new org.bukkit.potion.PotionEffect(
|
||||
org.bukkit.potion.PotionEffectType.POISON, 200, 0)).when(target.player)
|
||||
.getPotionEffect(org.bukkit.potion.PotionEffectType.POISON);
|
||||
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
|
||||
var tick = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
|
||||
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
|
||||
fixture.dispatcher.accept(tick);
|
||||
assertTrue(tick.isCancelled(), cause.toString());
|
||||
}
|
||||
for (var type : List.of(org.bukkit.potion.PotionEffectType.POISON,
|
||||
org.bukkit.potion.PotionEffectType.WITHER)) {
|
||||
var remove = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player,
|
||||
new org.bukkit.potion.PotionEffect(type, 200, 0), null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.MILK,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false);
|
||||
fixture.dispatcher.accept(remove);
|
||||
org.mockito.Mockito.doReturn(null).when(target.player).getPotionEffect(type);
|
||||
}
|
||||
org.mockito.Mockito.doReturn(0).when(target.player).getFireTicks();
|
||||
for (var cause : List.of(org.bukkit.event.entity.EntityDamageEvent.DamageCause.POISON,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.WITHER,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FIRE_TICK)) {
|
||||
var expired = new org.bukkit.event.entity.EntityDamageEvent(target.player, cause,
|
||||
org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class), 1);
|
||||
fixture.dispatcher.accept(expired);
|
||||
assertFalse(expired.isCancelled(), "Expired provenance cannot create party protection: " + cause);
|
||||
}
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.count() < 3; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.map(entry -> entry.getAsJsonObject("data")).toList();
|
||||
assertEquals(3, decisions.size());
|
||||
assertEquals(java.util.Set.of("poison", "wither", "fire_tick"), decisions.stream()
|
||||
.map(value -> value.get("damage_cause").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
|
||||
&& value.get("source_location_status").getAsString().equals("unavailable")
|
||||
&& value.has("target_location")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void mixedModeAndPriorCancelledHitsReportTruthfulDistinctOutcomes() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker);
|
||||
var mixed = PartyCombatNativeTest.hit(fixture, attacker.player, target.player);
|
||||
assertTrue(mixed.isCancelled());
|
||||
fixture.activate(target);
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture);
|
||||
var group = parties.create(attacker.core.id).orElseThrow();
|
||||
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, invitation.id()));
|
||||
var source = org.mockito.Mockito.mock(org.bukkit.damage.DamageSource.class);
|
||||
org.mockito.Mockito.when(source.getCausingEntity()).thenReturn(attacker.player);
|
||||
org.mockito.Mockito.when(source.getDirectEntity()).thenReturn(attacker.player);
|
||||
var cancelled = new org.bukkit.event.entity.EntityDamageByEntityEvent(attacker.player, target.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 4);
|
||||
cancelled.setCancelled(true); fixture.dispatcher.accept(cancelled);
|
||||
assertTrue(cancelled.isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.count() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var results = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.map(entry -> entry.getAsJsonObject("data")).toList();
|
||||
assertEquals(2, results.size());
|
||||
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("mixed_mode")
|
||||
&& value.get("outcome").getAsString().equals("refused")));
|
||||
assertTrue(results.stream().anyMatch(value -> value.get("reason").getAsString().equals("prior_cancelled")
|
||||
&& value.get("outcome").getAsString().equals("cancelled")
|
||||
&& value.get("prior_cancelled").getAsBoolean()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void stalePotionAndLingeringSplashCancellationHaveBoundedNativeResolutions() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var potion = PartyCombatNativeTest.entity(org.bukkit.entity.ThrownPotion.class);
|
||||
org.mockito.Mockito.when(potion.getShooter()).thenReturn(attacker.player);
|
||||
fixture.dispatcher.accept(new org.bukkit.event.entity.ProjectileLaunchEvent(potion));
|
||||
PartyCombatNativeTest.exit(fixture, attacker);
|
||||
var splash = new org.bukkit.event.entity.PotionSplashEvent(potion,
|
||||
new java.util.HashMap<>(java.util.Map.of(target.player, 1.0)));
|
||||
fixture.dispatcher.accept(splash); assertTrue(splash.isCancelled());
|
||||
var cloud = PartyCombatNativeTest.entity(org.bukkit.entity.AreaEffectCloud.class);
|
||||
var lingering = new org.bukkit.event.entity.LingeringPotionSplashEvent(potion, cloud);
|
||||
fixture.dispatcher.accept(lingering); assertTrue(lingering.isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|
||||
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
|
||||
.count() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& (entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_splash_protection")
|
||||
|| entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_lingering_protection")))
|
||||
.map(entry -> entry.getAsJsonObject("data")).toList();
|
||||
assertEquals(java.util.Set.of("party_splash_protection", "party_lingering_protection"), decisions.stream()
|
||||
.map(value -> value.get("action_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(decisions.stream().allMatch(value -> value.get("reason").getAsString().equals("stale_source")
|
||||
&& value.get("outcome").getAsString().equals("refused")));
|
||||
assertEquals("not_single_proved_player", decisions.stream().filter(value -> value.get("action_kind")
|
||||
.getAsString().equals("party_lingering_protection")).findFirst().orElseThrow()
|
||||
.get("target_status").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void protectedImpactPotionAndIgnitionDecisionsWithoutDamageEventsAreAudited() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture);
|
||||
var group = parties.create(attacker.core.id).orElseThrow();
|
||||
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, invitation.id()));
|
||||
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
|
||||
var impact = new org.bukkit.event.entity.ProjectileHitEvent(arrow, target.player);
|
||||
fixture.dispatcher.accept(impact); assertTrue(impact.isCancelled());
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.POISON, 200, 0);
|
||||
var poison = new org.bukkit.event.entity.EntityPotionEffectEvent(target.player, null, effect,
|
||||
attacker.player, org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_SPLASH,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, true);
|
||||
fixture.dispatcher.accept(poison); assertTrue(poison.isCancelled());
|
||||
var ignition = new org.bukkit.event.entity.EntityCombustByEntityEvent(arrow, target.player, 10.0f);
|
||||
fixture.dispatcher.accept(ignition); assertTrue(ignition.isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
|
||||
.count() < 3; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var outcomes = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().startsWith("party_"))
|
||||
.map(entry -> entry.getAsJsonObject("data")).toList();
|
||||
assertEquals(java.util.Set.of("party_projectile_impact_protection", "party_effect_application_protection",
|
||||
"party_ignite_protection"), outcomes.stream().map(value -> value.get("action_kind").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(outcomes.stream().allMatch(value -> value.get("reason").getAsString().equals("same_party")
|
||||
&& value.get("final_cancelled").getAsBoolean()));
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("combat.damage")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void serializedOldProjectileAfterRestartRetainsOldSessionAndNeverClaimsNewHolder() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var originalSession = sessions.active(attacker.core.id).orElseThrow().session();
|
||||
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
|
||||
var bytes = arrow.getPersistentDataContainer().serializeToBytes();
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
var serialized = PartyCombatNativeTest.entity(org.bukkit.entity.Arrow.class);
|
||||
org.mockito.Mockito.when(serialized.getShooter()).thenReturn(attacker.player);
|
||||
serialized.getPersistentDataContainer().readFromBytes(bytes, true);
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(PartyCombatNativeTest.hit(fixture, serialized, target.player).isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source")); i++)
|
||||
Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("stale_source"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(originalSession.toString(), result.get("source_session_id").getAsString());
|
||||
assertEquals("unavailable", result.get("provenance_age_status").getAsString());
|
||||
assertTrue(result.has("target_location"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void malformedAndForgedProjectileOriginsNeverBorrowCurrentCharacterOrParty() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var arrow = PartyCombatNativeTest.projectile(fixture, attacker.player);
|
||||
var key = arrow.getPersistentDataContainer().getKeys().stream()
|
||||
.filter(value -> value.getKey().equals("combat_origin_v1")).findFirst().orElseThrow();
|
||||
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING, "malformed");
|
||||
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
|
||||
arrow.getPersistentDataContainer().set(key, org.bukkit.persistence.PersistentDataType.STRING,
|
||||
attacker.core.id + "/" + java.util.UUID.randomUUID() + "/" + attacker.core.record.id());
|
||||
assertTrue(PartyCombatNativeTest.hit(fixture, arrow, target.player).isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.count() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var decisions = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.map(entry -> entry.getAsJsonObject("data")).toList();
|
||||
assertEquals(List.of("stale_source", "stale_source"), decisions.stream()
|
||||
.map(value -> value.get("reason").getAsString()).toList());
|
||||
assertEquals("ordinary_or_unproved", decisions.getFirst().get("provenance_quality").getAsString());
|
||||
assertFalse(decisions.getFirst().has("source_session_id"));
|
||||
assertEquals("claimed_session_unverified", decisions.get(1).get("provenance_quality").getAsString());
|
||||
assertFalse(decisions.stream().anyMatch(value -> value.has("source_party_id")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void attributedProjectileRechecksCurrentPartyAndExpiresWithOriginalSession() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var attacker = actor(fixture); var target = actor(fixture);
|
||||
fixture.activate(attacker); fixture.activate(target);
|
||||
var projectile = PartyCombatNativeTest.projectile(fixture, attacker.player);
|
||||
var parties = PartyLifecycleNativeTest.parties(fixture);
|
||||
var group = parties.create(attacker.core.id).orElseThrow();
|
||||
var invitation = parties.invite(attacker.core.id, group.id(), target.core.id).orElseThrow();
|
||||
assertTrue(parties.accept(target.core.id, invitation.id()));
|
||||
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
|
||||
PartyCombatNativeTest.exit(fixture, attacker);
|
||||
assertTrue(PartyCombatNativeTest.hit(fixture, projectile, target.player).isCancelled());
|
||||
for (int i = 0; i < 250 && records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.count() < 2; i++) Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var reasons = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("action_kind")
|
||||
&& entry.getAsJsonObject("data").get("action_kind").getAsString().equals("party_damage_protection"))
|
||||
.map(entry -> entry.getAsJsonObject("data").get("reason").getAsString()).toList();
|
||||
assertEquals(List.of("same_party", "stale_source"), reasons);
|
||||
}
|
||||
}
|
||||
|
||||
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
return actor;
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
var directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return result;
|
||||
try (var paths = Files.walk(directory)) {
|
||||
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var content = Files.readString(path);
|
||||
int end = content.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : content.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,7 @@ class PlayerSheetDropsNativeTest {
|
||||
static final class WorldFixture {
|
||||
final UUID playerId = UUID.randomUUID();
|
||||
final World world;
|
||||
final UUID worldId = UUID.randomUUID();
|
||||
final Player player;
|
||||
final Item item;
|
||||
ItemStack stack;
|
||||
@@ -307,6 +308,8 @@ class PlayerSheetDropsNativeTest {
|
||||
default -> throw new AssertionError("Unexpected item call " + name);
|
||||
});
|
||||
world = proxy(World.class, (name, args) -> switch (name) {
|
||||
case "getUID" -> worldId;
|
||||
case "getName" -> "craft-world";
|
||||
case "createEntity" -> {
|
||||
creates++;
|
||||
assertEquals(Item.class, args[1]);
|
||||
|
||||
@@ -48,7 +48,17 @@ class RecipeDiscoveryNativeTest {
|
||||
f.plugin.setEnabled(true);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), online.recipeAwards);
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered"))
|
||||
.toList();
|
||||
assertEquals(2, discoveries.size(), "One native first discovery per account; reload/join creates no duplicate");
|
||||
assertEquals(java.util.Set.of("enable_online", "join"), discoveries.stream()
|
||||
.map(e -> e.getAsJsonObject("data").get("trigger").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(discoveries.stream().allMatch(e -> e.getAsJsonObject("data").get("recipe_id")
|
||||
.getAsString().equals("fantasystick:character_sheet")));
|
||||
assertEquals(2, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
}
|
||||
|
||||
@Test void playerFailureIsLoggedIsolatedAndRetryableButRegistrationFailureAwardsNobody() throws Exception {
|
||||
@@ -80,6 +90,13 @@ class RecipeDiscoveryNativeTest {
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), failing.knownRecipes);
|
||||
} finally { f.plugin.getLogger().removeHandler(handler); }
|
||||
}
|
||||
var first = events(data.resolve("player-failure"));
|
||||
assertEquals(1, first.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("action_kind")
|
||||
&& e.getAsJsonObject("data").get("action_kind").getAsString().equals("recipe_discovery")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_failed")).count());
|
||||
assertEquals(2, first.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count(),
|
||||
"Healthy and retried players each get one first-discovery acknowledgment");
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data.resolve("registration-failure"))) {
|
||||
var online = new FantasyStickLifecycleNativeTest.Actor(data.resolve("registration-failure"));
|
||||
f.players.put(online.core.id, online.player); f.failRegistrationAt = "recipe";
|
||||
@@ -88,6 +105,68 @@ class RecipeDiscoveryNativeTest {
|
||||
assertTrue(online.recipeAwards.isEmpty());
|
||||
assertTrue(f.recipes.isEmpty());
|
||||
}
|
||||
var registration = data.resolve("registration-failure").resolve("events");
|
||||
if (java.nio.file.Files.isDirectory(registration))
|
||||
assertEquals(0, events(data.resolve("registration-failure")).stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")).count());
|
||||
}
|
||||
|
||||
@Test void nativeDiscoveryWithoutPostconditionReportsUnavailableAndRetriesWithoutInventingSuccess() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var player = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var unavailable = new AtomicBoolean(true);
|
||||
doAnswer(call -> {
|
||||
if (unavailable.getAndSet(false)) return 0;
|
||||
var requested = ((java.util.Collection<?>) call.getArgument(0)).stream()
|
||||
.map(org.bukkit.NamespacedKey.class::cast).toList();
|
||||
player.recipeAwards.add(requested); player.knownRecipes.addAll(requested); return requested.size();
|
||||
}).when(player.player).discoverRecipes(anyCollection());
|
||||
f.players.put(player.core.id, player.player);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(player.player, "unavailable"));
|
||||
assertTrue(player.knownRecipes.isEmpty());
|
||||
f.dispatcher.accept(new PlayerJoinEvent(player.player, "retry"));
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), player.knownRecipes);
|
||||
f.plugin.setEnabled(false);
|
||||
var records = events(data);
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
|
||||
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(player.core.id.toString())).count());
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("native_unavailable")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedNativeDiscoveryRetainsStableIdentityThroughEventSinkOutage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var joining = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
f.players.put(joining.core.id, joining.player);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "join"));
|
||||
assertEquals(java.util.Set.of(CharacterSheets.RECIPE), joining.knownRecipes);
|
||||
f.plugin.setEnabled(false);
|
||||
f.plugin.setEnabled(true);
|
||||
f.dispatcher.accept(new PlayerJoinEvent(joining.player, "already known"));
|
||||
f.plugin.setEnabled(false);
|
||||
var discoveries = events(data).stream().filter(e -> e.get("type").getAsString().endsWith("recipe.discovered")
|
||||
&& e.getAsJsonObject("data").get("player_id").getAsString().equals(joining.core.id.toString())).toList();
|
||||
assertEquals(1, discoveries.stream().map(e -> e.get("id").getAsString()).distinct().count(),
|
||||
"An acknowledged first discovery must be recoverable with its original event identity");
|
||||
assertEquals("join", discoveries.getFirst().getAsJsonObject("data").get("trigger").getAsString());
|
||||
assertEquals(List.of(List.of(CharacterSheets.RECIPE)), joining.recipeAwards);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path directory) throws Exception {
|
||||
var entries = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(directory.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) entries.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static long records(Path directory) throws Exception {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Registered admin/lifecycle outcome -> local JSONL, not isolated bridge invocation. */
|
||||
class SessionRecoveryEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.stream().anyMatch(message -> message.contains("pending session=")));
|
||||
awaitType(fixture, "recovery.inspected");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entry = records().stream().filter(value -> value.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")).findFirst().orElseThrow();
|
||||
var fields = entry.getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), fields.get("target_player_id").getAsString());
|
||||
assertEquals("pending", fields.get("receipt_phase").getAsString());
|
||||
assertEquals("authorized", fields.get("outcome").getAsString());
|
||||
assertFalse(fields.toString().contains("DIAMOND_SWORD"));
|
||||
assertFalse(fields.toString().contains("item_meta"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAdventureSaveRetainsEffectivePolicyAndEmitsNoChange() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var collision = data.resolve("config.yml");
|
||||
Files.createDirectory(collision);
|
||||
Files.writeString(collision.resolve("block"), "not a config");
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.getLast().contains("save failed"));
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
|
||||
assertTrue(administrator.messages.getLast().contains("adventure-mode=true"));
|
||||
for (int i = 0; i < 250 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")); i++)
|
||||
Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString().endsWith("config.changed")));
|
||||
Files.delete(collision.resolve("block"));
|
||||
Files.delete(collision);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedAdventureChangeKeepsStableEventIdAcrossWriterOutageAndRestart() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pending;
|
||||
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
|
||||
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pending.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType(fixture, "config.changed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var saved = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.changed")).toList();
|
||||
assertEquals(pending, saved.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void explicitRecoveryWithoutReceiptIsRefusedWithoutInventingSession() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
actor.core.inventory.setItemInOffHand(null);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("refused", result.get("outcome").getAsString());
|
||||
assertEquals("no_receipt", result.get("reason").getAsString());
|
||||
assertFalse(result.has("character_id"));
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedRecoveryReceiptCompletionRemainsAmbiguousWithoutSuccessEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.receipts().find(actor.core.id).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("receipt-backup.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("ambiguous", result.get("outcome").getAsString());
|
||||
assertEquals("receipt_save_failed", result.get("reason").getAsString());
|
||||
assertEquals("restored", result.get("current_phase").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unreadableReceiptRefusesRegisteredRecoveryWithoutTouchingNativeItems() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
var nativeBefore = actor.core.snapshot();
|
||||
var receipt = data.resolve("sessions").resolve(actor.core.id + ".yml");
|
||||
var backup = data.resolve("saved-receipt.yml");
|
||||
Files.move(receipt, backup);
|
||||
Files.createDirectory(receipt);
|
||||
Files.writeString(receipt.resolve("block"), "not a receipt");
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
assertArrayEquals(nativeBefore, actor.core.snapshot());
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("failed", result.get("outcome").getAsString());
|
||||
assertEquals("receipt_read_failed", result.get("reason").getAsString());
|
||||
assertFalse(result.has("session_id"));
|
||||
Files.delete(receipt.resolve("block"));
|
||||
Files.delete(receipt);
|
||||
Files.move(backup, receipt);
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void ambiguousCopiedBoxThroughRegisteredRecoveryRetainsReceiptAndItems() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.AMBIGUOUS,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
assertNotNull(fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().pending());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("retained", result.get("outcome").getAsString());
|
||||
assertEquals("ambiguous", result.get("reason").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void uncertainNativeRecoveryReportsAmbiguousWithoutInventingEquipmentSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
actor.core.cursor = actor.core.inventory.getItem(9).clone();
|
||||
actor.core.inventory.setItem(9, null);
|
||||
actor.core.beforeCursorMutation = () -> { throw new IllegalStateException("Uncertain cursor write"); };
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
actor.core.beforeCursorMutation = () -> {};
|
||||
fixture.plugin.setEnabled(false);
|
||||
var results = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("ambiguous", results.getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
assertEquals("native_exception", results.getFirst().getAsJsonObject("data").get("reason").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedCommandRecoveryReplaysStableResultAfterClosedWriter() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
java.util.Set<String> pending;
|
||||
try (var paths = Files.list(data.resolve("session-recovery-event-intents"))) {
|
||||
pending = paths.filter(path -> path.getFileName().toString().endsWith(".pending"))
|
||||
.map(path -> path.getFileName().toString().replace(".pending", ""))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
assertEquals(1, pending.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var outcome = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(pending, outcome.stream().map(entry -> entry.get("id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(1, outcome.size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void restartedAutomaticReconciliationRetainsUnverifiedHealthAndOriginalLocationStatus() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.awaitWorkers();
|
||||
actor.core.inventory.setItem(0, null);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var results = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, results.size());
|
||||
var value = results.getFirst().getAsJsonObject("data");
|
||||
assertEquals("ambiguous", value.get("outcome").getAsString());
|
||||
assertEquals("health_unverified", value.get("reason").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertEquals("restored", value.get("current_phase").getAsString());
|
||||
assertEquals("captured_current_only", value.get("location_status").getAsString());
|
||||
assertEquals("unavailable", value.get("original_location_status").getAsString());
|
||||
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
|
||||
assertTrue(records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("equipment.restored")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredProofBasedRecoveryCorrelatesEquipmentWithoutAnotherSessionExit() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
var session = sessions.active(actor.core.id).orElseThrow().session();
|
||||
var originalAttempt = ((java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(
|
||||
sessions, "owned")).get(actor.core.id);
|
||||
actor.core.inventory.setItem(0, new org.bukkit.inventory.ItemStack(org.bukkit.Material.STONE));
|
||||
assertEquals(games.dmg.fantasystick.items.EquipmentStorage.RestoreResult.OCCUPIED_SLOT,
|
||||
sessions.exit(actor.player));
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
awaitRefusedExitReady(fixture, sessions, actor);
|
||||
actor.core.inventory.setItem(0, null);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"recover", actor.core.id.toString()});
|
||||
fixture.tick();
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
assertNotNull(FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryAction"));
|
||||
assertTrue((boolean) FantasyStickLifecycleNativeTest.Fixture.field(originalAttempt, "sessionRecoveryResolved"),
|
||||
"Completed receipt should trigger one session recovery outcome");
|
||||
awaitType(fixture, "recovery.resolved");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var recovered = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.resolved")).toList();
|
||||
assertEquals(1, recovered.size());
|
||||
var value = recovered.getFirst().getAsJsonObject("data");
|
||||
assertEquals("restored", value.get("outcome").getAsString());
|
||||
assertEquals(session.toString(), value.get("session_id").getAsString());
|
||||
assertEquals("packed", value.get("prior_phase").getAsString());
|
||||
assertTrue(value.has("equipment_recovery_operation_id"));
|
||||
assertEquals("captured_at_entry", value.get("original_location_status").getAsString());
|
||||
assertEquals("captured_at_reconciliation", value.get("current_location_status").getAsString());
|
||||
assertTrue(value.has("original_x"));
|
||||
assertTrue(value.has("current_x"));
|
||||
assertEquals(1, entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("character.exited")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAdventureReadAndAcknowledgedChangeHaveSafeRevisionAndNoInvalidChange() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode"});
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "false"});
|
||||
fixture.tick();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin", new String[]{"config", "adventure-mode", "wrong"});
|
||||
awaitType(fixture, "config.changed");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
var read = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.read") && entry.getAsJsonObject("data").has("key")
|
||||
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
|
||||
var changed = entries.stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("config.changed") && entry.getAsJsonObject("data").has("key")
|
||||
&& entry.getAsJsonObject("data").get("key").getAsString().equals("adventure-mode")).toList();
|
||||
assertEquals(1, read.size());
|
||||
assertEquals("true", read.getFirst().getAsJsonObject("data").get("effective_value").getAsString());
|
||||
assertEquals(1, changed.size(), "Invalid input must not produce an acknowledged config change");
|
||||
var fields = changed.getFirst().getAsJsonObject("data");
|
||||
assertEquals("true", fields.get("before_value").getAsString());
|
||||
assertEquals("false", fields.get("after_value").getAsString());
|
||||
assertTrue(fields.get("persistence_revision").getAsString().matches("sha256:[0-9a-f]{64}"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void inspectionRevokedBeforeReadResponseDoesNotPublishProtectedTarget() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
administrator.allowed = false;
|
||||
fixture.tick();
|
||||
assertTrue(administrator.messages.isEmpty());
|
||||
for (int i = 0; i < 200 && records().stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("action.resolved") && entry.getAsJsonObject("data").has("reason")
|
||||
&& entry.getAsJsonObject("data").get("reason").getAsString().equals("permission_changed")); i++)
|
||||
Thread.sleep(20);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.anyMatch(value -> value.has("reason") && value.get("reason").getAsString()
|
||||
.equals("permission_changed") && !value.has("target_player_id")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authorizedOfflineInspectionReportsOnlySavedReceiptAndUnknownNativeLocation() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var administrator = new EquipmentAdminCommandNativeTest.Sender();
|
||||
var target = java.util.UUID.randomUUID();
|
||||
fixture.adminCommand.execute(administrator.port, "fstickadmin",
|
||||
new String[]{"inspect", target.toString()});
|
||||
fixture.tick();
|
||||
awaitType(fixture, "recovery.inspected");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var result = records().stream().filter(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")).findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(target.toString(), result.get("target_player_id").getAsString());
|
||||
assertEquals("absent", result.get("receipt_phase").getAsString());
|
||||
assertEquals("offline_unavailable", result.get("native_phase").getAsString());
|
||||
assertEquals("receipt_only", result.get("custody_quality").getAsString());
|
||||
assertEquals("not_applicable", result.get("location_status").getAsString());
|
||||
assertFalse(result.has("session_id"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredPermissionGateCannotExposeProtectedInspectionOrTarget() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var denied = new EquipmentAdminCommandNativeTest.Sender();
|
||||
denied.allowed = false;
|
||||
fixture.adminCommand.execute(denied.port, "fstickadmin",
|
||||
new String[]{"inspect", actor.core.id.toString()});
|
||||
assertTrue(denied.messages.getLast().toLowerCase(java.util.Locale.ROOT).contains("permission"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var entries = records();
|
||||
assertTrue(entries.stream().noneMatch(entry -> entry.get("type").getAsString()
|
||||
.endsWith("recovery.inspected")));
|
||||
assertTrue(entries.stream().filter(entry -> entry.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(entry -> entry.getAsJsonObject("data"))
|
||||
.noneMatch(fields -> fields.has("action_kind") && fields.get("action_kind").getAsString()
|
||||
.equals("equipment_inspect") && fields.has("target_player_id")));
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitRefusedExitReady(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
games.dmg.fantasystick.session.EquipmentSessions sessions,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
var owned = (java.util.Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(sessions, "owned");
|
||||
var pending = owned.get(actor.core.id);
|
||||
assertNotNull(pending);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
if (!(boolean) FantasyStickLifecycleNativeTest.Fixture.field(pending, "saving")) return;
|
||||
fixture.tick();
|
||||
}
|
||||
fail("Refused exit persistence callback did not settle before recovery request");
|
||||
}
|
||||
|
||||
private void awaitType(FantasyStickLifecycleNativeTest.Fixture fixture, String type) throws Exception {
|
||||
for (int i = 0; i < 300; i++) {
|
||||
var callback = fixture.tasks.poll(20, TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
if (records().stream().anyMatch(entry -> entry.get("type").getAsString().endsWith(type))) return;
|
||||
}
|
||||
fail("Registered recovery outcome did not reach JSONL: " + type + "; observed types="
|
||||
+ records().stream().map(entry -> entry.get("type").getAsString()).toList());
|
||||
}
|
||||
|
||||
private List<JsonObject> records() throws Exception {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
Path directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return result;
|
||||
try (var files = Files.walk(directory)) {
|
||||
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package games.dmg.fantasystick;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import games.dmg.fantasystick.items.CharacterSheets;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SwitchDelayEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void persistedExitAndPrepackSameOrDifferentCharacterDecisionsReachJsonl() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
actor.core.location.add(3, 0, 1);
|
||||
double exitX = actor.core.location.getX();
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
actor.core.location.add(8, 0, 0);
|
||||
double attemptX = actor.core.location.getX();
|
||||
var saved = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow();
|
||||
var exit = saved.lastExit();
|
||||
assertNotNull(exit);
|
||||
var another = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
actor.core.inventory.setItemInOffHand(another.core.inventory.getItemInOffHand().clone());
|
||||
var before = actor.core.snapshot();
|
||||
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
|
||||
fixture.tick(); fixture.tick();
|
||||
assertArrayEquals(before, actor.core.snapshot(), "A refused switch cannot alter equipment");
|
||||
assertTrue(actor.messages.getLast().contains("same character may be resumed now"));
|
||||
actor.core.inventory.setItemInOffHand(new CharacterSheets().create(actor.core.record));
|
||||
fixture.dispatcher.accept(new PlayerJoinEvent(actor.player, (net.kyori.adventure.text.Component) null));
|
||||
fixture.tick(); fixture.tick(); fixture.tick();
|
||||
assertTrue(actor.core.marker().endsWith("|PACKED"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var events = records();
|
||||
var started = events.stream().filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), started.get("player_id").getAsString());
|
||||
assertEquals(exit.characterId().toString(), started.get("character_id").getAsString());
|
||||
assertEquals(exit.deadline().toString(), started.get("deadline").getAsString());
|
||||
assertEquals("900", started.get("duration_seconds").getAsString());
|
||||
assertEquals(saved.revision().toString(), started.get("revision_after").getAsString());
|
||||
assertEquals(exitX, started.getAsJsonObject("location").get("x").getAsDouble());
|
||||
assertEquals(exit.exitedAt().toString(), events.stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("character.switch_delay_started"))
|
||||
.findFirst().orElseThrow().get("time").getAsString());
|
||||
var decisions = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
|
||||
.toList();
|
||||
assertEquals(3, decisions.size(), "Initial entry, refused switch and same-character resumption are distinct preflights");
|
||||
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
|
||||
&& d.get("last_exited_character_id").getAsString().equals("none")));
|
||||
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("refused")
|
||||
&& d.get("reason").getAsString().equals("cooldown")
|
||||
&& d.getAsJsonObject("location").get("x").getAsDouble() == attemptX));
|
||||
var observations = events.stream().filter(e -> e.get("type").getAsString().endsWith("action.observed"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("action_kind") && d.get("action_kind").getAsString().equals("character_switch_preflight"))
|
||||
.toList();
|
||||
assertEquals(decisions.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
|
||||
observations.stream().map(d -> d.get("action_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(decisions.stream().anyMatch(d -> d.get("outcome").getAsString().equals("allowed")
|
||||
&& d.get("same_character").getAsBoolean()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedExitRecoversSameDeadlineEventAfterWriterOutage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
var exit = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && records().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("character.switch_delay_started")); i++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var recovered = records().stream().filter(e -> e.get("type").getAsString()
|
||||
.endsWith("character.switch_delay_started")).toList();
|
||||
assertEquals(1, recovered.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
assertEquals(exit.deadline().toString(), recovered.getFirst().getAsJsonObject("data")
|
||||
.get("deadline").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), recovered.getFirst().getAsJsonObject("data")
|
||||
.get("character_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void twoPersistedExitsRemainDistinctDuringWriterOutageAndRestart() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.activate(actor);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
var first = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
fixture.reenter(actor);
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
var second = fixture.receipts().find(actor.core.id).toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS).orElseThrow().lastExit();
|
||||
assertNotEquals(first.sessionId(), second.sessionId());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && records().stream().filter(e -> e.get("type").getAsString()
|
||||
.endsWith("character.switch_delay_started")).map(e -> e.get("id").getAsString()).distinct().count() < 2; i++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var starts = records().stream().filter(e -> e.get("type").getAsString()
|
||||
.endsWith("character.switch_delay_started"))
|
||||
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
|
||||
(one, duplicate) -> one, java.util.LinkedHashMap::new));
|
||||
assertEquals(2, starts.size());
|
||||
assertEquals(java.util.Set.of(first.sessionId().toString(), second.sessionId().toString()),
|
||||
starts.values().stream().map(e -> e.getAsJsonObject("data").get("session_id").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
private List<com.google.gson.JsonObject> records() throws Exception {
|
||||
var events = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : Files.readAllLines(path))
|
||||
if (!line.isBlank()) events.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user