Compare commits

...
4 Commits
Author SHA1 Message Date
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
21 changed files with 1030 additions and 57 deletions

No files matched your search

@@ -712,11 +712,17 @@ 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());
eventCapture.excludeOwnedDamage(fireball::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");
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);
@@ -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,62 @@ 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 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,24 @@ 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);
/** 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();
}
@@ -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;
}
}
@@ -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(); }
@@ -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())) {
@@ -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,209 @@
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", target.getType().name());
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", target.getType().name());
var targetAtApplication = target.getLocation();
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
data.put("direct_entity_id", direct.getUniqueId().toString());
data.put("direct_entity_type", direct.getType().name());
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 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());
@@ -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) {
@@ -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) {
@@ -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;
@@ -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,
@@ -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());
@@ -88,6 +88,21 @@ 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 actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
@@ -124,6 +139,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 +170,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 +194,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 +247,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 +281,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 +303,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);
@@ -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);
@@ -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());}