feat(events): record Mending Light healing outcomes
Release / release (push) Successful in 8m27s

This commit is contained in:
dmg committed 2026-09-25 01:51:17 -04:00
1 parent 9674a8e5e6
commit 1d0cdc8f0f
8 files changed
+144 -17

No files matched your search

@@ -720,6 +720,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
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");
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);
@@ -421,6 +421,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
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 effectEnded(UUID application, String reason) {
Objects.requireNonNull(application); Objects.requireNonNull(reason);
var effectId = observedEffects.remove(application);
@@ -428,7 +435,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
}
@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").contains(result))
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)); }
}
@@ -28,6 +28,11 @@ public interface AbilityExecution extends AutoCloseable {
/** 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);
/** 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. */
@@ -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(); }
@@ -37,7 +37,7 @@ public final class AbilityCastEventBridge {
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(),
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"));
@@ -118,6 +118,34 @@ public final class AbilityCastEventBridge {
} 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 effectEnded(Cast cast, UUID application, String effectId, String reason) {
if (cast == null) return;
emit(cast, "effect.ended", "ended", Map.of("application_id", application.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) {
@@ -1264,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;
@@ -1278,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;
@@ -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);