feat(events): record Purify cleanse outcomes
Release / release (push) Successful in 13m17s

This commit is contained in:
dmg committed 2026-09-25 02:01:08 -04:00
1 parent 1d0cdc8f0f
commit 08d13d2192
8 files changed
+109 -10

No files matched your search

@@ -721,6 +721,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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);
@@ -428,6 +428,10 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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);
@@ -33,6 +33,9 @@ public interface AbilityExecution extends AutoCloseable {
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. */
@@ -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())) {
@@ -146,6 +146,25 @@ public final class AbilityCastEventBridge {
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(),
@@ -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) {
@@ -1296,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]); }
@@ -1309,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;
@@ -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);