Compare commits

...
14 Commits
Author SHA1 Message Date
dmg 13828054e5 feat(events): audit combat feedback preferences
Release / release (push) Failing after 14m7s
2026-09-25 09:48:27 -04:00
dmg 39c58ef4d9 feat(events): audit account aura preferences
Release / release (push) Successful in 14m40s
2026-09-25 09:37:45 -04:00
dmg ec73018f93 feat(events): audit account particle preferences
Release / release (push) Successful in 14m30s
2026-09-25 09:21:18 -04:00
dmg b86f703d14 feat(events): record Meteor impact outcomes
Release / release (push) Successful in 12m56s
2026-09-25 03:19:10 -04:00
dmg 2231dd058a feat(events): record War Banner benefit outcomes
Release / release (push) Successful in 7m10s
2026-09-25 03:11:25 -04:00
dmg ff28f2e98d feat(events): record Sanctuary area and healing outcomes
Release / release (push) Successful in 7m19s
2026-09-25 03:02:45 -04:00
dmg 21877f6820 feat(events): record Chain Lightning strike outcomes
Release / release (push) Failing after 13m13s
2026-09-25 02:50:24 -04:00
dmg 228de3d08f feat(events): record Frost Nova burst outcomes
Release / release (push) Successful in 7m29s
2026-09-25 02:42:05 -04:00
dmg e3bc854481 feat(events): record Rallying Cry boost outcomes
Release / release (push) Successful in 13m10s
2026-09-25 02:28:10 -04:00
dmg 3163a491a0 feat(events): record Guardian Bond damage outcomes
Release / release (push) Successful in 13m31s
2026-09-25 02:18:00 -04:00
dmg 08d13d2192 feat(events): record Purify cleanse outcomes
Release / release (push) Successful in 13m17s
2026-09-25 02:01:08 -04:00
dmg 1d0cdc8f0f feat(events): record Mending Light healing outcomes
Release / release (push) Successful in 8m27s
2026-09-25 01:51:17 -04:00
dmg 9674a8e5e6 feat(events): record Fireball and configuration outcomes
Release / release (push) Successful in 14m20s
2026-09-25 01:41:25 -04:00
dmg dd75e6a76f feat(events): add opt-in ability cast event hooks
Release / release (push) Successful in 14m37s
2026-09-25 01:16:43 -04:00
55 changed files with 3344 additions and 196 deletions

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;
@@ -712,11 +713,33 @@ 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);
progressionEvents = new games.dmg.fantasystick.events.ProgressionEventBridge(eventLog::emit, Clock.systemUTC());books.attachProgressionEvents(progressionEvents);
abilityEvents = new games.dmg.fantasystick.events.AbilityEventBridge(eventLog::emit, Clock.systemUTC());books.attachAbilityEvents(abilityEvents);
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
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);
@@ -813,7 +836,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
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); }
@@ -835,7 +866,7 @@ 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 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;
@@ -43,11 +43,32 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Consumer<Failure> failures;
private final Map<Key, Object> pending = new HashMap<>();
private final Set<String> eventOwnedAbilities = new HashSet<>();
private final Map<Key, Long> generations = new HashMap<>();
private final Map<Key, Set<Runtime>> active = new HashMap<>();
private org.bukkit.scheduler.BukkitTask sweepTask;
private games.dmg.fantasystick.events.AbilityCastEventBridge castEvents;
private boolean closed;
public void attachCastEvents(games.dmg.fantasystick.events.AbilityCastEventBridge events) {
if (closed || castEvents != null) throw new IllegalStateException("Cast events already attached or engine closed");
castEvents = Objects.requireNonNull(events);
}
/** An ability owner opts in only after its actual effect-specific event path is delivered. */
public void enableCastEventsFor(String abilityId) {
if (closed || registry.find(Objects.requireNonNull(abilityId)).isEmpty())
throw new IllegalArgumentException("Unknown or closed ability event owner");
eventOwnedAbilities.add(abilityId);
}
public boolean eventsEnabledFor(String abilityId) { return eventOwnedAbilities.contains(abilityId); }
private void resolve(games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
String outcome, String reason) {
if (castEvents != null) castEvents.resolved(audit, outcome, reason);
}
public AbilityEngine(AbilityRegistry registry, CharacterStorage characters, EquipmentSessions sessions,
PartyRoster parties, Clock clock, Consumer<Runnable> nextTick, AbilityScheduler scheduler,
Consumer<AbilityReport> reports, Consumer<Failure> failures) {
@@ -176,22 +197,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
pending.remove(key, token); report(key.character(), key.ability(), invalid);
input.player().sendMessage("That ability's cooldown is invalid."); return;
}
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); report(key.character(), key.ability(), rejected);
}
});
var audit = castEvents == null || !eventOwnedAbilities.contains(definition.id())
? null : castEvents.begin(input, record, definition);
try {
characters.commitAbilityCooldown(key.character(), record.revision(), key.ability(), readyAt)
.whenComplete((mutation, failure) -> {
try {
nextTick.accept(() -> completeCast(input, definition, record.revision(), readyAt,
key, token, generation, audit, mutation, failure));
} catch (RuntimeException rejected) {
pending.remove(key, token); resolve(audit, "failed", "scheduler_rejected");
report(key.character(), key.ability(), rejected);
}
});
} catch (RuntimeException failure) {
pending.remove(key, token); resolve(audit, "failed", "save_rejected");
report(key.character(), key.ability(), failure);
}
}
private void completeCast(ControlInput input, AbilityDefinition definition, BigInteger expectedRevision, Instant readyAt,
Key key, Object token, long generation, CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token)) return;
if (closed) return;
Key key, Object token, long generation, games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterStorage.AbilityMutation mutation, Throwable failure) {
if (!pending.remove(key, token) || closed) { resolve(audit, "interrupted", "session_or_engine_closed"); return; }
if (failure != null) {
resolve(audit, "failed", "save_failed");
report(key.character(), key.ability(), failure);
input.player().sendMessage("The cast could not be saved; no effect was applied."); return;
}
@@ -199,6 +229,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
&& mutation.record().map(record -> readyAt.equals(record.cooldowns().get(key.ability()))).orElse(false);
if ((mutation.status() != CharacterStorage.AbilityMutationStatus.APPLIED && !unchangedDeadline)
|| mutation.record().isEmpty()) {
resolve(audit, "refused", mutation.status().name().toLowerCase(java.util.Locale.ROOT));
input.player().sendMessage(switch (mutation.status()) {
case COOLDOWN -> "That ability is still on cooldown.";
case STALE -> "That character changed; retry the cast.";
@@ -208,23 +239,26 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
resolve(audit, "interrupted", "session_publication_changed");
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
resolve(audit, "interrupted", "eligibility_changed");
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation);
var runtime = new Runtime(key, input, definition, generation, audit);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.close(); report(key.character(), key.ability(), effectFailure);
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
}
@@ -293,12 +327,18 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final ControlInput input;
private final AbilityDefinition definition;
private final long generation;
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
private boolean finished;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation) {
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
this.audit = audit;
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@@ -361,12 +401,75 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public void finish() { close(); }
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
UUID application = castEvents == null ? UUID.randomUUID() : castEvents.effectApplied(audit, effectId, target, evidence);
observedEffects.put(application, effectId);
return application;
}
@Override public void acceptedDamage(LivingEntity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (castEvents == null || audit == null) return;
try {
var details = new java.util.LinkedHashMap<String, Object>(evidence);
if (target instanceof Player player)
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
details.put("target_character_id", active.character().toString());
details.put("target_session_id", active.session().toString());
});
castEvents.acceptedDamage(audit, target, requested, actual, direct, details);
} catch (RuntimeException ignored) { /* Recording cannot change accepted native damage. */ }
}
@Override public void acceptedHeal(EquipmentSessions.Active aided, Player target,
CharacterRecord saved, double requested, double actual,
double before, double after, double maximum) {
if (castEvents != null && audit != null)
castEvents.healed(audit, target, aided.character(), aided.session(), saved,
requested, actual, before, after, maximum);
}
@Override public void acceptedCleanse(EquipmentSessions.Active aided, Player target, List<String> removed) {
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);
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
}
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
"healed", "cleansed", "protected", "boosted").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
}
@Override public void finish() {
if (outcome == null) outcome = "completed";
close();
}
@Override public void close() {
if (finished) return;
finished = true;
if (outcome == null) outcome = "interrupted";
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
if (castEvents != null) castEvents.resolved(audit, outcome,
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
var runtimes = active.get(key);
if (runtimes != null) { runtimes.remove(this); if (runtimes.isEmpty()) active.remove(key); }
}
@@ -5,6 +5,8 @@ import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
@@ -21,6 +23,32 @@ public interface AbilityExecution extends AutoCloseable {
boolean trainingDummy(Entity target);
void damage(LivingEntity target, double amount, DamageSource source);
void report(AbilityReport.Type type, Entity target, BigDecimal amount);
/** An owner reports an effect only after its actual native application. */
UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence);
/** Only the effect owner calls this after positive, uncancelled native damage is accepted. */
void acceptedDamage(LivingEntity target, double requested, double actual, Entity direct,
Map<String, Object> evidence);
/** Only after the aided record and native health have both been acknowledged. */
void acceptedHeal(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player target,
games.dmg.fantasystick.persistence.CharacterRecord saved,
double requested, double actual, double before, double after, double maximum);
/** 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. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
void finish();
@Override void close();
}
@@ -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() {
@@ -56,6 +56,8 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
final BigInteger intelligence;
final Settings settings;
LivingEntity target;
double requestedDamage;
boolean accepted;
boolean finished;
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
this.projectile = projectile; this.execution = execution; this.origin = origin;
@@ -123,8 +125,14 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
}
execution.own(state);
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), execution::finish);
execution.later(current.lifetime(), execution::finish);
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "range", "effective_settings", settingsData(current))); execution.finish();
});
execution.later(current.lifetime(), () -> {
execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString(),
"limit", "lifetime", "effective_settings", settingsData(current))); execution.finish();
});
particles.world(Particle.FLAME, origin, 8, 0.15, 0.15, 0.15, 0.01);
}
@@ -141,14 +149,44 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
var source = DamageSource.builder(DamageType.FIREBALL).withCausingEntity(state.execution.input().player())
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
state.target = target;
state.requestedDamage = nativeDamage;
damaging = state;
try { state.execution.damage(target, nativeDamage, source); }
finally { if (damaging == state) damaging = null; }
} finally {
state.execution.finish();
String outcome = state.accepted ? "hit" : event.isCancelled() ? "cancelled" : "miss";
try {
if (outcome.equals("miss") && (closed || !state.execution.stillCurrent())) outcome = "interrupted";
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("projectile_id", state.projectile.getUniqueId().toString());
evidence.put("direct_entity_id", state.projectile.getUniqueId().toString());
evidence.put("effective_settings", settingsData(state.settings));
var impact = event.getEntity().getLocation();
if (impact != null && impact.getWorld() != null) evidence.put("target_location", locationData(impact));
if (event.getHitEntity() != null) evidence.put("target_entity_id", event.getHitEntity().getUniqueId().toString());
state.execution.resolve(outcome, evidence);
} catch (RuntimeException auditFailure) {
state.execution.resolve(outcome);
} finally {
state.execution.finish();
}
}
}
private static Map<String, Object> settingsData(Settings settings) {
return Map.of("base-damage", settings.baseDamage().toString(),
"cooldown-seconds", seconds(settings.cooldown()).toPlainString(),
"range", Double.toString(settings.range()),
"lifetime", seconds(settings.lifetime()).toPlainString(),
"projectile-speed", Double.toString(settings.speedPerTick()));
}
private static Map<String, Object> locationData(Location location) {
return Map.of("world_id", location.getWorld().getUID().toString(),
"world_name", location.getWorld().getName(), "x", location.getX(),
"y", location.getY(), "z", location.getZ());
}
private boolean inRange(State state, Location current) {
return current.getWorld() != null && state.origin.getWorld() != null
&& current.getWorld().equals(state.origin.getWorld())
@@ -162,16 +200,25 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
if (state != null && damaging != state) event.setCancelled(true);
}
/** One native application belongs to this projectile, including a fantasy-player target. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target) return false;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return false;
var causing = event.getDamageSource().getCausingEntity();
return causing == null || causing == state.execution.input().player();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void acceptedDamage(EntityDamageEvent event) {
var state = damaging;
if (state == null || event.getEntity() != state.target || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage())) return;
var direct = event.getDamageSource().getDirectEntity();
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|| byEntity.getDamager() != state.projectile)) return;
var causing = event.getDamageSource().getCausingEntity();
if (causing != null && causing != state.execution.input().player()) return;
if (state == null || state.accepted || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || !ownsAcceptedDamage(event)) return;
state.accepted = true;
state.execution.acceptedDamage(state.target, state.requestedDamage, event.getFinalDamage(),
state.projectile, Map.of("damage_kind", "owned_direct_projectile"));
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
}
@@ -182,7 +229,9 @@ public final class Fireball implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true;
for (var state : List.copyOf(projectiles.values())) state.execution.finish();
for (var state : List.copyOf(projectiles.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
projectiles.clear(); damaging = null;
}
}
@@ -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();
}
}
@@ -101,25 +101,31 @@ public final class MendingLight implements AbilityEffect {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition());
var target = target(execution.input(), configured.range());
if (target == null) { execution.finish(); return; }
if (target == null) { execution.resolve("miss"); execution.finish(); return; }
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) { execution.finish(); return; }
if (attribute == null) { execution.resolve("miss"); execution.finish(); return; }
double current = target.player().getHealth(); double maximum = attribute.getValue(); double missing = maximum - current;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
BigInteger exact = healing(configured.baseHealing(), execution.character().stats().get(Stat.WIS));
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
if (!Double.isFinite(proposed) || proposed <= 0) {
execution.resolve("miss"); execution.finish(); return;
}
var event = new EntityRegainHealthEvent(target.player(), proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(event);
double accepted = Math.min(proposed, Math.min(missing, event.getAmount()));
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
execution.resolve(event.isCancelled() ? "cancelled" : "miss"); execution.finish(); return;
}
var expected = target.record().revision();
BigDecimal persistedHealth = BigDecimal.valueOf(current + accepted);
BigDecimal limit = nativeLimit.get();
characters.saveHealth(target.record().id(), expected, persistedHealth, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(execution, target, expected, accepted, saved, failure));
} catch (RuntimeException rejected) { execution.finish(); }
} catch (RuntimeException rejected) { execution.resolve("failed"); execution.finish(); }
});
}
@@ -127,20 +133,34 @@ public final class MendingLight implements AbilityEffect {
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (failure != null || saved == null || saved.isEmpty()) {
execution.resolve("failed");
execution.input().player().sendMessage("Mending Light health could not be saved; no healing was applied.");
return;
}
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) return;
if (!execution.stillCurrent() || !current(execution.input().player(), target, from(execution.definition()).range())) {
execution.resolve("interrupted"); return;
}
var persisted = saved.orElseThrow();
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) return;
if (!sessions.acknowledgeProgress(target.active(), expected, persisted)) {
execution.resolve("interrupted"); return;
}
var attribute = target.player().getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
double current = target.player().getHealth(); double maximum = attribute.getValue();
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;
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) {
execution.resolve("miss"); return;
}
target.player().setHealth(Math.min(maximum, current + restored));
var amount = BigDecimal.valueOf(restored).stripTrailingZeros();
double after = target.player().getHealth();
double actual = after - current;
if (!Double.isFinite(actual) || actual <= 0) { execution.resolve("miss"); return; }
execution.acceptedHeal(target.active(), target.player(), persisted, accepted, actual, current, after, maximum);
execution.resolve("healed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"target_character_id", target.active().character().toString(),
"actual_amount", BigDecimal.valueOf(actual).stripTrailingZeros().toPlainString()));
var amount = BigDecimal.valueOf(actual).stripTrailingZeros();
supports.accept(new Support(execution.input().session().character(), target.active(), amount));
execution.report(AbilityReport.Type.HEALING, target.player(), amount);
} finally { execution.finish(); }
@@ -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&&divided[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) {
@@ -72,16 +72,22 @@ public final class Purify implements AbilityEffect {
@Override public void activate(AbilityExecution execution) {
if (!execution.stillCurrent()) return;
var configured = from(execution.definition()); var target = target(execution.input(), configured.range());
if (target == null) return;
int removed = 0;
if (target == null) { execution.resolve("miss"); return; }
var removed = new java.util.ArrayList<String>();
int attempted = 0;
for (var type : HARMFUL) {
if (!execution.stillCurrent() || !current(execution.input(), target, configured.range())) break;
if (target.player().getPotionEffect(type) == null) continue;
attempted++;
target.player().removePotionEffect(type);
if (target.player().getPotionEffect(type) == null) removed++;
if (target.player().getPotionEffect(type) == null)
removed.add(type.getKey().getKey().toUpperCase(java.util.Locale.ROOT));
}
if (removed == 0) return;
var amount = BigDecimal.valueOf(removed);
if (removed.isEmpty()) { execution.resolve(attempted == 0 ? "miss" : "cancelled"); return; }
execution.acceptedCleanse(target.active(), target.player(), List.copyOf(removed));
execution.resolve("cleansed", Map.of("target_player_id", target.player().getUniqueId().toString(),
"removed_effects", List.copyOf(removed)));
var amount = BigDecimal.valueOf(removed.size());
execution.report(AbilityReport.Type.CLEANSING, target.player(), amount);
if (target.player() != execution.input().player()
&& execution.stillCurrent() && current(execution.input(), target, configured.range())) {
@@ -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;
@@ -861,7 +861,7 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|| event.getTag() == null) return;
var player = connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String ability=match.group(2);
var view = abilityViews.get(player.getUniqueId());
boolean audited=unlearn||unlock&&"revive".equals(ability);
boolean audited=unlearn||unlock&&("revive".equals(ability)||abilityEngine.eventsEnabledFor(ability));
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(ability)
|| !abilityViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(audited)refuseAbilityMutation(player,ability,unlock,"stale_forged_or_repeated_action");return;}
var audit=audited&&abilityEvents!=null?abilityEvents.begin(view.input(),view.before(),ability,unlock):null;
@@ -0,0 +1,266 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityDefinition;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
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;
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
public final class AbilityCastEventBridge {
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityCastEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Cast begin(ControlInput input, CharacterRecord before, AbilityDefinition definition) {
try {
var player = input.player();
var target = input.target();
var targetAtRequest = target == null ? null : target.getLocation();
var cast = new Cast(UUID.randomUUID(),
new EventInput.Context(player.getUniqueId(), player.getName(), input.session().character(),
before.name(), input.session().session(), location(player.getLocation())),
definition.id(), target == null ? null : target.getUniqueId(),
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
clock.instant());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
return null; // Auditing may never refuse a cast or change its authority.
}
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
if (cast == null) return;
try {
var settings = new LinkedHashMap<String, Object>();
definition.parameters().forEach((key, value) -> settings.put(key, value.toPlainString()));
var data = new LinkedHashMap<String, Object>();
data.put("cooldown_until", deadline);
data.put("after_revision", saved.revision().toString());
data.put("stage", "cooldown_committed");
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
data.put("captured_settings", settings);
data.put("settings_scope", "definition_at_request");
for (Stat stat : definition.requirements().keySet())
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
emit(cast, "ability.cast", "accepted", data);
} catch (RuntimeException ignored) { /* Capturing metadata cannot refuse a saved cast. */ }
}
public void resolved(Cast cast, String outcome, String reason) {
resolved(cast, outcome, reason, Map.of());
}
public void resolved(Cast cast, String outcome, String reason, Map<String, Object> evidence) {
if (cast == null) return;
var data = new LinkedHashMap<String, Object>(evidence);
data.put("reason", reason);
emit(cast, "ability.resolved", outcome, data);
}
public UUID effectApplied(Cast cast, String effectId, Entity target, Map<String, Object> evidence) {
Objects.requireNonNull(effectId);
Objects.requireNonNull(evidence);
UUID application = UUID.randomUUID();
if (cast != null) {
try {
var data = new LinkedHashMap<String, Object>(evidence);
data.put("effect_id", effectId);
data.put("application_id", application.toString());
if (target != null) {
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var place = target.getLocation();
if (place != null) data.put("target_location", locationData(place));
}
emit(cast, "effect.applied", "applied", data, application);
} catch (RuntimeException ignored) { /* An observation failure cannot interrupt an applied effect. */ }
}
return application;
}
public void acceptedDamage(Cast cast, Entity target, double requested, double actual,
Entity direct, Map<String, Object> evidence) {
if (cast == null || !Double.isFinite(requested) || !Double.isFinite(actual) || actual <= 0) return;
try {
var data = new LinkedHashMap<String, Object>(evidence);
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("requested_amount", requested);
data.put("actual_amount", actual);
data.put("target_entity_id", target.getUniqueId().toString());
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", entityType(direct));
emit(cast, "combat.damage", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
}
public void healed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter, UUID aidedSession,
CharacterRecord saved, double requested, double actual, double before, double after,
double maximum) {
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());
data.put("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("after_revision", saved.revision().toString());
data.put("requested_amount", decimal(requested));
data.put("actual_amount", decimal(actual));
data.put("health_before", decimal(before));
data.put("health_after", decimal(after));
data.put("maximum_health", decimal(maximum));
emit(cast, "combat.healed", "acknowledged", data, application);
} catch (RuntimeException ignored) { /* Healing remains authoritative despite logging failure. */ }
}
private static String decimal(double value) {
if (!Double.isFinite(value)) throw new IllegalArgumentException("Non-finite health observation");
return java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
}
public void cleansed(Cast cast, org.bukkit.entity.Player target, UUID aidedCharacter,
UUID aidedSession, java.util.List<String> removed) {
if (cast == null || removed == null || removed.isEmpty()) 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("target_player_id", target.getUniqueId().toString());
data.put("target_character_id", aidedCharacter.toString());
data.put("target_session_id", aidedSession.toString());
data.put("target_location", locationData(target.getLocation()));
data.put("self_target", aidedCharacter.equals(cast.context().characterId()));
data.put("removed_effects", java.util.List.copyOf(removed));
data.put("actual_count", Integer.toString(removed.size()));
emit(cast, "combat.cleansed", "accepted", data, application);
} 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(),
"effect_id", effectId, "reason", reason), application);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
emit(cast, type, outcome, extra, null);
}
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra, UUID application) {
try {
var data = new LinkedHashMap<String, Object>(extra);
data.put("action_kind", "ability_cast");
data.put("ability_id", cast.abilityId());
data.put("outcome", outcome);
if (cast.targetId() != null) {
data.put("target_entity_id", cast.targetId().toString());
data.put("target_type", cast.targetType());
if (!cast.targetLocation().isEmpty()) data.put("target_location", cast.targetLocation());
}
var identity = application == null ? cast.actionId() : application;
sink.accept(new EventInput(stable(identity, type), cast.actionId(),
type.equals("action.observed") ? cast.requestedAt() : clock.instant(), type, cast.context(), data));
} catch (RuntimeException ignored) { /* A writer failure cannot authorize or undo gameplay. */ }
}
private EventInput.Location location(Location value) {
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
}
private Map<String, Object> locationData(Location value) {
return Map.of("world_id", value.getWorld().getUID().toString(), "world_name", value.getWorld().getName(),
"x", value.getX(), "y", value.getY(), "z", value.getZ(), "captured_at", clock.instant().toString());
}
private static UUID stable(UUID identity, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:ability-cast:" + identity + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** Authorized ability-setting reads and acknowledged updates; no write authority. */
public final class AbilityConfigEventBridge {
public record Actor(String kind, UUID playerId, String playerName) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public AbilityConfigEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
/** Capture account identity on the command thread, never on the file worker. */
public Actor capture(CommandSender sender) {
try {
if (sender instanceof Player player) return new Actor("player", player.getUniqueId(), player.getName());
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null, null);
} catch (RuntimeException ignored) {
return null; // Omit uncertain identity, never refuse the authorized action.
}
}
public void read(Actor actor, String abilityId, String key, String value) {
emit("config.read", actor, abilityId, key, null, value);
}
public void changed(Actor actor, String abilityId, String key, String before, String after) {
emit("config.changed", actor, abilityId, key, before, after);
}
private void emit(String type, Actor actor, String abilityId, String key, String before, String after) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("actor_kind", actor.kind());
data.put("ability_id", abilityId);
data.put("key", key);
if (before != null) data.put("before", before);
data.put("after", after);
data.put("application", "future_casts_only");
data.put("outcome", "acknowledged");
var action = UUID.randomUUID();
var id = UUID.nameUUIDFromBytes(("fantasystick:ability-config:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
var context = actor.playerId() == null ? null
: new EventInput.Context(actor.playerId(), actor.playerName(), null, null, null, null);
sink.accept(new EventInput(id, action, clock.instant(), type, context, data));
} catch (RuntimeException ignored) { /* File logging cannot change an authorized configuration action. */ }
}
}
@@ -39,6 +39,12 @@ public final class EventCaptureListener implements Listener {
private final EquipmentSessions sessions;
private final Clock clock;
private final Map<Object, Captured> captured = java.util.Collections.synchronizedMap(new WeakHashMap<>());
private java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> ownedDamage = ignored -> false;
/** Feature owners replace the generic incoming view with one richer application record. */
public void excludeOwnedDamage(java.util.function.Predicate<org.bukkit.event.entity.EntityDamageEvent> owner) {
ownedDamage = ownedDamage.or(Objects.requireNonNull(owner));
}
public EventCaptureListener(LocalEventLog events, EquipmentSessions sessions, Clock clock) {
this.events = Objects.requireNonNull(events);
@@ -102,6 +108,8 @@ public final class EventCaptureListener implements Listener {
public void acceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player) || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0) return;
try { if (ownedDamage.test(event)) return; }
catch (RuntimeException ignored) { /* Observation failure cannot change native damage. */ }
var snapshot = context(player).orElse(null);
if (snapshot == null) return;
var details = new LinkedHashMap<String, Object>();
@@ -337,7 +337,11 @@ public final class LocalEventLog implements AutoCloseable {
data.put("session_id", context.sessionId().toString());
}
if (context.location() != null) data.put("location", location(context.location()));
} else data.put("actor_kind", "system");
} else {
var claimed = data.get("actor_kind");
data.put("actor_kind", "console".equals(claimed) || "command_block".equals(claimed)
? claimed : "system");
}
var envelope = new LinkedHashMap<String, Object>();
envelope.put("specversion", "1.0");
envelope.put("id", input.id().toString());
@@ -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. */ }
}
}
@@ -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) {
@@ -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) {
@@ -69,10 +69,11 @@ public final class MendingLightSettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public MendingLight.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<>();
@@ -67,10 +67,11 @@ public final class PurifySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public Purify.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) {
@@ -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) {
@@ -27,6 +27,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private volatile boolean closed;
private games.dmg.fantasystick.persistence.AdventureSettings settings;
private games.dmg.fantasystick.persistence.FireballSettings fireballSettings;
private games.dmg.fantasystick.events.AbilityConfigEventBridge abilityConfigEvents;
private games.dmg.fantasystick.ability.Fireball fireball;
private games.dmg.fantasystick.persistence.FrostShardSettings frostSettings;
private games.dmg.fantasystick.ability.FrostShard frostShard;
@@ -338,6 +339,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
public void attachGearSettings(games.dmg.fantasystick.persistence.GearSettings settings){if(closed||gearSettings!=null)throw new IllegalStateException("Gear settings already attached or command closed");gearSettings=Objects.requireNonNull(settings);}
public void attachCoinSettings(games.dmg.fantasystick.persistence.CoinSettings settings){if(closed||coinSettings!=null)throw new IllegalStateException("Coin settings already attached or command closed");coinSettings=Objects.requireNonNull(settings);}
public void attachCoinEvents(games.dmg.fantasystick.events.CoinEventBridge events){if(closed||coinEvents!=null)throw new IllegalStateException("Coin events already attached or command closed");coinEvents=Objects.requireNonNull(events);}
public void attachAbilityConfigEvents(games.dmg.fantasystick.events.AbilityConfigEventBridge events){if(closed||abilityConfigEvents!=null)throw new IllegalStateException("Ability config events already attached or command closed");abilityConfigEvents=Objects.requireNonNull(events);}
public void attachHardMobXpSettings(games.dmg.fantasystick.persistence.HardMobXpSettings settings,
games.dmg.fantasystick.party.CombatExperience experience) {
if (closed || hardMobXpSettings != null) throw new IllegalStateException("Hard-mob XP settings already attached or command closed");
@@ -1045,8 +1047,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void fireballConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = fireballSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + fireballSettings.value(key) + " (applies to future Fireball casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "fireball", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Fireball casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FireballSettings.Update> operation;
try { operation = fireballSettings.set(key, args[2]); }
@@ -1060,6 +1065,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
if (failure == null && fireballSettings.publish(saved)) {
fireball.configure(fireballSettings.settings()); abilities.replace(fireball.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "fireball", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1258,8 +1264,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = mendingLightSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + mendingLightSettings.value(key) + " (applies to future Mending Light casts only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "mending_light", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Mending Light casts only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MendingLightSettings.Update> operation;
@@ -1272,8 +1281,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition()); mendingLightSettings.publish(saved);
if (failure == null && mendingLightSettings.publish(saved)) {
mendingLight.configure(saved.settings()); abilities.replace(mendingLight.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "mending_light", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1286,8 +1296,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void purifyConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = purifySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + purifySettings.value(key) + " (applies to future Purify casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "purify", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Purify casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.PurifySettings.Update> operation;
try { operation = purifySettings.set(key, args[2]); }
@@ -1299,8 +1312,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
purify.configure(saved.settings()); abilities.replace(purify.definition()); purifySettings.publish(saved);
if (failure == null && purifySettings.publish(saved)) {
purify.configure(saved.settings()); abilities.replace(purify.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "purify", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -1313,8 +1327,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]); }
@@ -1326,8 +1343,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;
@@ -1340,8 +1358,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]); }
@@ -1353,8 +1374,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;
@@ -1367,8 +1389,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]); }
@@ -1380,12 +1405,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); }
@@ -1394,8 +1421,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]); }
@@ -1407,12 +1437,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); }
@@ -1421,8 +1453,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]); }
@@ -1434,12 +1469,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); }
@@ -1448,7 +1485,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; }
@@ -1456,9 +1498,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); }
@@ -1466,10 +1512,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) {
@@ -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)) + ".";
@@ -77,6 +77,11 @@ class AbilityEngineNativeTest {
(CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"), sessions, parties,
java.time.Clock.fixed(java.time.Instant.parse("2026-09-19T20:00:00Z"), java.time.ZoneOffset.UTC),
f.tasks::add, scheduler, reports::add, failure -> fail(failure.toString()));
var captured = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(
event -> { if (event.type().equals("action.observed")) throw new IllegalStateException("test writer unavailable");
captured.add(event); }, java.time.Clock.systemUTC()));
engine.enableCastEventsFor("area_test"); engine.enableCastEventsFor("selection_test");
var active = sessions.active(a.core.id).orElseThrow();
var hostile = org.mockito.Mockito.mock(org.bukkit.entity.LivingEntity.class);
org.mockito.Mockito.when(hostile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -85,6 +90,7 @@ class AbilityEngineNativeTest {
reports.stream().map(games.dmg.fantasystick.ability.AbilityReport::type).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, reports.size());
assertEquals(a.core.record.id(), reports.getFirst().sourceCharacter());
assertTrue(captured.isEmpty(), "Unselected real abilities and reports cannot claim event delivery");
var ordinary = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
org.mockito.Mockito.when(ordinary.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
@@ -109,11 +115,18 @@ class AbilityEngineNativeTest {
assertEquals(0, delayedEffects.get(), "Party eligibility is rechecked at delayed application");
active = sessions.active(a.core.id).orElseThrow();
cast(engine, f, a, active, 5, "selection_test", b.player);
cast(engine, f, a, active, 5, "selection_test", null);
assertEquals(1, delayed.size());
var selectionCast = captured.stream().filter(event -> event.type().equals("ability.cast")
&& event.data().get("ability_id").equals("selection_test")).findFirst().orElseThrow();
assertEquals(active.session(), selectionCast.context().sessionId());
engine.remove(a.core.record.id(), "selection_test");
delayed.removeFirst().run();
assertEquals(0, selectionEffects.get(), "Removal invalidates pending selection callbacks without clearing cooldowns");
assertEquals(1, captured.stream().filter(event -> event.type().equals("ability.resolved")
&& event.actionId().equals(selectionCast.actionId()) && event.data().get("outcome").equals("interrupted")).count());
assertTrue(captured.stream().noneMatch(event -> event.type().equals("effect.applied")),
"No-op casts and unused reports cannot invent an effect");
var stored = ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(a.core.record.id()).get().orElseThrow();
assertTrue(stored.cooldowns().containsKey("selection_test"));
@@ -138,7 +151,14 @@ class AbilityEngineNativeTest {
List.of("A registered test effect"), "Use the equipped shard", Map.of(), BigInteger.ONE, AbilityDefinition.Category.BUF,
AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE, Duration.ofSeconds(4),
Map.of("range", BigDecimal.valueOf(12)), AbilityDefinition.TargetPolicy.SELF,
execution -> { assertTrue(execution.stillCurrent()); effects.incrementAndGet(); }));
execution -> {
assertTrue(execution.stillCurrent());
effects.incrementAndGet(); // Test-defined native execution, not a real spell.
execution.effectApplied("test_focus_effect", null, Map.of("native_applications", 1));
}));
var engine = (games.dmg.fantasystick.ability.AbilityEngine)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine");
engine.enableCastEventsFor("test_focus");
var a = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(a.core.id, a.player); f.activate(a);
a.core.inventory.getInventory().setSelectedSlot(0);
@@ -201,6 +221,82 @@ class AbilityEngineNativeTest {
assertEquals(1, effects.get(), "Exit/resume cannot reset the persisted character cooldown");
assertEquals(cooled.cooldowns(), storage.find(a.core.record.id()).get().orElseThrow().cooldowns());
}
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.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());
}
for (var suffix : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"effect.applied", "effect.ended")) {
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).count(), suffix);
}
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals("test_focus", castEvent.getAsJsonObject("data").get("ability_id").getAsString());
assertTrue(castEvent.getAsJsonObject("data").has("cooldown_until"));
var applied = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.applied")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
var ended = events.stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("test_focus")).findFirst().orElseThrow();
assertEquals(applied.getAsJsonObject("data").get("application_id").getAsString(),
ended.getAsJsonObject("data").get("application_id").getAsString());
assertEquals("1", applied.getAsJsonObject("data").get("native_applications").getAsString());
assertTrue(events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.anyMatch(event -> event.getAsJsonObject("data").get("action_id").getAsString()
.equals(castEvent.getAsJsonObject("data").get("action_id").getAsString())));
}
@Test void failedCooldownSaveOfRegisteredDefinitionNeverClaimsACastOrEffect() {
var registry = new AbilityRegistry();
var effects = new AtomicInteger();
registry.register(new AbilityDefinition("test_failure", "Test Failure", Material.AMETHYST_SHARD,
List.of("A test-only ability"), "Use the control", Map.of(), BigInteger.ONE,
AbilityDefinition.Category.BUF, AbilityDefinition.Activation.IMMEDIATE, ControlInput.Gesture.USE,
Duration.ofSeconds(4), Map.of(), AbilityDefinition.TargetPolicy.SELF,
execution -> effects.incrementAndGet()));
var original = new games.dmg.fantasystick.crafting.CharacterFactory().create(
games.dmg.fantasystick.progression.ProgressionRules.defaults());
var record = new games.dmg.fantasystick.persistence.CharacterRecord(original.id(), original.name(),
original.revision(), original.stats(), original.level(), original.experience(), original.unspentPoints(),
original.health(), java.util.Set.of("test_failure"), List.of("test_failure", "", "", "", "", "", ""),
original.cooldowns(), original.restrictedKills(), false, null, null);
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Account");
org.mockito.Mockito.when(world.getUID()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
var sessions = org.mockito.Mockito.mock(games.dmg.fantasystick.session.EquipmentSessions.class);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var storage = org.mockito.Mockito.mock(CharacterStorage.class);
var failed = java.util.concurrent.CompletableFuture
.<CharacterStorage.AbilityMutation>failedFuture(new java.io.IOException("simulated save failure"));
org.mockito.Mockito.when(storage.commitAbilityCooldown(org.mockito.ArgumentMatchers.eq(record.id()),
org.mockito.ArgumentMatchers.eq(record.revision()), org.mockito.ArgumentMatchers.eq("test_failure"),
org.mockito.ArgumentMatchers.any())).thenReturn(failed);
var parties = new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(), ignored -> true);
var events = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(events::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("test_failure");
engine.accept(new ControlInput(player, active, 2, "test_failure", ControlInput.Kind.ABILITY,
ControlInput.Gesture.USE, null, 0, 0, () -> true));
assertEquals(List.of("action.observed", "ability.resolved"), events.stream().map(
games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", events.getLast().data().get("outcome"));
assertEquals(events.getFirst().actionId(), events.getLast().actionId());
assertEquals(0, effects.get());
assertEquals(1, failures.size());
engine.close(); parties.close();
}
private static AbilityDefinition definition(String id, AbilityDefinition.Activation activation,
@@ -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) {
@@ -38,6 +38,11 @@ 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")).count());
}
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
@@ -60,6 +65,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"});
@@ -128,6 +128,69 @@ class FireballNativeTest {
assertEquals(1, launches.get(), "Exit/resume and control reconstruction preserve the same Fireball deadline");
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("fireball"));
}
var events = events(data);
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
"ability.resolved", "combat.damage"))
assertEquals(1, events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).count(), suffix);
var castEvent = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
var resolution = events.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
resolution.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("hit", resolution.getAsJsonObject("data").get("outcome").getAsString());
assertEquals("12", castEvent.getAsJsonObject("data").get("captured_int").getAsString());
assertEquals("6", castEvent.getAsJsonObject("data").getAsJsonObject("captured_settings")
.get("base-damage").getAsString());
assertEquals("definition_at_request", castEvent.getAsJsonObject("data").get("settings_scope").getAsString());
assertEquals("6", resolution.getAsJsonObject("data").getAsJsonObject("effective_settings")
.get("base-damage").getAsString());
assertTrue(resolution.getAsJsonObject("data").has("direct_entity_id"));
assertTrue(resolution.getAsJsonObject("data").has("target_location"));
var damage = events.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")).findFirst().orElseThrow();
assertEquals("7.0", damage.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
damage.getAsJsonObject("data").get("action_id").getAsString());
}
@Test void acceptedFantasyPlayerHitHasOneOwnedDamageRecordNotAnIncomingDuplicate() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
var b = new FantasyStickLifecycleNativeTest.Actor(data);
withFireball(data, a, 12);
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
f.activate(a); f.activate(b);
var worldId = UUID.randomUUID();
org.mockito.Mockito.when(a.world.getUID()).thenReturn(worldId);
b.core.location.setWorld(a.world); b.core.location.setX(10); b.core.location.setY(65); b.core.location.setZ(0);
org.mockito.Mockito.doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
var arrow = projectile(a, new org.bukkit.Location(a.world, 10, 65.6, 0));
prepareLaunches(f, a, arrow);
var hits = new java.util.ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
org.mockito.Mockito.doAnswer(call -> {
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE,
(org.bukkit.damage.DamageSource) call.getArgument(1), (Double) call.getArgument(0));
f.dispatcher.accept(event); hits.add(event); return null;
}).when(b.player).damage(org.mockito.ArgumentMatchers.anyDouble(),
org.mockito.ArgumentMatchers.any(org.bukkit.damage.DamageSource.class));
var eye = new org.bukkit.Location(a.world, 0, 65.6, 0);
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
org.mockito.Mockito.doReturn(eye).when(a.player).getEyeLocation();
cast(f, a);
f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
assertEquals(1, hits.size()); assertFalse(hits.getFirst().isCancelled());
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
&& event.getAsJsonObject("data").has("target_player_id")).toList();
assertEquals(1, damage.size(), "The native incoming observer must yield to the ability owner");
assertEquals("fireball", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
assertEquals("7.0", damage.getFirst().getAsJsonObject("data").get("actual_amount").getAsString());
}
@SuppressWarnings("unchecked")
@@ -296,6 +359,14 @@ class FireballNativeTest {
assertTrue(org.mockito.Mockito.mockingDetails(a.world).getInvocations().stream()
.noneMatch(invocation -> invocation.getMethod().getName().equals("createExplosion")));
}
var observed = events(data);
assertEquals(2, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
assertEquals(0, 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("fireball")).count());
}
@Test void rangeLifetimeAndSessionEndEachRemoveOwnedProjectileWithoutLateDamage() throws Exception {
@@ -333,6 +404,16 @@ class FireballNativeTest {
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
org.mockito.Mockito.verify(exiting, org.mockito.Mockito.atLeastOnce()).remove();
}
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("fireball"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(4, outcomes.size(), outcomes.toString());
assertEquals(2, outcomes.stream().filter("miss"::equals).count());
assertTrue(outcomes.contains("expired")); assertTrue(outcomes.contains("interrupted"));
assertEquals(0, 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("fireball")).count());
}
@Test void validatedConfiguredBalanceReachesTheRegisteredDefinition() throws Exception {
@@ -351,6 +432,12 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4"), definition.parameters().get("projectile-speed"));
assertTrue(String.join(" ", definition.lore()).contains("Deals 9"));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick"});
admin.allowed = true;
assertEquals(java.util.List.of("fireball-projectile-speed-blocks-per-tick"), f.command.tabComplete(admin.port,
"fantasystick", new String[]{"config", "fireball-projectile-s"}));
assertEquals(java.util.List.of("3", "4"), f.command.tabComplete(admin.port, "fantasystick",
@@ -362,7 +449,28 @@ class FireballNativeTest {
assertEquals(new java.math.BigDecimal("4.5"), updated.parameters().get("projectile-speed"));
assertTrue(java.nio.file.Files.readString(data.resolve("config.yml"))
.contains("fireball-projectile-speed-blocks-per-tick: '4.5'"));
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(admin.port, "fstickadmin",
new String[]{"config", "fireball-projectile-speed-blocks-per-tick", "5"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("4.5", ((games.dmg.fantasystick.persistence.FireballSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "fireballSettings"))
.value("fireball-projectile-speed-blocks-per-tick"));
assertTrue(admin.messages.getLast().contains("save failed"));
}
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().equals("fireball-projectile-speed-blocks-per-tick")).toList();
assertEquals(2, config.size());
assertTrue(config.getFirst().get("type").getAsString().endsWith("config.read"));
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("4.5", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
}
@Test void liveSpeedUpdateAppliesOnlyToFutureCastsAndRetainsActiveArrow() throws Exception {
@@ -473,6 +581,12 @@ class FireballNativeTest {
var active = new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),
java.util.UUID.randomUUID(), record.id());
var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class);
var world = org.mockito.Mockito.mock(org.bukkit.World.class);
org.mockito.Mockito.when(world.getUID()).thenReturn(UUID.randomUUID());
org.mockito.Mockito.when(world.getName()).thenReturn("world");
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(active.player());
org.mockito.Mockito.when(player.getName()).thenReturn("Caster");
org.mockito.Mockito.when(player.getLocation()).thenReturn(new org.bukkit.Location(world, 1, 64, 2));
org.mockito.Mockito.when(sessions.current(active)).thenReturn(true);
org.mockito.Mockito.when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));
var failed = new java.util.concurrent.CompletableFuture<games.dmg.fantasystick.persistence.CharacterStorage.AbilityMutation>();
@@ -483,6 +597,10 @@ class FireballNativeTest {
var failures = new java.util.ArrayList<games.dmg.fantasystick.ability.AbilityEngine.Failure>();
var engine = new games.dmg.fantasystick.ability.AbilityEngine(registry, storage, sessions, parties,
java.time.Clock.systemUTC(), Runnable::run, (delay, action) -> () -> {}, ignored -> {}, failures::add);
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
engine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(audit::add,
java.time.Clock.systemUTC()));
engine.enableCastEventsFor("fireball");
engine.accept(new games.dmg.fantasystick.session.ControlInput(player, active, 2, "fireball",
games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,
games.dmg.fantasystick.session.ControlInput.Gesture.USE, null, 0, 0, () -> true));
@@ -491,6 +609,10 @@ class FireballNativeTest {
org.mockito.Mockito.verify(sessions, org.mockito.Mockito.never()).acknowledgeProgress(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertEquals(1, failures.size());
assertEquals(java.util.List.of("action.observed", "ability.resolved"),
audit.stream().map(games.dmg.fantasystick.events.EventInput::type).toList());
assertEquals("failed", audit.getLast().data().get("outcome"));
assertEquals(audit.getFirst().actionId(), audit.getLast().actionId());
engine.close(); fireball.close(); parties.close();
}
@@ -527,6 +649,16 @@ class FireballNativeTest {
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
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.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());
}
return events;
}
private static void await(java.util.concurrent.atomic.AtomicInteger value, int expected) throws Exception {
for (int attempt = 0; attempt < 500 && value.get() < expected; attempt++) Thread.sleep(10);
assertEquals(expected, value.get());
@@ -602,6 +734,7 @@ class FireballNativeTest {
org.bukkit.Location location) {
var projectile = org.mockito.Mockito.mock(org.bukkit.entity.Arrow.class);
org.mockito.Mockito.when(projectile.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
org.mockito.Mockito.when(projectile.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
org.mockito.Mockito.when(projectile.getShooter()).thenReturn(actor.player);
org.mockito.Mockito.when(projectile.getLocation()).thenAnswer(ignored -> location.clone());
org.mockito.Mockito.when(projectile.getWorld()).thenReturn(location.getWorld());
@@ -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); }
@@ -88,6 +88,45 @@ class MendingLightNativeTest {
resumed.core.inventory.setItemInOffHand(allySheet); fixture.players.put(resumed.core.id, resumed.player); fixture.activate(resumed);
assertEquals(10, resumed.player.getHealth(), "Healed health survives exit, transfer and resume");
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.healed"))
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("mending_light")).count(), type);
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
var healed = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).findFirst().orElseThrow();
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
healed.getAsJsonObject("data").get("action_id").getAsString());
assertEquals("6", healed.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("4", healed.getAsJsonObject("data").get("health_before").getAsString());
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 {
@@ -124,6 +163,16 @@ class MendingLightNativeTest {
}
}
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 selfOrdinaryNonPartyFullHealthRangeAndLineOfSightRefuseBeforeCooldown() throws Exception {
for (int route = 0; route < 6; route++) {
var directory = data.resolve("refusal-" + route);
@@ -145,6 +194,9 @@ class MendingLightNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow()
.cooldowns().containsKey("mending_light"), "route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
}
}
@@ -166,6 +218,14 @@ class MendingLightNativeTest {
assertEquals(4, ally.player.getHealth());
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("mending_light"));
}
var observed = events(data);
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void targetSaveFailureOrSessionReplacementPublishesNoNativeHealingOrSupport() {
@@ -211,6 +271,8 @@ class MendingLightNativeTest {
verify(target, never()).setHealth(anyDouble()); assertTrue(supports.isEmpty());
verify(sessions, never()).acknowledgeProgress(eq(targetActive), any(), any());
verify(execution, times(2)).finish();
verify(execution, never()).acceptedHeal(any(), any(), any(), anyDouble(), anyDouble(),
anyDouble(), anyDouble(), anyDouble());
}
@Test void configuredDefinitionAdminRestartAndFutureCastCoverEverySetting() throws Exception {
@@ -243,6 +305,19 @@ class MendingLightNativeTest {
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, 15, true);
use(fixture, caster); drain(fixture, 3); assertEquals(10, ally.player.getHealth());
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", "mending-light-base-healing", "10"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("9", ((MendingLightSettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "mendingLightSettings"))
.value("mending-light-base-healing"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "-1"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "mending-light-base-healing", "9", "extra"});
@@ -252,6 +327,15 @@ class MendingLightNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(new BigDecimal("9"), reloaded.find("mending_light").orElseThrow().parameters().get("base-healing"));
}
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().equals("mending-light-base-healing")).toList();
assertEquals(2, config.size());
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("8", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
assertEquals("console", config.getLast().getAsJsonObject("data").get("actor_kind").getAsString());
for (String invalid : List.of("mending-light-base-healing: -1\n", "mending-light-cooldown-seconds: -1\n",
"mending-light-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
@@ -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);}
@@ -73,6 +73,18 @@ class PurifyNativeTest {
learnAndAssign(fixture, actor); use(fixture, actor);
assertTrue(actor.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var recorded = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved", "combat.cleansed"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
recorded.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count(), type);
var cleansed = recorded.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).findFirst().orElseThrow();
var names = new java.util.HashSet<String>();
cleansed.getAsJsonObject("data").getAsJsonArray("removed_effects").forEach(value -> names.add(value.getAsString()));
assertEquals(java.util.Set.of("POISON", "WITHER", "SLOWNESS", "WEAKNESS"), names);
assertEquals("4", cleansed.getAsJsonObject("data").get("actual_count").getAsString());
}
@Test void aimedAllyActualCleanseCreatesPersistedSupportShareThatFollowsSourceCharacter() throws Exception {
@@ -102,6 +114,23 @@ class PurifyNativeTest {
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
assertTrue(holder.messages.stream().anyMatch(message -> message.contains("5") && message.contains("XP")));
}
var allyCleanse = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).toList();
assertEquals(1, allyCleanse.size());
assertEquals("POISON", allyCleanse.getFirst().getAsJsonObject("data")
.getAsJsonArray("removed_effects").get(0).getAsString());
assertFalse(allyCleanse.getFirst().getAsJsonObject("data").get("self_target").getAsBoolean());
}
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 normalSelfOrdinaryNonPartyOutOfRangeObstructedAndNoOpTargetsRefuseBeforeCooldown() throws Exception {
@@ -125,6 +154,9 @@ class PurifyNativeTest {
assertFalse(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("purify"),
"route " + route + " must refuse before cooldown");
}
assertEquals(0, events(directory).stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
}
}
@@ -145,6 +177,14 @@ class PurifyNativeTest {
assertTrue(effects.containsKey(PotionEffectType.POISON));
assertNotNull(storage(fixture).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("purify"));
}
var records = events(data);
assertEquals(0, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.cleansed")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")).count());
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("purify")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void selfCleanseNeverEmitsSupportEvidence() {
@@ -196,6 +236,19 @@ class PurifyNativeTest {
effects(fixture, ally.player).put(PotionEffectType.SLOWNESS, instance(PotionEffectType.SLOWNESS, 200, 0));
doReturn(false).when(caster.player).isSneaking(); use(fixture, caster); drain(fixture, 2);
assertNull(ally.player.getPotionEffect(PotionEffectType.SLOWNESS));
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", "purify-range-blocks", "21"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("20", ((games.dmg.fantasystick.persistence.PurifySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "purifySettings"))
.value("purify-range-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "purify-range-blocks", "20", "extra"});
@@ -205,6 +258,14 @@ class PurifyNativeTest {
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "abilityRegistry");
assertEquals(0, new BigDecimal("20").compareTo(reloaded.find("purify").orElseThrow().parameters().get("range")));
}
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().equals("purify-range-blocks")).toList();
assertEquals(2, config.size());
assertEquals("16", config.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals("16", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("20", config.getLast().getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("purify-cooldown-seconds: -1\n", "purify-range-blocks: 0\n")) {
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
java.nio.file.Files.writeString(directory.resolve("config.yml"), invalid);
@@ -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")) {
@@ -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));}
@@ -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();}}
@@ -0,0 +1,61 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityCastEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void explicitEffectEvidenceCannotReplaceTheOwnedAbilityIdentityOrOutcome() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var action = UUID.randomUUID();
var cast = new AbilityCastEventBridge.Cast(action,
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = bridge.effectApplied(cast, "test_effect", null,
Map.of("ability_id", "forged", "outcome", "forged", "action_kind", "forged", "actual_amount", 2));
bridge.effectEnded(cast, application, "test_effect", "expired");
assertEquals(2, records.size());
var applied = records.getFirst();
assertEquals("test_focus", applied.data().get("ability_id"));
assertEquals("applied", applied.data().get("outcome"));
assertEquals("ability_cast", applied.data().get("action_kind"));
assertEquals(2, applied.data().get("actual_amount"));
assertEquals(application.toString(), applied.data().get("application_id"));
assertEquals(application.toString(), records.getLast().data().get("application_id"));
assertNotEquals(applied.id(), records.getLast().id());
assertEquals(action, applied.actionId());
assertEquals(action, records.getLast().actionId());
}
@Test void ownerCanResolvePriorNativeCancellationWithoutInventingACastOrEffect() {
var records = new ArrayList<EventInput>();
var bridge = new AbilityCastEventBridge(records::add, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
bridge.resolved(cast, "cancelled", "prior_native_cancellation");
assertEquals(1, records.size());
assertEquals("ability.resolved", records.getFirst().type());
assertEquals("cancelled", records.getFirst().data().get("outcome"));
assertEquals("prior_native_cancellation", records.getFirst().data().get("reason"));
}
@Test void aBrokenSinkCannotChangeAnEffectApplicationOrResolution() {
var bridge = new AbilityCastEventBridge(event -> { throw new IllegalStateException("writer unavailable"); }, CLOCK);
var cast = new AbilityCastEventBridge.Cast(UUID.randomUUID(),
new EventInput.Context(UUID.randomUUID(), "Account", UUID.randomUUID(), "Hero", UUID.randomUUID(), null),
"test_focus", null, null, Map.of(), CLOCK.instant());
var application = assertDoesNotThrow(() -> bridge.effectApplied(cast, "native_effect", null, Map.of("actual_amount", 1)));
assertDoesNotThrow(() -> bridge.effectEnded(cast, application, "native_effect", "expired"));
assertDoesNotThrow(() -> bridge.resolved(cast, "completed", "effect_finished_without_hit_claim"));
}
}
@@ -0,0 +1,52 @@
package games.dmg.fantasystick.events;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class AbilityConfigEventBridgeTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-25T04:00:00Z"), ZoneOffset.UTC);
@Test void authorizedReadAndAcknowledgedChangeHaveDistinctTypedRecords() {
var events = new ArrayList<EventInput>();
var bridge = new AbilityConfigEventBridge(events::add, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("console", null, null);
bridge.read(actor, "fireball", "fireball-cooldown-seconds", "1.2");
bridge.changed(actor, "fireball", "fireball-cooldown-seconds", "1.2", "2");
assertEquals(2, events.size());
assertEquals("config.read", events.getFirst().type());
assertEquals("config.changed", events.getLast().type());
assertNotEquals(events.getFirst().id(), events.getLast().id());
assertEquals("fireball", events.getLast().data().get("ability_id"));
assertEquals("1.2", events.getLast().data().get("before"));
assertEquals("2", events.getLast().data().get("after"));
assertEquals("future_casts_only", events.getLast().data().get("application"));
assertNull(events.getLast().context(), "Console is not a fictional player or character");
}
@Test void identityCaptureFailureCannotBlockAnAuthorizedReadOrSave() {
var bridge = new AbilityConfigEventBridge(event -> fail("No unsupported identity may be emitted"), CLOCK);
var player = (org.bukkit.entity.Player) java.lang.reflect.Proxy.newProxyInstance(
org.bukkit.entity.Player.class.getClassLoader(), new Class<?>[]{org.bukkit.entity.Player.class},
(proxy, method, args) -> {
if (method.getName().equals("getUniqueId")) throw new IllegalStateException("identity unavailable");
return null;
});
var actor = assertDoesNotThrow(() -> bridge.capture(player));
assertNull(actor);
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
@Test void aBrokenLogSinkCannotRefuseAnAlreadyAuthorizedConfigurationAction() {
var bridge = new AbilityConfigEventBridge(event -> { throw new IllegalStateException("writer down"); }, CLOCK);
var actor = new AbilityConfigEventBridge.Actor("player", UUID.randomUUID(), "Admin");
assertDoesNotThrow(() -> bridge.read(actor, "fireball", "fireball-range-blocks", "32"));
assertDoesNotThrow(() -> bridge.changed(actor, "fireball", "fireball-range-blocks", "32", "40"));
}
}
@@ -59,6 +59,27 @@ final class LocalEventLogTest {
}
}
@Test void preservesRealNonPlayerAdminActorWithoutAllowingAContextFreePlayerImpersonation() throws Exception {
var clock = Clock.fixed(Instant.parse("2026-09-25T05:00:00Z"), ZoneOffset.UTC);
try (var events = LocalEventLog.open(data, clock,
new LocalEventLog.RuntimeInfo("Season 4", "26.2", "test"), 8, 134_217_728L,
message -> fail(message))) {
events.ready().toCompletableFuture().get();
for (var actor : java.util.List.of("console", "command_block", "player")) {
var action = UUID.randomUUID();
assertEquals(LocalEventLog.Delivery.EXPORTED,
events.emit(new EventInput(action, action, clock.instant(), "config.read", null,
Map.of("actor_kind", actor, "key", actor))).toCompletableFuture().get());
}
}
var lines = jsonLines(data.resolve("events"));
for (var actor : java.util.List.of("console", "command_block"))
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"" + actor + "\"")
&& line.contains("\"actor_kind\":\"" + actor + "\"")));
assertTrue(java.util.Arrays.stream(lines).anyMatch(line -> line.contains("\"key\":\"player\"")
&& line.contains("\"actor_kind\":\"system\"")));
}
@Test void pendingEvidenceAloneReportsRecoveryWithoutInventingPriorShutdown() throws Exception {
var root=data.resolve("events");Files.createDirectories(root.resolve("pending"));var server=UUID.randomUUID();Files.writeString(root.resolve("server-id"),server+"\n");var event=UUID.randomUUID();var exact="{\"specversion\":\"1.0\",\"id\":\""+event+"\",\"source\":\"urn:minecraft-server:"+server+"\",\"type\":\"games.dmg.minecraft.fantasystick.action.observed\",\"time\":\"2026-09-24T20:00:00Z\",\"datacontenttype\":\"application/json\",\"data\":{\"schema_version\":1}}\n";Files.writeString(root.resolve("pending/"+event+".pending"),exact);
try(var recovered=LocalEventLog.open(data,Clock.fixed(Instant.parse("2026-09-25T00:00:00Z"),ZoneOffset.UTC),new LocalEventLog.RuntimeInfo("Season 4","26.2","test"),8,134_217_728L,message->fail(message))){assertEquals(LocalEventLog.Delivery.EXPORTED,recovered.ready().toCompletableFuture().get());}
@@ -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));
}
}