feat(events): record Rallying Cry boost outcomes
Release / release (push) Successful in 13m10s

This commit is contained in:
dmg committed 2026-09-25 02:28:10 -04:00
1 parent 3163a491a0
commit e3bc854481
9 files changed
+151 -15

No files matched your search

@@ -724,6 +724,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
abilityEngine.enableCastEventsFor("rallying_cry");
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);
@@ -439,6 +439,12 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
incoming, actual, remaining, bondApplication);
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
if (castEvents != null && audit != null)
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
Objects.requireNonNull(application); Objects.requireNonNull(reason);
var effectId = observedEffects.remove(application);
@@ -40,6 +40,10 @@ public interface AbilityExecution extends AutoCloseable {
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
org.bukkit.entity.Player ally, Entity attacker, double incoming,
double actual, double remaining, UUID bondApplication);
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
void effectEnded(UUID applicationId, String reason);
/** An owner may specify a truthful terminal outcome before finishing. */
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
}
}
private record Used(Grant grant, Entity target, BigDecimal amount) { }
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
private final EquipmentSessions sessions;
private final PartyRoster parties;
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
}
@EventHandler(priority = EventPriority.MONITOR)
public void accepted(EntityDamageEvent event) {
var used = pending.remove(event);
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
if (!Double.isFinite(effective) || effective <= 0) return;
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
used.base, effective, event.getFinalDamage(), used.grant.percent);
var actual = BigDecimal.valueOf(effective);
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
}
private static Entity attacker(EntityDamageByEntityEvent event) {
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
private final class Area implements AutoCloseable {
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
UUID application; String endReason = "interrupted"; boolean closed;
Area(AbilityExecution execution, Settings settings) {
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
}
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; areas.remove(sequence, this);
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
if (application != null) execution.effectEnded(application, endReason);
}
}
private final class Benefit implements AutoCloseable {
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
final UUID application; boolean closed;
Benefit(Area area, Player player) {
this.area = area; this.player = player;
speed = speed(player, area.settings.speedAmplifier());
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
var evidence = new LinkedHashMap<String, Object>();
evidence.put("speed_amplifier", area.settings.speedAmplifier());
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
if (active != null) {
evidence.put("recipient_character_id", active.character().toString());
evidence.put("recipient_session_id", active.session().toString());
}
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
}
@Override public void close() {
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
closeResource(bonus); closeResource(speed);
if (application != null) area.execution.effectEnded(application,
area.closed ? area.endReason : "recipient_eligibility_changed");
}
}
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
execution.own(area);
area.application = execution.effectApplied("rallying_cry_area", null,
Map.of("radius", Double.toString(area.settings.radius()),
"duration_seconds", seconds(area.settings.duration())));
area.refresh(); execution.later(area.settings.duration(), () -> {
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
});
}
private AutoCloseable speed(Player player, int amplifier) {
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
}
private void breakArea(Area area) { area.close(); area.execution.finish(); }
private void breakArea(Area area) {
area.endReason = "eligibility_changed"; area.close();
area.execution.resolve("interrupted"); area.execution.finish();
}
private static boolean inside(Player source, Player target, double radius) {
try { var a = source.getLocation(); var b = target.getLocation();
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
for (var area : List.copyOf(areas.values())) {
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
}
areas.clear();
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
speeds.clear();
}
@@ -188,6 +188,34 @@ public final class AbilityCastEventBridge {
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|| !Double.isFinite(bonus) || bonus <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
var application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("causation_id", cast.actionId().toString());
data.put("attacker_player_id", attacker.getUniqueId().toString());
if (attacking != null) {
data.put("attacker_character_id", attacking.character().toString());
data.put("attacker_session_id", attacking.session().toString());
}
data.put("target_entity_id", target.getUniqueId().toString());
data.put("target_type", entityType(target));
var location = target.getLocation();
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
data.put("base_amount", decimal(base));
data.put("actual_amount", decimal(bonus));
data.put("final_amount", decimal(finalDamage));
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
data.put("selection", "strongest_applicable_only");
emit(cast, "combat.boosted", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
}
private static String entityType(Entity entity) {
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
var type = entity.getType();
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
public String value(String key) { return effectiveValues.get(key); }
public RallyingCry.Settings settings() { return parse(effectiveValues); }
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values()); effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1358,8 +1358,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = rallyingCrySettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
try { operation = rallyingCrySettings.set(key, args[2]); }
@@ -1371,8 +1374,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
if (failure == null && rallyingCrySettings.publish(saved)) {
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -70,6 +70,16 @@ class RallyingCryNativeTest {
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void veto(EntityDamageByEntityEvent event) {
if (event.getEntity() == enemy) event.setCancelled(true);
}
}
fixture.dispatcher.register(new Veto(), fixture.plugin);
var cancelled = new EntityDamageByEntityEvent(ally.player, enemy,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
fixture.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
@@ -85,6 +95,31 @@ class RallyingCryNativeTest {
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var records = events(data);
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
"combat.boosted"))
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
for (var type : List.of("effect.applied", "effect.ended"))
assertEquals(3, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
&& event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
var boosted = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.boosted")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).findFirst().orElseThrow();
assertEquals("2", boosted.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals("12", boosted.getAsJsonObject("data").get("final_amount").getAsString());
}
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
var values = new ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(file))
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
return values;
}
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
@@ -199,6 +234,19 @@ class RallyingCryNativeTest {
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
assertEquals(14, future.getDamage(), 1.0e-9);
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
fixture.command.execute(sender.port, "fstickadmin",
new String[]{"config", "rallying-cry-radius-blocks", "13"});
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
assertNotNull(failed); failed.run();
}
assertEquals("12", ((games.dmg.fantasystick.persistence.RallyingCrySettings)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "rallyingCrySettings"))
.value("rallying-cry-radius-blocks"));
assertTrue(sender.messages.getLast().contains("save failed"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
assertTrue(sender.messages.getLast().contains("invalid"));
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
@@ -211,6 +259,17 @@ class RallyingCryNativeTest {
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
}
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))
.filter(event -> event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("rallying-cry-")).toList();
assertEquals(6, config.size());
assertEquals("10", config.getFirst().getAsJsonObject("data").get("after").getAsString());
var radius = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
&& event.getAsJsonObject("data").get("key").getAsString().equals("rallying-cry-radius-blocks"))
.findFirst().orElseThrow();
assertEquals("10", radius.getAsJsonObject("data").get("before").getAsString());
assertEquals("12", radius.getAsJsonObject("data").get("after").getAsString());
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
"rallying-cry-damage-bonus-percent: 101\n")) {