Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7c75b6ed | ||
|
|
39881ad6fc | ||
|
|
9737ed0ddb | ||
|
|
53366b3c50 | ||
|
|
84205c0c14 | ||
|
|
2cc053e6ff | ||
|
|
3f104cb0e2 | ||
|
|
13828054e5 | ||
|
|
39c58ef4d9 | ||
|
|
ec73018f93 | ||
|
|
b86f703d14 | ||
|
|
2231dd058a | ||
|
|
ff28f2e98d | ||
|
|
21877f6820 | ||
|
|
228de3d08f | ||
|
|
e3bc854481 | ||
|
|
3163a491a0 |
No files matched your search
@@ -163,6 +163,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.events.LocalEventLog eventLog;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
|
||||
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
|
||||
@@ -176,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);
|
||||
|
||||
@@ -712,7 +721,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
|
||||
particlesCommand.attachEvents(preferenceEvents);
|
||||
feedbackCommand.attachEvents(preferenceEvents);
|
||||
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
|
||||
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
|
||||
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
|
||||
@@ -722,14 +740,44 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("fireball");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
abilityEngine.enableCastEventsFor("rallying_cry");
|
||||
abilityEngine.enableCastEventsFor("frost_nova");
|
||||
abilityEngine.enableCastEventsFor("chain_lightning");
|
||||
abilityEngine.enableCastEventsFor("sanctuary");
|
||||
abilityEngine.enableCastEventsFor("war_banner");
|
||||
abilityEngine.enableCastEventsFor("meteor");
|
||||
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
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);
|
||||
@@ -739,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);
|
||||
@@ -814,12 +862,48 @@ 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);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
if(eventLog!=null)eventLog.ready();
|
||||
if (eventLog != null) {
|
||||
var preferenceReplay = preferenceEvents;
|
||||
var savedPreferences = feedbackPreferences;
|
||||
eventLog.ready().whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
|
||||
&& preferenceReplay != null && savedPreferences != null)
|
||||
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
|
||||
});
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -841,7 +925,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particleDelivery = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = 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;
|
||||
@@ -1145,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);
|
||||
@@ -1190,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", () -> {
|
||||
|
||||
@@ -432,6 +432,19 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
|
||||
}
|
||||
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
|
||||
double incoming, double actual, double remaining,
|
||||
UUID bondApplication) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
|
||||
incoming, actual, remaining, bondApplication);
|
||||
}
|
||||
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
|
||||
Entity target, double base, double bonus, double finalDamage,
|
||||
java.math.BigDecimal percent) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason);
|
||||
var effectId = observedEffects.remove(application);
|
||||
|
||||
@@ -36,6 +36,14 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
/** Record only harmful native effects actually removed from this target. */
|
||||
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player target, List<String> removedEffects);
|
||||
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
|
||||
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player ally, Entity attacker, double incoming,
|
||||
double actual, double remaining, UUID bondApplication);
|
||||
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
|
||||
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
|
||||
org.bukkit.entity.Player attacker, Entity target,
|
||||
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
|
||||
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
|
||||
void effectEnded(UUID applicationId, String reason);
|
||||
/** An owner may specify a truthful terminal outcome before finishing. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -48,8 +49,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested;
|
||||
final BigDecimal defined; final UUID chainId; final int ordinal;
|
||||
final UUID previousId; final Map<String, Object> previousLocation;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
|
||||
UUID chainId, int ordinal, LivingEntity previous) {
|
||||
this.execution = execution; this.target = target; this.requested = requested;
|
||||
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
|
||||
previousId = previous == null ? null : previous.getUniqueId();
|
||||
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -103,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, current);
|
||||
if (hop + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
previous = current;
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>();
|
||||
outcome.put("chain_id", chainId.toString());
|
||||
outcome.put("initial_target_id", initial.getUniqueId().toString());
|
||||
outcome.put("initial_target_location", locationData(initial.getLocation()));
|
||||
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
|
||||
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
|
||||
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
|
||||
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
|
||||
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
@@ -179,13 +203,42 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_kind", "owned_chain_strike");
|
||||
evidence.put("chain_id", attempt.chainId.toString());
|
||||
evidence.put("hop_ordinal", attempt.ordinal);
|
||||
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
|
||||
if (attempt.previousId != null) {
|
||||
evidence.put("previous_target_id", attempt.previousId.toString());
|
||||
evidence.put("previous_target_location", attempt.previousLocation);
|
||||
}
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), evidence);
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** Exclude only this in-flight native hop from the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
|
||||
private void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
|
||||
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
|
||||
public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private final class Grant implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
|
||||
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
this.acceptedObservation = acceptedObservation;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
@@ -42,10 +45,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
return grant(execution, player, percent, current, null);
|
||||
}
|
||||
|
||||
/** An owner may observe only the selected, positive native prevention after final cancellation. */
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
var grant = new Grant(execution, player, percent, current, acceptedObservation);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -76,6 +85,9 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
if (prevention.grant.acceptedObservation != null) try {
|
||||
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
|
||||
@@ -50,36 +50,61 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final AbilityExecution execution; UUID application;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
boolean apply() {
|
||||
owned = target.addPotionEffect(applied);
|
||||
if (owned) try {
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("amplifier", applied.getAmplifier());
|
||||
evidence.put("duration_ticks", applied.getDuration());
|
||||
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
|
||||
if (previous != null) {
|
||||
evidence.put("replaced_amplifier", previous.getAmplifier());
|
||||
evidence.put("replaced_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_nova_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
|
||||
return owned;
|
||||
}
|
||||
@Override public void close() { close("interrupted"); }
|
||||
void close(String reason) {
|
||||
if (closed) return; closed = true; if (!owned) return;
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
|| current.getDuration() > expected + 2) {
|
||||
if (application != null) execution.effectEnded(application, "superseded");
|
||||
return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
if (application != null) execution.effectEnded(application, reason);
|
||||
}
|
||||
}
|
||||
private final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
|
||||
String endReason = "interrupted"; boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
|
||||
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -133,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
int affected = 0, accepted = 0;
|
||||
var candidates = candidates(source, origin, captured.radius());
|
||||
for (var target : candidates) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
|
||||
try { execution.damage(target, amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
if (!attempt.accepted) continue;
|
||||
accepted++;
|
||||
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
captured.slownessAmplifier(), false, true, true), execution);
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { particles.world(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
|
||||
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
|
||||
result.put("radius", captured.radius()); result.put("requested_amount", amount);
|
||||
result.put("burst_location", locationData(origin));
|
||||
execution.resolve(accepted > 0 ? "hit" : "miss", result);
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
else execution.later(captured.slownessDuration(), () -> {
|
||||
group.endReason = "expired"; execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
@@ -188,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** The per-target native hit belongs to this cast, not the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location origin) {
|
||||
var world = origin.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
|
||||
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
UUID application;
|
||||
String endReason = "interrupted";
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
|
||||
this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final boolean nativeDamageMode;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
|
||||
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.nativeDamageMode = damage == null;
|
||||
this.damage = nativeDamageMode ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
execution.own(state);
|
||||
state.application = execution.effectApplied("guardian_bond", ally,
|
||||
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
|
||||
"range", Double.toString(captured.range()),
|
||||
"duration_seconds", seconds(captured.duration())));
|
||||
execution.later(captured.duration(), () -> {
|
||||
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
|
||||
if (!nativeDamageMode) {
|
||||
// Injected test adapters return their final accepted result directly.
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an uncancelled original hit can claim actual prevention. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedPrevention(EntityDamageByEntityEvent event) {
|
||||
var reduction = pendingReductions.remove(event);
|
||||
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
|
||||
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
|
||||
if (actual <= 0) return;
|
||||
var state = reduction.state();
|
||||
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
|
||||
reduction.incoming(), actual, event.getFinalDamage(), state.application);
|
||||
if (nativeDamageMode) {
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
|
||||
supports.accept(new Support(reduction.attacker().getUniqueId(),
|
||||
state.execution.input().session().character(), state.aided, amount));
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
|
||||
requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
if (transfer.owner != null && transfer.accepted.signum() > 0)
|
||||
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
|
||||
transfer.accepted.doubleValue(), transfer.attacker,
|
||||
Map.of("damage_kind", "guardian_bond_redirection"));
|
||||
}
|
||||
|
||||
/** The guardian's accepted native hit has one richer bond-owned damage record. */
|
||||
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
return transfer != null && event instanceof EntityDamageByEntityEvent hit
|
||||
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
state.endReason = "eligibility_changed";
|
||||
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
|
||||
}
|
||||
}
|
||||
@@ -39,14 +39,54 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
Duration.ofSeconds(2),5,4);}
|
||||
}
|
||||
private record Ground(Block block,Location origin){ }
|
||||
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
|
||||
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
|
||||
}
|
||||
}
|
||||
private final class Warning implements AutoCloseable {
|
||||
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
|
||||
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
|
||||
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
|
||||
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
|
||||
void step(){
|
||||
if(closed||impacted)return;
|
||||
if(!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
|
||||
execution.finish();return;
|
||||
}
|
||||
elapsed++;
|
||||
if(elapsed>=maximumTicks){impact();execution.finish();return;}
|
||||
warningVisual();execution.later(Duration.ofMillis(50),this::step);
|
||||
}
|
||||
void impact(){
|
||||
if(closed||impacted)return;impacted=true;
|
||||
if(!groundCurrent()||!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
|
||||
return;
|
||||
}
|
||||
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
|
||||
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
|
||||
for(var target:candidates){
|
||||
if(attempted==64||!execution.stillCurrent())break;
|
||||
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
|
||||
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
|
||||
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
|
||||
try{execution.damage(target,amount,sourceDamage);}
|
||||
finally{if(damaging==attempt)damaging=null;}
|
||||
attempted++;if(attempt.accepted)accepted++;
|
||||
}
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
|
||||
evidence.put("impact_location",locationData(ground.origin()));
|
||||
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
|
||||
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
|
||||
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
|
||||
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
|
||||
}
|
||||
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
|
||||
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
|
||||
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
|
||||
@@ -66,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
|
||||
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
|
||||
@Override public void activate(AbilityExecution execution){
|
||||
if(closed||!execution.stillCurrent())return;
|
||||
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
|
||||
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
|
||||
new Warning(execution,target,captured).start();
|
||||
}
|
||||
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
|
||||
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
|
||||
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void acceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
|
||||
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
|
||||
attempt.accepted=true;
|
||||
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
|
||||
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
|
||||
var caster=attempt.execution.input().player();
|
||||
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
|
||||
return (causing==null||causing==caster)&&(direct==null||direct==caster);
|
||||
}
|
||||
private static Map<String,Object> locationData(Location location){
|
||||
var world=location==null?null:location.getWorld();
|
||||
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
|
||||
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
|
||||
"x",location.getX(),"y",location.getY(),"z",location.getZ());
|
||||
}
|
||||
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
|
||||
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
|
||||
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
|
||||
if (!Double.isFinite(effective) || effective <= 0) return;
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
|
||||
used.base, effective, event.getFinalDamage(), used.grant.percent);
|
||||
var actual = BigDecimal.valueOf(effective);
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
|
||||
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
|
||||
final UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("speed_amplifier", area.settings.speedAmplifier());
|
||||
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
|
||||
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
execution.own(area);
|
||||
area.application = execution.effectApplied("rallying_cry_area", null,
|
||||
Map.of("radius", Double.toString(area.settings.radius()),
|
||||
"duration_seconds", seconds(area.settings.duration())));
|
||||
area.refresh(); execution.later(area.settings.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private void breakArea(Area area) {
|
||||
area.endReason = "eligibility_changed"; area.close();
|
||||
area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
|
||||
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var area : List.copyOf(areas.values())) {
|
||||
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
|
||||
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
UUID areaApplication; int pulseOrdinal;
|
||||
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void start() {
|
||||
states.add(this); execution.own(this);
|
||||
areaApplication = execution.effectApplied("sanctuary_area", null,
|
||||
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
|
||||
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
|
||||
reconcile(); visual(); schedule();
|
||||
}
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
expired = true; endReason = "expired"; closeMitigation();
|
||||
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
|
||||
if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
entry.getValue().close(); mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
|
||||
}
|
||||
void pulse() {
|
||||
pulseOrdinal++;
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
@@ -141,11 +150,46 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
for (var grant : List.copyOf(mitigation.values())) grant.close();
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final State state; final Player player; final AutoCloseable grant;
|
||||
UUID application; boolean closed;
|
||||
Benefit(State state, Player player) {
|
||||
this.state = state; this.player = player;
|
||||
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
|
||||
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var attacker = event.getDamageSource().getCausingEntity();
|
||||
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
attacker = byEntity.getDamager();
|
||||
if (active != null && attacker != null && amount > 0)
|
||||
state.execution.acceptedPrevention(active, player, attacker,
|
||||
event.getDamage() + amount, amount, event.getFinalDamage(), application);
|
||||
});
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
try { grant.close(); } catch (Exception ignored) { }
|
||||
if (application != null) state.execution.effectEnded(application,
|
||||
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
double after = target.player().getHealth();
|
||||
double actual = after - current;
|
||||
if (!Double.isFinite(actual) || actual <= 0) return;
|
||||
state.execution.acceptedHeal(target.active(), target.player(), persisted,
|
||||
accepted, actual, current, after, maximum);
|
||||
state.execution.report(AbilityReport.Type.HEALING, target.player(),
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
BigDecimal.valueOf(actual).stripTrailingZeros());
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
@@ -240,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
|
||||
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
|
||||
boolean closed;
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Location origin, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { execution.finish(); return; }
|
||||
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
|
||||
@@ -104,20 +104,36 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
|
||||
UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
BooleanSupplier current = () -> !closed && area.eligible(player);
|
||||
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
|
||||
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
|
||||
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
|
||||
evidence.put("area_location", locationData(area.origin));
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(resistance);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
|
||||
var origin = placement(execution.input().player(), captured.placementRange());
|
||||
if (origin == null) { execution.finish(); return; }
|
||||
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
|
||||
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
|
||||
area.refresh(); execution.later(captured.duration(), execution::finish);
|
||||
area.application = execution.effectApplied("war_banner_area", null,
|
||||
Map.of("area_location", locationData(origin), "radius", captured.radius(),
|
||||
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
|
||||
area.refresh(); execution.later(captured.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
@@ -231,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
|
||||
} catch(RuntimeException invalid){return false;}
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
|
||||
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -87,7 +87,7 @@ public final class AbilityCastEventBridge {
|
||||
data.put("application_id", application.toString());
|
||||
if (target != null) {
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", target.getType().name());
|
||||
data.put("target_type", entityType(target));
|
||||
var place = target.getLocation();
|
||||
if (place != null) data.put("target_location", locationData(place));
|
||||
}
|
||||
@@ -108,12 +108,12 @@ public final class AbilityCastEventBridge {
|
||||
data.put("requested_amount", requested);
|
||||
data.put("actual_amount", actual);
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", target.getType().name());
|
||||
data.put("target_type", entityType(target));
|
||||
var targetAtApplication = target.getLocation();
|
||||
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
|
||||
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
|
||||
data.put("direct_entity_id", direct.getUniqueId().toString());
|
||||
data.put("direct_entity_type", direct.getType().name());
|
||||
data.put("direct_entity_type", entityType(direct));
|
||||
emit(cast, "combat.damage", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
|
||||
}
|
||||
@@ -165,6 +165,63 @@ public final class AbilityCastEventBridge {
|
||||
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
|
||||
}
|
||||
|
||||
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
|
||||
UUID aidedSession, Entity attacker, double incoming, double actual,
|
||||
double remaining, UUID bondApplication) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
|
||||
data.put("target_player_id", ally.getUniqueId().toString());
|
||||
data.put("target_character_id", aidedCharacter.toString());
|
||||
data.put("target_session_id", aidedSession.toString());
|
||||
data.put("target_location", locationData(ally.getLocation()));
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("actual_amount", decimal(actual));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
|
||||
}
|
||||
|
||||
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
|
||||
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
|
||||
double finalDamage, java.math.BigDecimal percent) {
|
||||
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|
||||
|| !Double.isFinite(bonus) || bonus <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("attacker_player_id", attacker.getUniqueId().toString());
|
||||
if (attacking != null) {
|
||||
data.put("attacker_character_id", attacking.character().toString());
|
||||
data.put("attacker_session_id", attacking.session().toString());
|
||||
}
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", entityType(target));
|
||||
var location = target.getLocation();
|
||||
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
|
||||
data.put("base_amount", decimal(base));
|
||||
data.put("actual_amount", decimal(bonus));
|
||||
data.put("final_amount", decimal(finalDamage));
|
||||
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
|
||||
data.put("selection", "strongest_applicable_only");
|
||||
emit(cast, "combat.boosted", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
|
||||
}
|
||||
|
||||
private static String entityType(Entity entity) {
|
||||
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
|
||||
var type = entity.getType();
|
||||
return type == null ? "unavailable" : type.name();
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
|
||||
if (cast == null) return;
|
||||
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
|
||||
|
||||
@@ -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,198 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
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.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured account preferences remain independent of gameplay and feedback delivery. */
|
||||
public final class PreferenceEventBridge {
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, String key,
|
||||
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public Action begin(Player player, String key, boolean before, Boolean requested) {
|
||||
Objects.requireNonNull(player);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
var where = player.getLocation();
|
||||
EventInput.Location location = where == null || where.getWorld() == null ? null
|
||||
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
|
||||
where.getY(), where.getZ(), clock.instant(), "exact");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : active.session(), location), key, before,
|
||||
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
|
||||
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
|
||||
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
|
||||
emit("action.observed", action, "observed", null, null, before, false);
|
||||
return action;
|
||||
}
|
||||
|
||||
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
|
||||
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
|
||||
boolean initial = !key.equals("player-aura-particles");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
|
||||
emit("action.observed", action, "observed", null, null, true, false);
|
||||
emit("action.resolved", action, "refused", "player_required", null, true, false);
|
||||
}
|
||||
|
||||
/** Written with the preference value in the same acknowledged file revision. */
|
||||
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
|
||||
var fields = new LinkedHashMap<String,String>();
|
||||
fields.put("time", action.time().toString());
|
||||
fields.put("account", action.context().playerId().toString());
|
||||
fields.put("account_name", action.context().playerName());
|
||||
fields.put("key", action.key());
|
||||
fields.put("before", Boolean.toString(action.before()));
|
||||
fields.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
fields.put("requested", Boolean.toString(action.requested()));
|
||||
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
|
||||
if (action.context().characterId() != null) {
|
||||
fields.put("character", action.context().characterId().toString());
|
||||
fields.put("character_name", action.context().characterName());
|
||||
fields.put("session", action.context().sessionId().toString());
|
||||
}
|
||||
var location = action.context().location();
|
||||
if (location != null) {
|
||||
fields.put("world", location.worldId().toString());
|
||||
fields.put("world_name", location.worldName());
|
||||
fields.put("x", Double.toString(location.x()));
|
||||
fields.put("y", Double.toString(location.y()));
|
||||
fields.put("z", Double.toString(location.z()));
|
||||
fields.put("location_time", location.capturedAt().toString());
|
||||
}
|
||||
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
|
||||
}
|
||||
|
||||
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
|
||||
try {
|
||||
var row = intent.captured();
|
||||
UUID account = UUID.fromString(row.get("account"));
|
||||
if (!"true".equals(row.get("published"))
|
||||
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
|
||||
== Boolean.parseBoolean(row.get("requested"))) return;
|
||||
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
|
||||
EventInput.Location location = row.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
|
||||
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
|
||||
Instant.parse(row.get("location_time")), "exact") : null;
|
||||
var context = new EventInput.Context(account, row.get("account_name"), character,
|
||||
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
|
||||
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
|
||||
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
|
||||
row.getOrDefault("saved_before", row.get("before"))),
|
||||
Boolean.parseBoolean(row.get("requested")), "player");
|
||||
boolean after = Boolean.parseBoolean(row.get("requested"));
|
||||
boolean effective = row.get("key").equals("player-aura-particles")
|
||||
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
|
||||
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
|
||||
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
|
||||
}
|
||||
|
||||
public void read(Action action) {
|
||||
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
|
||||
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
|
||||
}
|
||||
|
||||
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
|
||||
if (!published) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
if (update.intent() == null || action.savedBefore() == action.requested()) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
}
|
||||
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
|
||||
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
|
||||
}
|
||||
|
||||
public void failed(Action action, String reason) {
|
||||
emit("action.resolved", action, "failed", reason, null, action.before(), false);
|
||||
}
|
||||
public void refused(Action action, String reason) {
|
||||
emit("action.resolved", action, "refused", reason, null, action.before(), false);
|
||||
}
|
||||
|
||||
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
|
||||
boolean effective, boolean acknowledgedIntent) {
|
||||
if (action == null) return;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
|
||||
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
|
||||
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
|
||||
data.put("preference_key", action.key());
|
||||
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
|
||||
data.put("scope", "account");
|
||||
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
|
||||
data.put("before", Boolean.toString(action.before()));
|
||||
data.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
data.put("effective_before", Boolean.toString(action.before()));
|
||||
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
|
||||
if (action.context() != null) {
|
||||
boolean latestSaved = type.equals("preference.changed") ? action.requested()
|
||||
: preferenceSequence == null ? action.savedBefore()
|
||||
: action.key().equals("player-aura-particles")
|
||||
? preferences.savedPlayerAurasEnabled(action.context().playerId())
|
||||
: action.key().equals("combat_feedback")
|
||||
? preferences.savedEnabled(action.context().playerId())
|
||||
: preferences.savedAllParticlesEnabled(action.context().playerId());
|
||||
data.put("saved_value", Boolean.toString(latestSaved));
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
data.put("effective_after", Boolean.toString(effective));
|
||||
}
|
||||
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
|
||||
data.put("outcome", outcome);
|
||||
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
|
||||
if (type.equals("preference.changed")) {
|
||||
data.put("after", Boolean.toString(action.requested()));
|
||||
data.put("preference_revision", Long.toString(preferenceSequence));
|
||||
}
|
||||
try {
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
|
||||
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
|
||||
: clock.instant(), type, action.context(), Map.copyOf(data));
|
||||
var result = sink.apply(input);
|
||||
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
|
||||
preferences.acknowledgeEvent(action.id());
|
||||
});
|
||||
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
|
||||
}
|
||||
}
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public ChainLightning.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -71,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public FrostNova.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -70,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -72,10 +72,11 @@ public final class MeteorSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Meteor.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
|
||||
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
|
||||
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
|
||||
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
this(account, preference, enabled, sequence, null);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-player-feedback-preferences"));
|
||||
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
|
||||
private long persistedSequence;
|
||||
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public PlayerFeedbackPreferences(Path directory) throws IOException {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
effective.putAll(loadEnabled());
|
||||
var loaded = loadEnabled();
|
||||
effective.putAll(loaded);
|
||||
saved.putAll(loaded);
|
||||
var root = readRoot();
|
||||
var persisted = root.get("preference-sequence");
|
||||
if (persisted != null) {
|
||||
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
|
||||
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
|
||||
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
|
||||
}
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
for (var entry : pending.entrySet()) {
|
||||
UUID id;
|
||||
try { id = UUID.fromString(entry.getKey()); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
|
||||
var row = stringMap(entry.getValue(), "pending preference event");
|
||||
var captured = new LinkedHashMap<String, String>();
|
||||
for (var field : row.entrySet()) {
|
||||
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
|
||||
captured.put(field.getKey(), text);
|
||||
}
|
||||
long sequence;
|
||||
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
|
||||
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
|
||||
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
|
||||
pendingEvents.put(id, new EventIntent(id, captured, sequence));
|
||||
persistedSequence = Math.max(persistedSequence, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing routine combat/effect feedback preference. */
|
||||
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
|
||||
public synchronized boolean savedEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
|
||||
Preference.COMBAT_FEEDBACK.defaultValue);
|
||||
}
|
||||
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
|
||||
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
|
||||
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
|
||||
Preference.PLAYER_AURAS.defaultValue);
|
||||
}
|
||||
public synchronized boolean savedAllParticlesEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
|
||||
Preference.ALL_PARTICLES.defaultValue);
|
||||
}
|
||||
public synchronized boolean auraParticlesEnabled(UUID account) {
|
||||
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
|
||||
}
|
||||
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled, intent);
|
||||
}
|
||||
|
||||
/** Shared persisted precedence for the separately delivered all-particles command. */
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled, intent);
|
||||
}
|
||||
|
||||
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
|
||||
|
||||
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
|
||||
public CompletionStage<Void> markPublished(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
var root = readRoot();
|
||||
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var found = stringMap(all.get(action.toString()), "pending preference event");
|
||||
if (found.isEmpty()) return; // Already accepted by the event writer.
|
||||
found.put("published", "true");
|
||||
all.put(action.toString(), found);
|
||||
root.put("pending-preference-events", all);
|
||||
writeRoot(root);
|
||||
synchronized (this) {
|
||||
var previous = pendingEvents.get(action);
|
||||
if (previous != null) {
|
||||
var snapshot = new LinkedHashMap<>(previous.captured());
|
||||
snapshot.put("published", "true");
|
||||
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
|
||||
}
|
||||
}
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(rejected);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletionStage<Void> acknowledgeEvent(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
Map<String,Object> root = readRoot();
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
if (pending.remove(action.toString()) == null) return;
|
||||
root.put("pending-preference-events", pending);
|
||||
writeRoot(root);
|
||||
synchronized (this) { pendingEvents.remove(action); }
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
return set(account, preference, enabled, null);
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
|
||||
Objects.requireNonNull(account); Objects.requireNonNull(preference);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
|
||||
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(account, preference, enabled);
|
||||
return new Update(account, preference, enabled, ++persistedSequence);
|
||||
long sequence = ++persistedSequence;
|
||||
var accepted = save(account, preference, enabled, intent, sequence);
|
||||
return new Update(account, preference, enabled, sequence, accepted);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Player preference save refused or failed; effective setting unchanged", failure));
|
||||
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
|
||||
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
|
||||
long sequence) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
|
||||
Path lockFile = directory.resolve(".player-preferences.lock");
|
||||
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
Map<String, Object> root = readRoot();
|
||||
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
|
||||
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
|
||||
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
|
||||
values.put(preference.key, enabled);
|
||||
players.put(account.toString(), values);
|
||||
root.put(PLAYERS, players);
|
||||
root.put("preference-sequence", Long.toString(sequence));
|
||||
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
|
||||
if (accepted != null) {
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var captured = new LinkedHashMap<String,Object>(accepted.captured());
|
||||
captured.put("preference_sequence", Long.toString(sequence));
|
||||
pending.put(accepted.actionId().toString(), captured);
|
||||
root.put("pending-preference-events", pending);
|
||||
}
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
|
||||
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
synchronized (this) {
|
||||
saved.put(new AccountPreference(account, preference), enabled);
|
||||
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
|
||||
}
|
||||
return accepted;
|
||||
} catch (OverlappingFileLockException busy) {
|
||||
throw new IOException("Player preferences busy", busy);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeRoot(Map<String,Object> root) throws IOException {
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private Map<String, Object> readRoot() throws IOException {
|
||||
Path file = directory.resolve(FILE);
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
|
||||
|
||||
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public RallyingCry.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -73,10 +73,11 @@ public final class SanctuarySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Sanctuary.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -75,10 +75,11 @@ public final class WarBannerSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public WarBanner.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -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"));
|
||||
@@ -1327,8 +1366,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void guardianBondConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = guardianBondSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "guardian_bond", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Guardian Bond casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
|
||||
try { operation = guardianBondSettings.set(key, args[2]); }
|
||||
@@ -1340,8 +1382,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
|
||||
if (failure == null && guardianBondSettings.publish(saved)) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "guardian_bond", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1354,8 +1397,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = rallyingCrySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
|
||||
try { operation = rallyingCrySettings.set(key, args[2]); }
|
||||
@@ -1367,8 +1413,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
|
||||
if (failure == null && rallyingCrySettings.publish(saved)) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1381,8 +1428,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void frostNovaConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostNovaSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_nova", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Nova casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
|
||||
try { operation = frostNovaSettings.set(key, args[2]); }
|
||||
@@ -1394,12 +1444,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
|
||||
boolean published = failure == null && frostNovaSettings.publish(saved);
|
||||
if (published) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_nova", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
|
||||
: "Frost Nova configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1408,8 +1460,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void chainLightningConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = chainLightningSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "chain_lightning", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Chain Lightning casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
|
||||
try { operation = chainLightningSettings.set(key, args[2]); }
|
||||
@@ -1421,12 +1476,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
|
||||
boolean published = failure == null && chainLightningSettings.publish(saved);
|
||||
if (published) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "chain_lightning", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
|
||||
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1435,8 +1492,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = sanctuarySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "sanctuary", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Sanctuary casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
|
||||
try { operation = sanctuarySettings.set(key, args[2]); }
|
||||
@@ -1448,12 +1508,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
|
||||
boolean published = failure == null && sanctuarySettings.publish(saved);
|
||||
if (published) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "sanctuary", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
|
||||
: "Sanctuary configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1462,7 +1524,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void warBannerConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = warBannerSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "war_banner", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future War Banner casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
|
||||
try { operation = warBannerSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
|
||||
@@ -1470,9 +1537,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
|
||||
boolean published = failure == null && warBannerSettings.publish(saved);
|
||||
if (published) {
|
||||
warBanner.configure(saved.settings()); abilities.replace(warBanner.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "war_banner", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
|
||||
: "War Banner configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1480,10 +1551,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void meteorConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = meteorSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "meteor", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Meteor casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
|
||||
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor setting is invalid; the effective value is unchanged.");return;}
|
||||
operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
try { operation = meteorSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Meteor setting is invalid; the effective value is unchanged."); return;
|
||||
}
|
||||
operation.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(null, "config-" + key, failure);
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
boolean published = failure == null && meteorSettings.publish(saved);
|
||||
if (published) {
|
||||
meteor.configure(saved.settings()); abilities.replace(meteor.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "meteor", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Meteor casts; active warnings and deadlines are unchanged."
|
||||
: "Meteor configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
@@ -1629,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();
|
||||
|
||||
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
|
||||
this.events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
|
||||
catch (RuntimeException ignored) { }
|
||||
sender.sendMessage("Fantasy feedback is a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
var action = observe(player, null);
|
||||
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
|
||||
var action = observe(player, null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
return false;
|
||||
}
|
||||
boolean enabled = args[0].equals("on");
|
||||
var account = player.getUniqueId();
|
||||
preferences.set(account, enabled).whenComplete((saved, failure) -> {
|
||||
var action = observe(player, enabled);
|
||||
var save = action == null ? preferences.set(account, enabled)
|
||||
: preferences.set(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
"Fantasy feedback preference could not be saved; it remains "
|
||||
+ state(preferences.enabled(account)) + ".");
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
|
||||
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
|
||||
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
|
||||
}
|
||||
|
||||
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
@Override public void close() { closed = true; }
|
||||
|
||||
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
|
||||
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
sender.sendMessage("Fantasy particles are a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
player.sendMessage(status(player.getUniqueId(), args[0]));
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
String route = args[0];
|
||||
boolean enabled = args[1].equals("on");
|
||||
UUID account = player.getUniqueId();
|
||||
var action = observe(player, route, enabled);
|
||||
var save = route.equals("players")
|
||||
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
|
||||
? action == null ? preferences.setPlayerAuras(account, enabled)
|
||||
: preferences.setPlayerAuras(account, enabled, events.intent(action))
|
||||
: action == null ? preferences.setAllParticles(account, enabled)
|
||||
: preferences.setAllParticles(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
(route.equals("players") ? "Active-character aura" : "All-particles")
|
||||
+ " preference could not be saved; " + status(account, route));
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published,
|
||||
route.equals("players") ? preferences.auraParticlesEnabled(account)
|
||||
: preferences.allParticlesEnabled(account));
|
||||
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
|
||||
Player player, String route, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, key(route), route.equals("players")
|
||||
? preferences.auraParticlesEnabled(player.getUniqueId())
|
||||
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
|
||||
}
|
||||
|
||||
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
|
||||
|
||||
private String status(UUID account, String route) {
|
||||
if (route.equals("all"))
|
||||
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
|
||||
|
||||
@@ -67,6 +67,69 @@ class ChainLightningNativeTest {
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
|
||||
assertEquals(1, abilityEvents(records, type).size(), type);
|
||||
var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(2, hits.size(), "Exactly one positive accepted record per hop");
|
||||
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic native observer must not repeat feature-owned chain hits");
|
||||
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
|
||||
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
|
||||
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("chain_lightning")).toList();
|
||||
}
|
||||
|
||||
@Test void cancelledMiddleHopContinuesWithoutClaimingItsNativeDamage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
var first = mock(LivingEntity.class); var cancelled = mock(LivingEntity.class); var third = mock(LivingEntity.class);
|
||||
locate(first, caster.world, UUID.randomUUID(), 10);
|
||||
locate(cancelled, caster.world, UUID.randomUUID(), 14);
|
||||
locate(third, caster.world, UUID.randomUUID(), 18);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(true).when(caster.player).hasLineOfSight(first);
|
||||
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
|
||||
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
doReturn(List.of(cancelled)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(List.of(third)).when(cancelled).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(true).when(first).hasLineOfSight(cancelled); doReturn(true).when(cancelled).hasLineOfSight(third);
|
||||
var applied = new LinkedHashMap<UUID, Double>();
|
||||
for (var target : List.of(first, cancelled, third)) nativeDamage(fixture, target, applied);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void cancel(EntityDamageEvent event) { if (event.getEntity() == cancelled) event.setCancelled(true); }
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, applied.size()); assertFalse(applied.containsKey(cancelled.getUniqueId()));
|
||||
}
|
||||
var records = events(data); var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
|
||||
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -221,6 +284,18 @@ class ChainLightningNativeTest {
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", "8"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("7", ((games.dmg.fantasystick.persistence.ChainLightningSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
|
||||
.value("chain-lightning-jump-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
@@ -232,6 +307,11 @@ class ChainLightningNativeTest {
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList();
|
||||
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,13 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertEquals("Fantasy feedback is off.", player.messages.getLast());
|
||||
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
|
||||
}
|
||||
var records = feedbackRecords();
|
||||
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"))
|
||||
.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 {
|
||||
@@ -60,6 +67,188 @@ class FantasyFeedbackCommandNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredFeedbackReadAndChangeReachAccountOwnedJsonl() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("Fantasy feedback is now off.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords();
|
||||
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("combat_feedback", read.get("preference_key").getAsString());
|
||||
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
|
||||
assertEquals("/fstick feedback", read.get("entry_point").getAsString());
|
||||
assertEquals("true", read.get("default_value").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
var change = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
|
||||
assertEquals("true", change.get("before").getAsString());
|
||||
assertEquals("false", change.get("after").getAsString());
|
||||
assertTrue(change.get("preference_sequence").getAsLong() > 0);
|
||||
assertEquals(actor.world.getUID().toString(), change.getAsJsonObject("location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackRefusalsFailedAndSupersededWritesCannotClaimSuccess() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "on"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "OFF"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
|
||||
records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data").get("reason").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFeedbackSaveIsReportedWithoutChangingEffectiveAccountState() 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);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertTrue(feedbackRecords().stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closedFeedbackCommandCannotPublishUnacknowledgedPreferenceSuccess() 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);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((games.dmg.fantasystick.session.FantasyFeedbackCommand)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackCommand")).close();
|
||||
fixture.tick();
|
||||
assertTrue(preferences.enabled(actor.core.id), "Closed callback leaves the current effective value unchanged");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("closed")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackOutboxRecoversOriginalAcceptedChangeAfterSinkOutage() 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);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(p -> "true".equals(p.captured().get("published"))));
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && feedbackRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackPreferenceIsAccountOwnedAcrossCharacterTransfer() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var recipient = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(owner.core.id, owner.player); fixture.players.put(recipient.core.id, recipient.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
fixture.activate(owner);
|
||||
var sheet = owner.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(owner.player);
|
||||
fixture.awaitCharacterAndReceipt(owner);
|
||||
recipient.core.inventory.setItem(8, recipient.core.inventory.getItemInOffHand());
|
||||
recipient.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(recipient);
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertEquals("Fantasy feedback is off.", owner.messages.getLast());
|
||||
assertTrue(root.onCommand(recipient.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertEquals("Fantasy feedback is on.", recipient.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var reads = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
|
||||
&& d.get("effective_value").getAsString().equals("false") && !d.has("character_id")));
|
||||
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(recipient.core.id.toString())
|
||||
&& d.get("effective_value").getAsString().equals("true")
|
||||
&& d.get("character_id").getAsString().equals(owner.core.record.id().toString())));
|
||||
assertEquals(1, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
}
|
||||
}
|
||||
|
||||
private List<com.google.gson.JsonObject> feedbackRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.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());
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
|
||||
@@ -287,6 +287,503 @@ class FantasyParticlesNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAllParticlesReadAndSavedChangeProduceAccountEvents() 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);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all"}));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && !preferences.pendingEvents().isEmpty(); attempt++)
|
||||
Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().isEmpty(), "First acknowledged event was not drained");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.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 read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("true", changed.get("before").getAsString());
|
||||
assertEquals("false", changed.get("after").getAsString());
|
||||
assertTrue(changed.get("preference_sequence").getAsLong() > 0);
|
||||
assertEquals(read.get("player_id"), changed.get("player_id"));
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "on"}));
|
||||
for (int attempt = 0; attempt < 80 && !actor.messages.getLast().contains("now on"); attempt++) {
|
||||
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
}
|
||||
assertEquals("All Fantasy Stick particles are now on.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
var logical = changes.stream().collect(java.util.stream.Collectors.toMap(
|
||||
d -> d.get("action_id").getAsString(), d -> d, (first, duplicate) -> first,
|
||||
java.util.LinkedHashMap::new));
|
||||
assertEquals(2, logical.size(), "At-least-once export may duplicate a physical line, not a logical change");
|
||||
assertTrue(logical.values().stream().mapToLong(d -> d.get("preference_sequence").getAsLong()).max().orElseThrow()
|
||||
> changed.get("preference_sequence").getAsLong(),
|
||||
"The acknowledged preference revision must stay ordered across restart");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedAndSupersededAllParticleWritesNeverClaimAnAcceptedChange() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "on"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertTrue(preferences.allParticlesEnabled(actor.core.id));
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "invalid"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var resolved = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("preference_key") && d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments"), resolved.stream()
|
||||
.map(d -> d.get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(3, resolved.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
|
||||
"Superseded and unchanged saves cannot become successes during restart recovery");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAllParticleSaveRemainsObservableWithoutChangingGameplay() 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);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).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("save_failed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void accountOptOutRetainsAuditAndCapturesCurrentCharacterWithoutBorrowingAnotherAccount() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var other = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.players.put(other.core.id, other.player);
|
||||
fixture.activate(actor);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
|
||||
var sourceSheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
other.core.inventory.setItem(8, other.core.inventory.getItemInOffHand());
|
||||
other.core.inventory.setItemInOffHand(sourceSheet);
|
||||
fixture.activate(other);
|
||||
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are off.", actor.messages.getLast());
|
||||
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(actor.player,
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "all", "of"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data")).toList();
|
||||
assertEquals(1, changed.size(), "Particle delivery and autocomplete do not create audit events");
|
||||
assertEquals(actor.core.id.toString(), changed.getFirst().get("player_id").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), changed.getFirst().get("character_id").getAsString());
|
||||
assertEquals(actor.world.getUID().toString(), changed.getFirst().getAsJsonObject("location")
|
||||
.get("world_id").getAsString());
|
||||
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(other.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertFalse(read.has("character_id"), "An ordinary viewer cannot borrow a playing character identity");
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.anyMatch(d -> d.get("player_id").getAsString().equals(other.core.id.toString())
|
||||
&& d.has("character_id")
|
||||
&& d.get("character_id").getAsString().equals(actor.core.record.id().toString())
|
||||
&& d.get("effective_value").getAsString().equals("true")),
|
||||
"A transferred sheet carries its character, not the previous account's particle preference");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonPlayerAllParticleRequestsAreTruthfullyRefusedWithoutInventingPreferenceSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var console = org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
var block = org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(console, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
assertTrue(root.onCommand(block, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().contains(".preference.")).count());
|
||||
var refused = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("reason") && d.get("reason").getAsString().equals("player_required"))
|
||||
.toList();
|
||||
assertEquals(java.util.Set.of("console", "command_block"), refused.stream()
|
||||
.map(d -> d.get("actor_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(2, refused.size());
|
||||
assertTrue(refused.stream().allMatch(d -> d.get("location_status").getAsString().equals("not_applicable")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedPreferenceOutlivesUnavailableEventSinkWithoutLosingItsOriginalEvent() 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);
|
||||
var log = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
|
||||
log.close(); // Logging fails after gameplay has started; the saved preference remains authoritative.
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(intent -> "true".equals(intent.captured().get("published"))); attempt++)
|
||||
Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(intent -> "true".equals(intent.captured().get("published"))),
|
||||
"Committed publication proof must be durable before orderly restart");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); attempt++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var events = preferenceRecords();
|
||||
var changes = events.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.filter(e -> e.getAsJsonObject("data").get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count(),
|
||||
"An acknowledged account change must retain one recoverable event identity");
|
||||
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void readDuringDeferredPublicationDistinguishesSavedFromEffectivePreference() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS); // worker barrier; do not execute deferred main callback
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var read = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(actor.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("false", read.get("saved_value").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
assertEquals(0, preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
|
||||
"A queued native callback has not yet published a changed outcome");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void twoAcknowledgedChangesSurviveEventSinkFailureWithTheirOriginalIdentities() 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);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
for (String value : List.of("off", "on")) {
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", value}));
|
||||
for (int attempt = 0; attempt < 80 && (actor.messages.isEmpty()
|
||||
|| !actor.messages.getLast().contains("now " + value)); attempt++) {
|
||||
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
}
|
||||
assertTrue(actor.messages.getLast().contains("now " + value));
|
||||
}
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
|
||||
.filter(intent -> "true".equals(intent.captured().get("published"))).count() < 2; attempt++)
|
||||
Thread.sleep(10);
|
||||
assertEquals(2, preferences.pendingEvents().stream()
|
||||
.filter(intent -> "true".equals(intent.captured().get("published"))).count());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.get("id").getAsString()).distinct().count() < 2; attempt++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
|
||||
(first, duplicate) -> first, java.util.LinkedHashMap::new));
|
||||
assertEquals(2, changes.size(), "Both acknowledged changes need distinct stable recovered outcomes");
|
||||
assertEquals(java.util.Set.of("false", "true"), changes.values().stream()
|
||||
.map(e -> e.getAsJsonObject("data").get("after").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAuraPreferenceRecordsDefaultOffAndSavedOptInUnderAllOff() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("effectively off"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
var aura = records.stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
var read = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("false", read.get("default_value").getAsString());
|
||||
assertEquals("false", read.get("saved_value").getAsString());
|
||||
assertEquals("false", read.get("effective_value").getAsString());
|
||||
assertEquals("/fstick particles players", read.get("entry_point").getAsString());
|
||||
var change = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
|
||||
assertEquals("false", change.get("saved_before").getAsString());
|
||||
assertEquals("true", change.get("saved_value").getAsString());
|
||||
assertEquals("false", change.get("effective_value").getAsString());
|
||||
assertEquals("true", change.get("after").getAsString());
|
||||
assertTrue(change.get("preference_sequence").getAsLong() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraRefusalsAndSupersededWritesNeverClaimPreferenceSuccess() 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);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setPlayerAuras(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "invalid"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var reasons = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data").get("reason").getAsString()).toList();
|
||||
assertEquals(5, reasons.size());
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
|
||||
java.util.Set.copyOf(reasons));
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, preferenceRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraSaveFailureLeavesDefaultOffAndReportsFailure() 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);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertEquals("save_failed", records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data").get("reason").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraOptInSurvivesRestartAndCharacterTransferWithoutLeakingAccounts() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var receiver = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(owner.core.id, owner.player);
|
||||
fixture.players.put(receiver.core.id, receiver.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
fixture.activate(owner);
|
||||
var sheet = owner.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(owner.player);
|
||||
fixture.awaitCharacterAndReceipt(owner);
|
||||
receiver.core.inventory.setItem(8, receiver.core.inventory.getItemInOffHand());
|
||||
receiver.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(receiver);
|
||||
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
|
||||
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(owner.player,
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "of"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream()
|
||||
.filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var receiverRead = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(receiver.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("false", receiverRead.get("saved_value").getAsString());
|
||||
assertEquals(owner.core.record.id().toString(), receiverRead.get("character_id").getAsString());
|
||||
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
|
||||
&& d.get("saved_value").getAsString().equals("true")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedAuraOptInRecoversSameEventAfterSinkOutage() 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);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(p -> "true".equals(p.captured().get("published"))));
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && preferenceRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("saved_before").getAsString());
|
||||
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("saved_value").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.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());
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -99,6 +99,45 @@ class FrostNovaNativeTest {
|
||||
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
|
||||
assertEquals(1, abilityEvents(records, type).size(), type);
|
||||
assertEquals(2, abilityEvents(records, "combat.damage").size(), "One accepted record per eligible target");
|
||||
for (var type : List.of("effect.applied", "effect.ended"))
|
||||
assertEquals(2, abilityEvents(records, type).size(), "Only the two owned Slowness layers: " + type);
|
||||
var damageEvents = abilityEvents(records, "combat.damage");
|
||||
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic native observer cannot duplicate either owned burst hit");
|
||||
assertEquals(2, damageEvents.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertTrue(damageEvents.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")
|
||||
&& event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 8
|
||||
&& event.getAsJsonObject("data").get("damage_kind").getAsString().equals("owned_area_burst")));
|
||||
var applied = abilityEvents(records, "effect.applied");
|
||||
assertEquals(2, applied.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertTrue(applied.stream().allMatch(event -> event.getAsJsonObject("data").get("amplifier").getAsInt() == 1));
|
||||
assertEquals(2, abilityEvents(records, "effect.ended").stream()
|
||||
.filter(event -> event.getAsJsonObject("data").get("reason").getAsString().equals("expired")).count());
|
||||
var outcome = abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data");
|
||||
assertEquals("hit", outcome.get("outcome").getAsString());
|
||||
assertEquals(2, outcome.get("accepted_count").getAsInt());
|
||||
assertEquals(3, outcome.get("attempt_count").getAsInt());
|
||||
assertEquals(5, outcome.get("candidate_count").getAsInt(), "Duplicate nearby references are deduplicated");
|
||||
assertTrue(outcome.getAsJsonObject("burst_location").has("world_id"));
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
|
||||
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_nova")).toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -274,6 +313,18 @@ class FrostNovaNativeTest {
|
||||
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
|
||||
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
|
||||
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "frost-nova-radius-blocks", "11"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("10", ((games.dmg.fantasystick.persistence.FrostNovaSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "frostNovaSettings"))
|
||||
.value("frost-nova-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
|
||||
@@ -286,6 +337,12 @@ class FrostNovaNativeTest {
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("frost-nova-")).toList();
|
||||
assertEquals(6, config.size(), "One authorized read and five acknowledged saves, no denied/invalid successes");
|
||||
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
|
||||
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
|
||||
@@ -68,6 +68,65 @@ class GuardianBondNativeTest {
|
||||
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
|
||||
assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
|
||||
"effect.applied", "effect.ended", "combat.prevented", "combat.damage"))
|
||||
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(), type);
|
||||
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
|
||||
var prevented = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
|
||||
assertEquals("3", prevented.getAsJsonObject("data").get("actual_amount").getAsString());
|
||||
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
|
||||
prevented.getAsJsonObject("data").get("action_id").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@Test void laterCancelledOriginalHitCannotClaimAcceptedPrevention() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player);
|
||||
fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally);
|
||||
party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event) {
|
||||
if (event.getEntity() == ally.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
var enemy = mock(org.bukkit.entity.Zombie.class);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var original = hit(enemy, ally.player, 10); fixture.dispatcher.accept(original);
|
||||
assertTrue(original.isCancelled());
|
||||
assertEquals(7, guardian.player.getHealth(), 1.0e-9);
|
||||
}
|
||||
var observed = events(data);
|
||||
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count());
|
||||
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(),
|
||||
"The guardian's separately accepted native damage remains truthful");
|
||||
}
|
||||
|
||||
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
|
||||
@@ -92,7 +151,10 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
};
|
||||
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
|
||||
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var hit = hit(attacker, ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(List.of("guardian"), defeatOrder,
|
||||
"Redirected damage reaches normal guardian defeat before original damage resumes");
|
||||
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
|
||||
@@ -107,6 +169,11 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
|
||||
}
|
||||
var ownerDamage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).toList();
|
||||
assertEquals(1, ownerDamage.size(), "A lethal redirect still has one accepted guardian damage record");
|
||||
assertEquals(1.0, ownerDamage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
|
||||
}
|
||||
|
||||
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
|
||||
@@ -132,6 +199,25 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledOriginalNativeHitCannotCreateEffectiveSupport() {
|
||||
var context = new Context(true);
|
||||
when(context.guardian.getHealth()).thenReturn(10.0);
|
||||
doAnswer(call -> {
|
||||
var redirected = hit(call.getArgument(1), context.guardian, (Double) call.getArgument(0));
|
||||
context.bond.acceptedRedirect(redirected);
|
||||
return null;
|
||||
}).when(context.guardian).damage(anyDouble(), any(Entity.class));
|
||||
context.activate();
|
||||
var cancelled = hit(context.enemy, context.ally, 10);
|
||||
context.bond.redirect(cancelled); cancelled.setCancelled(true);
|
||||
context.bond.acceptedPrevention(cancelled);
|
||||
assertTrue(context.supports.isEmpty(), "A later-vetoed original hit cannot create support credit");
|
||||
var accepted = hit(context.enemy, context.ally, 10);
|
||||
context.bond.redirect(accepted); context.bond.acceptedPrevention(accepted);
|
||||
assertEquals(1, context.supports.size());
|
||||
assertEquals(0, new BigDecimal("3").compareTo(context.supports.getFirst().amount()));
|
||||
}
|
||||
|
||||
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
|
||||
var context = new Context(); var requested = new ArrayList<BigDecimal>();
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
@@ -268,6 +354,19 @@ class GuardianBondNativeTest {
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "guardian-bond-range-blocks", "17"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("16", ((games.dmg.fantasystick.persistence.GuardianBondSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "guardianBondSettings"))
|
||||
.value("guardian-bond-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
|
||||
@@ -281,6 +380,18 @@ class GuardianBondNativeTest {
|
||||
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("guardian-bond-")).toList();
|
||||
assertEquals(5, config.size());
|
||||
assertEquals("14", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
assertEquals("console", config.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());
|
||||
var range = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().equals("guardian-bond-range-blocks"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("14", range.getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("16", range.getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
|
||||
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-percent: 101\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
@@ -299,7 +410,8 @@ class GuardianBondNativeTest {
|
||||
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
|
||||
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
|
||||
final PartyRoster.Party party;
|
||||
Context() {
|
||||
Context() { this(false); }
|
||||
Context(boolean nativeAdapter) {
|
||||
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
|
||||
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
@@ -314,8 +426,10 @@ class GuardianBondNativeTest {
|
||||
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
bond = nativeAdapter
|
||||
? new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties, supports::add)
|
||||
: new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
when(execution.definition()).thenReturn(bond.definition());
|
||||
}
|
||||
void activate() { bond.activate(execution); }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -105,6 +105,30 @@ class MendingLightNativeTest {
|
||||
assertEquals("10", healed.getAsJsonObject("data").get("health_after").getAsString());
|
||||
}
|
||||
|
||||
@Test void accountFeedbackOptOutCannotHideAnAcceptedNativeHealEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(caster.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
assertFalse(caster.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
|
||||
assertTrue(ally.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
var records = events(data);
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")
|
||||
&& e.getAsJsonObject("data").has("ability_id")
|
||||
&& e.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).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());
|
||||
}
|
||||
|
||||
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
|
||||
@@ -29,12 +29,55 @@ class MeteorNativeTest {
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);
|
||||
var vetoed=mock(LivingEntity.class);locate(vetoed,caster.world,new UUID(0,3),3,65,0);
|
||||
doReturn(List.of(enemy,vetoed)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
class Veto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageEvent event){if(event.getEntity()==vetoed)event.setCancelled(true);}
|
||||
}
|
||||
f.dispatcher.register(new Veto(),f.plugin);
|
||||
doAnswer(call->{var source=(DamageSource)call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,vetoed,EntityDamageEvent.DamageCause.MAGIC,
|
||||
source,(double)call.getArgument(0)));return null;
|
||||
}).when(vetoed).damage(anyDouble(),any(DamageSource.class));
|
||||
var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.damage"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic observer must not duplicate Meteor-owned native damage");
|
||||
var impact=abilityEvents(records,"combat.damage").getFirst().getAsJsonObject("data");
|
||||
assertEquals(22,impact.get("actual_amount").getAsDouble(),1e-9);
|
||||
assertTrue(impact.has("target_location"));
|
||||
var outcome=abilityEvents(records,"ability.resolved").getFirst().getAsJsonObject("data");
|
||||
assertEquals("hit",outcome.get("outcome").getAsString());
|
||||
assertEquals(1,outcome.get("accepted_count").getAsInt());
|
||||
assertEquals(2,outcome.get("attempted_count").getAsInt(),"Cancelled native impact has no damage success");
|
||||
assertTrue(outcome.getAsJsonObject("impact_location").has("world_id"));
|
||||
}
|
||||
|
||||
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("meteor")).toList();
|
||||
}
|
||||
|
||||
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}
|
||||
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only real ground selection commits a cooldown");
|
||||
assertTrue(abilityEvents(events(data),"combat.damage").isEmpty(),"Warning alone cannot claim a hit");
|
||||
}
|
||||
|
||||
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
|
||||
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
|
||||
@@ -44,9 +87,27 @@ class MeteorNativeTest {
|
||||
|
||||
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
|
||||
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));
|
||||
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}}
|
||||
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS,"8"});
|
||||
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("7",((MeteorSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"meteorSettings")).value(MeteorSettings.RADIUS));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("meteor-")).toList();
|
||||
assertEquals(7,config.size(),"Authorized read and six acknowledged saved updates only");
|
||||
}
|
||||
|
||||
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
|
||||
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);doAnswer(call->{((LivingEntity)call.getArgument(0)).damage((Double)call.getArgument(1),(org.bukkit.damage.DamageSource)call.getArgument(2));return null;}).when(execution).damage(any(LivingEntity.class),anyDouble(),any(org.bukkit.damage.DamageSource.class));var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -70,6 +70,16 @@ class RallyingCryNativeTest {
|
||||
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void veto(EntityDamageByEntityEvent event) {
|
||||
if (event.getEntity() == enemy) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
@@ -85,6 +95,31 @@ class RallyingCryNativeTest {
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
|
||||
"combat.boosted"))
|
||||
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
|
||||
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
|
||||
for (var type : List.of("effect.applied", "effect.ended"))
|
||||
assertEquals(3, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
|
||||
var boosted = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.boosted")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).findFirst().orElseThrow();
|
||||
assertEquals("2", boosted.getAsJsonObject("data").get("actual_amount").getAsString());
|
||||
assertEquals("12", boosted.getAsJsonObject("data").get("final_amount").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
|
||||
@@ -199,6 +234,19 @@ class RallyingCryNativeTest {
|
||||
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(14, future.getDamage(), 1.0e-9);
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "rallying-cry-radius-blocks", "13"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("12", ((games.dmg.fantasystick.persistence.RallyingCrySettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "rallyingCrySettings"))
|
||||
.value("rallying-cry-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
|
||||
@@ -211,6 +259,17 @@ class RallyingCryNativeTest {
|
||||
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("rallying-cry-")).toList();
|
||||
assertEquals(6, config.size());
|
||||
assertEquals("10", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
var radius = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().equals("rallying-cry-radius-blocks"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("10", radius.getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("12", radius.getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
|
||||
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
|
||||
"rallying-cry-damage-bonus-percent: 101\n")) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,19 @@ class SanctuaryNativeTest {
|
||||
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
|
||||
PartyBookNativeTest.disableAllParticles(fixture,caster);caster.particleCalls.clear();viewer.particleCalls.clear();
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
var incomingAttacker=mock(org.bukkit.entity.LivingEntity.class);when(incomingAttacker.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var incomingSource=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(incomingAttacker).withDirectEntity(incomingAttacker).build();
|
||||
var incoming=new EntityDamageByEntityEvent(incomingAttacker,ally.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
|
||||
fixture.dispatcher.accept(incoming);assertEquals(8,incoming.getFinalDamage(),1.0e-9);
|
||||
class LateVeto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event){if(event.getDamager()==incomingAttacker)event.setCancelled(true);}
|
||||
}
|
||||
fixture.dispatcher.register(new LateVeto(),fixture.plugin);
|
||||
var vetoed=new EntityDamageByEntityEvent(incomingAttacker,ally.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
|
||||
fixture.dispatcher.accept(vetoed);assertTrue(vetoed.isCancelled());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
@@ -54,6 +67,61 @@ class SanctuaryNativeTest {
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.prevented"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
var heals=abilityEvents(records,"combat.healed");
|
||||
assertTrue(heals.size()>=1,"Actual acknowledged ally-health pulses are individually recorded");
|
||||
assertEquals(heals.size(),heals.stream().map(event->event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(3,abilityEvents(records,"effect.applied").size(),"One fixed area plus caster/ally benefits");
|
||||
assertEquals(3,abilityEvents(records,"effect.ended").size(),"No per-tick duplicate grants");
|
||||
var heal=heals.stream().map(event->event.getAsJsonObject("data"))
|
||||
.filter(value->value.get("health_before").getAsString().equals("4")).findFirst().orElseThrow();
|
||||
assertEquals("2",heal.get("actual_amount").getAsString());
|
||||
assertEquals("6",heal.get("health_after").getAsString());
|
||||
assertTrue(heal.has("target_location"));
|
||||
assertEquals("2",abilityEvents(records,"combat.prevented").getFirst().getAsJsonObject("data")
|
||||
.get("actual_amount").getAsString());
|
||||
}
|
||||
|
||||
@Test void cancelledNativeAllyHealCannotClaimSavedOrEffectiveHealth()throws Exception{
|
||||
UUID allyId;
|
||||
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally=new FantasyStickLifecycleNativeTest.Actor(data);allyId=ally.core.id;
|
||||
prepareCaster(data,caster,true);setHealth(data,ally,BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id,caster.player);fixture.players.put(allyId,ally.player);
|
||||
fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
|
||||
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
|
||||
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
class Veto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGH)
|
||||
public void cancel(EntityRegainHealthEvent event){if(event.getEntity()==ally.player)event.setCancelled(true);}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(),fixture.plugin);
|
||||
use(fixture,caster);drain(fixture,2);
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);
|
||||
assertEquals(4,ally.core.health,0);
|
||||
}
|
||||
assertTrue(abilityEvents(events(data),"combat.healed").stream()
|
||||
.noneMatch(event->event.getAsJsonObject("data").get("target_player_id").getAsString().equals(allyId.toString())),
|
||||
"Cancelled ally healing must not become a saved-health success or a JSONL healing claim");
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("sanctuary")).toList();
|
||||
}
|
||||
|
||||
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
|
||||
@@ -145,6 +213,16 @@ class SanctuaryNativeTest {
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
|
||||
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());
|
||||
plugin.command.execute(sender.port,"fstickadmin",new String[]{"config","sanctuary-radius-blocks","9"});
|
||||
var failed=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("8",((games.dmg.fantasystick.persistence.SanctuarySettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"sanctuarySettings")).value("sanctuary-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
|
||||
@@ -152,6 +230,11 @@ class SanctuaryNativeTest {
|
||||
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
|
||||
}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("sanctuary-")).toList();
|
||||
assertEquals(8,config.size(),"Authorized read and seven saved settings only");
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,45 @@ class WarBannerNativeTest {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
|
||||
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);
|
||||
class LateVeto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event){if(event.getEntity()==enemy)event.setCancelled(true);}
|
||||
}
|
||||
f.dispatcher.register(new LateVeto(),f.plugin);
|
||||
var cancelled=hit(ally.player,enemy,10);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.boosted"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
assertEquals(2,abilityEvents(records,"effect.applied").size(),"Fixed banner and the actually eligible ally benefit");
|
||||
assertEquals(2,abilityEvents(records,"effect.ended").size(),"Expiry closes only actually owned benefits");
|
||||
var boosted=abilityEvents(records,"combat.boosted").getFirst().getAsJsonObject("data");
|
||||
assertEquals("2.5",boosted.get("actual_amount").getAsString());
|
||||
assertEquals("12.5",boosted.get("final_amount").getAsString());
|
||||
assertTrue(boosted.has("target_location"));
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("war_banner")).toList();
|
||||
}
|
||||
|
||||
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
|
||||
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only the actual solid-top placement commits a cast");
|
||||
}
|
||||
|
||||
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
|
||||
@@ -65,7 +97,21 @@ class WarBannerNativeTest {
|
||||
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
|
||||
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(sender.port,"fstickadmin",new String[]{"config",WarBannerSettings.RADIUS,"10"});
|
||||
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("9",((WarBannerSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"warBannerSettings")).value(WarBannerSettings.RADIUS));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("war-banner-")).toList();
|
||||
assertEquals(8,config.size(),"Authorized read and seven saved updates, without denied/invalid successes");
|
||||
}
|
||||
|
||||
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
|
||||
|
||||
@@ -30,7 +30,8 @@ class ChainLightningSettingsTest {
|
||||
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().jumpRange(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRange), "Stale save callbacks cannot republish policy or claim configuration success");
|
||||
assertEquals(7, settings.settings().jumpRange(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
|
||||
@@ -21,7 +21,8 @@ class FrostNovaSettingsTest {
|
||||
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(5, settings.settings().radius(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRange), "A late save callback cannot republish stale settings or claim an event");
|
||||
assertEquals(7, settings.settings().radius(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
|
||||
@@ -12,7 +12,7 @@ class MeteorSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
|
||||
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
|
||||
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
|
||||
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));assertTrue(store.publish(update));assertEquals("6",store.value(MeteorSettings.RADIUS));var later=store.set(MeteorSettings.RADIUS,"7").toCompletableFuture().get();assertTrue(store.publish(later));assertFalse(store.publish(update),"A stale callback cannot claim publication twice");assertEquals("7",store.value(MeteorSettings.RADIUS));}}
|
||||
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(data,defaults(),(from,to)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);})) {assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
|
||||
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
|
||||
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
|
||||
|
||||
@@ -22,7 +22,9 @@ class SanctuarySettingsTest {
|
||||
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
|
||||
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
|
||||
assertEquals(6, settings.settings().radius(), 0);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRadius), "A stale save callback cannot republish policy or claim a config event");
|
||||
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("7", String.valueOf(yaml.get(SanctuarySettings.RADIUS)));
|
||||
|
||||
@@ -39,7 +39,11 @@ class WarBannerSettingsTest {
|
||||
var update=store.set(WarBannerSettings.RADIUS,"9.00").toCompletableFuture().get();
|
||||
assertEquals("8",store.value(WarBannerSettings.RADIUS));assertEquals("9",update.value());
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
|
||||
store.publish(update);assertEquals("9",store.value(WarBannerSettings.RADIUS));
|
||||
assertTrue(store.publish(update));assertEquals("9",store.value(WarBannerSettings.RADIUS));
|
||||
var later=store.set(WarBannerSettings.RADIUS,"10").toCompletableFuture().get();
|
||||
assertTrue(store.publish(later));
|
||||
assertFalse(store.publish(update),"A stale callback cannot replace current settings or claim a second success");
|
||||
assertEquals("10",store.value(WarBannerSettings.RADIUS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user