feat(events): add opt-in ability cast event hooks
Release / release (push) Successful in 14m37s

This commit is contained in:
dmg committed 2026-09-25 01:16:43 -04:00
1 parent 22d92b5bbf
commit dd75e6a76f
7 files changed
+369 -19

No files matched your search

@@ -717,6 +717,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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()));
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);
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,17 @@ 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 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 +400,35 @@ 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 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) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) outcome = result;
}
@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);
AbilityEngine.this.resolve(audit, outcome, outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended");
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,12 @@ 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);
/** 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 finish();
@Override void close();
}
@@ -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,122 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.ability.AbilityDefinition;
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.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) {
if (cast == null) return;
emit(cast, "ability.cast", "accepted", Map.of("cooldown_until", deadline,
"after_revision", saved.revision().toString(), "stage", "cooldown_committed"));
}
public void resolved(Cast cast, String outcome, String reason) {
if (cast == null) return;
emit(cast, "ability.resolved", outcome, Map.of("reason", reason));
}
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 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));
}
}
@@ -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,
@@ -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"));
}
}