feat(recovery): export acknowledged pulses and config events
Release / release (push) Failing after 9m29s

This commit is contained in:
dmg committed 2026-09-26 15:35:56 -04:00
1 parent 9425103745
commit 71839364a2
6 files changed
+546 -47

No files matched your search

@@ -173,6 +173,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.events.AbilityEventBridge abilityEvents;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private java.util.concurrent.CompletionStage<Void> recoveryAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.ReviveEventBridge reviveEvents;
private games.dmg.fantasystick.events.AdminRevivalEventBridge adminRevivalEvents;
private java.util.concurrent.CompletionStage<Void> adminRevivalAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
@@ -792,7 +793,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("meteor");
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);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
outOfCombatRecovery.attachEvents(recoveryEvents,
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
? quickerRegeneration.bonus() : java.math.BigInteger.ZERO,
record -> record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)
&& record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationII.ID)
? quickerRegenerationII.bonus() : java.math.BigInteger.ZERO);
admin.attachRecoveryEvents(recoveryEvents);
reviveEvents = new games.dmg.fantasystick.events.ReviveEventBridge(eventLog::emit, Clock.systemUTC());revive.attachEvents(reviveEvents);
adminRevivalEvents = new games.dmg.fantasystick.events.AdminRevivalEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
@@ -968,6 +978,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
if (replayGrantEvents != null) replayGrantEvents.replay();
});
var replayRecoveryEvents = recoveryEvents;
recoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
if (replayRecoveryEvents != null) replayRecoveryEvents.replay();
});
var replaySwitchConfigEvents = switchConfigEvents;
switchConfigAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous switch configuration intent drain failed", failure);
@@ -1043,11 +1058,12 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
var closingEquipmentAuditEvents = equipmentAuditEvents; equipmentAuditEvents = null;
var closingSessionRecoveryAuditEvents = sessionRecoveryAuditEvents; sessionRecoveryAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
var closingBooks = books; books = null;
var closingPartyEvents = partyEvents; partyEvents = null;
var closingPartyCombat = partyCombat; partyCombat = null;
@@ -1404,6 +1420,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
var settingsDrained = closingOutOfCombatRecoverySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
: closingOutOfCombatRecoverySettings.drained();
recoveryAuditDrain = settingsDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Recovery setting drain failed", failure);
closingRecoveryEvents.close();
return null;
}).thenCompose(ignored -> closingRecoveryEvents.drained());
});
if (closingSwitchConfigEvents != null) cleanup("switch configuration event intent drain", () -> {
var settingsDrained = closingSwitchDelaySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -1,35 +1,191 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
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.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.command.BlockCommandSender;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
/** One authoritative out-of-combat healing publication with bounded passive attribution. */
public final class RecoveryEventBridge {
public record Action(UUID actionId,EventInput.Context context,BigInteger expectedRevision,
BigDecimal healthBefore,BigDecimal proposed,BigInteger configuredBase,
BigInteger configuredQuickerRegeneration){
public Action{Objects.requireNonNull(actionId);Objects.requireNonNull(context);Objects.requireNonNull(expectedRevision);Objects.requireNonNull(healthBefore);Objects.requireNonNull(proposed);Objects.requireNonNull(configuredBase);Objects.requireNonNull(configuredQuickerRegeneration);}
/** Recovery observation is never itself a heal; only native and persisted publication owns combat.healed. */
public final class RecoveryEventBridge implements AutoCloseable {
public record Action(UUID actionId, EventInput.Context context, BigInteger expectedRevision,
BigDecimal healthBefore, BigDecimal proposed, BigInteger configuredBase,
BigInteger configuredQuickerRegeneration, BigInteger configuredQuickerRegenerationII,
Instant lastActivity, Instant eligibleAt,
BigDecimal maximumHealth, long pulseOrdinal) { }
public record Actor(String kind, EventInput.Context context) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
private final EquipmentSessions sessions;
/** Native/unit-only observer; does not require an export outbox. */
public RecoveryEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock);
outbox = null; sessions = null;
}
private final Consumer<EventInput> sink;private final Clock clock;
public RecoveryEventBridge(Consumer<EventInput> sink,Clock clock){this.sink=Objects.requireNonNull(sink);this.clock=Objects.requireNonNull(clock);}
public Action begin(Player player,EquipmentSessions.Active active,CharacterRecord record,double current,double proposed,BigInteger base,BigInteger bonus){var action=new Action(UUID.randomUUID(),context(player,active,record.name()),record.revision(),BigDecimal.valueOf(current),BigDecimal.valueOf(proposed),base,bonus);emit("action.observed",action,"observed",null,null,null,null,null);return action;}
public void refused(Action action,String reason){emit("action.resolved",action,"refused",reason,null,null,null,null);}
public void failed(Action action,String reason){emit("action.resolved",action,"failed",reason,null,null,null,null);}
public void healed(Action action,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){emit("action.resolved",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);emit("combat.healed",action,"accepted","acknowledged",healthAfter,acceptedTotal,baseContribution,passiveContribution);}
private void emit(String type,Action action,String outcome,String reason,BigDecimal healthAfter,BigDecimal acceptedTotal,BigDecimal baseContribution,BigDecimal passiveContribution){var data=new LinkedHashMap<String,Object>();data.put("action_kind","out_of_combat_recovery");data.put("recovery_kind","out_of_combat");data.put("cause","recovery_timer");data.put("expected_revision",action.expectedRevision().toString());data.put("health_before",action.healthBefore().toPlainString());data.put("proposed_total",action.proposed().toPlainString());data.put("configured_base",action.configuredBase().toString());data.put("configured_quicker_regeneration_i",action.configuredQuickerRegeneration().toString());data.put("outcome",outcome);if(action.context().location()==null)data.put("location_status","unavailable");if(healthAfter!=null)data.put("health_after",healthAfter.toPlainString());if(acceptedTotal!=null){data.put("accepted_total",acceptedTotal.toPlainString());data.put("base_contribution",baseContribution.toPlainString());if(passiveContribution.signum()>0)data.put("passive_contributions",Map.of("quicker_regeneration_i",passiveContribution.toPlainString()));data.put("native_publication","acknowledged");}if(reason!=null)data.put(type.equals("combat.healed")?"native_boundary":"reason",reason);try{sink.accept(new EventInput(id(action.actionId(),type),action.actionId(),clock.instant(),type,action.context(),Map.copyOf(data)));}catch(RuntimeException ignored){}
/** Registered plugin path retains acknowledged pulses and policy changes for writer restart. */
public RecoveryEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
Path data, EquipmentSessions sessions, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event);
this.clock = Objects.requireNonNull(clock);
this.sessions = Objects.requireNonNull(sessions);
outbox = new FeatureEventOutbox(data, "out-of-combat-recovery-event-intents",
java.util.Set.of("combat.healed", "config.changed"), sink, failures);
}
private EventInput.Context context(Player player,EquipmentSessions.Active active,String name){EventInput.Location captured;try{captured=location(player.getLocation());}catch(RuntimeException unavailable){captured=null;}return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),captured);}
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 static UUID id(UUID action,String type){return UUID.nameUUIDFromBytes(("fantasystick:recovery:"+action+":"+type).getBytes(StandardCharsets.UTF_8));}
public Actor actor(CommandSender sender) {
if (sender instanceof Player player) {
try {
var active = sessions.active(player.getUniqueId()).orElse(null);
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
return new Actor("player", new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : active.session(), location(player)));
} catch (RuntimeException unavailable) { return null; }
}
return new Actor(sender instanceof BlockCommandSender ? "command_block" : "console", null);
}
public void configRead(Actor actor, String key, String effective) {
if (actor == null) return;
var values = config(actor, key);
values.put("effective", effective);
values.put("outcome", "acknowledged_read");
var operation = UUID.randomUUID();
try { sink.accept(new EventInput(id(operation, "config.read"), operation, clock.instant(),
"config.read", actor.context(), values)); }
catch (RuntimeException ignored) { /* Audit reads cannot change recovery. */ }
}
public void configChanged(Actor actor, UUID operation, String key, String before, String effective,
OutOfCombatRecoverySettings.Update saved) {
if (actor == null || outbox == null) return;
try {
var values = config(actor, key);
values.put("operation_id", operation.toString());
values.put("policy_revision", Long.toString(saved.sequence()));
values.put("before", before);
values.put("after", saved.value());
values.put("effective", effective);
values.put("outcome", "acknowledged_future_evaluations");
outbox.accepted(new EventInput(id(operation, "config.changed"), operation, clock.instant(),
"config.changed", actor.context(), values));
} catch (RuntimeException ignored) { /* An audit cannot reconfigure or reset the damage clock. */ }
}
private static Map<String, Object> config(Actor actor, String key) {
var values = new LinkedHashMap<String, Object>();
values.put("key", key);
values.put("default", OutOfCombatRecoverySettings.defaultValue(key));
values.put("application_scope", "future_recovery_evaluations");
values.put("activity_timestamp_retained", true);
values.put("actor_kind", actor.kind());
if (actor.context() == null) values.put("location_status", "not_applicable");
else if (actor.context().location() == null) values.put("location_status", "unavailable");
return values;
}
public Action begin(Player player, EquipmentSessions.Active active, CharacterRecord record,
double current, double proposed, BigInteger base, BigInteger bonus, BigInteger bonusII,
Instant lastActivity, Instant eligibleAt, double maximum, long pulseOrdinal) {
var action = new Action(UUID.randomUUID(), context(player, active, record.name()), record.revision(),
BigDecimal.valueOf(current), BigDecimal.valueOf(proposed), base, bonus, bonusII, lastActivity,
eligibleAt, BigDecimal.valueOf(maximum), pulseOrdinal);
emit("action.observed", action, "observed", null, null, null, null, null, null);
return action;
}
public void refused(Action action, String reason) {
emit("action.resolved", action, "refused", reason, null, null, null, null, null);
}
public void failed(Action action, String reason) {
emit("action.resolved", action, "failed", reason, null, null, null, null, null);
}
public void healed(Action action, BigDecimal healthAfter, BigDecimal acceptedTotal,
BigDecimal baseContribution, BigDecimal quickerContribution, BigDecimal quickerIIContribution) {
emit("action.resolved", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
baseContribution, quickerContribution, quickerIIContribution);
emit("combat.healed", action, "accepted", "acknowledged", healthAfter, acceptedTotal,
baseContribution, quickerContribution, quickerIIContribution);
}
private void emit(String type, Action action, String outcome, String reason, BigDecimal healthAfter,
BigDecimal acceptedTotal, BigDecimal baseContribution, BigDecimal quickerContribution,
BigDecimal quickerIIContribution) {
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "out_of_combat_recovery");
data.put("recovery_kind", "out_of_combat");
data.put("healing_source", "out_of_combat_recovery");
data.put("cause", "recovery_timer");
data.put("pulse_application_id", action.actionId().toString());
data.put("pulse_ordinal", Long.toString(action.pulseOrdinal()));
data.put("last_activity_at", action.lastActivity().toString());
data.put("eligible_at", action.eligibleAt().toString());
data.put("maximum_health", action.maximumHealth().toPlainString());
data.put("expected_revision", action.expectedRevision().toString());
data.put("health_before", action.healthBefore().toPlainString());
data.put("proposed_total", action.proposed().toPlainString());
data.put("configured_base", action.configuredBase().toString());
data.put("configured_quicker_regeneration_i", action.configuredQuickerRegeneration().toString());
data.put("configured_quicker_regeneration_ii", action.configuredQuickerRegenerationII().toString());
data.put("outcome", outcome);
if (action.context().location() == null) data.put("location_status", "unavailable");
if (healthAfter != null) data.put("health_after", healthAfter.toPlainString());
if (acceptedTotal != null) {
data.put("accepted_total", acceptedTotal.toPlainString());
data.put("base_contribution", baseContribution.toPlainString());
var passives = new LinkedHashMap<String, String>();
if (quickerContribution.signum() > 0) passives.put("quicker_regeneration_i", quickerContribution.toPlainString());
if (quickerIIContribution.signum() > 0) passives.put("quicker_regeneration_ii", quickerIIContribution.toPlainString());
if (!passives.isEmpty()) data.put("passive_contributions", passives);
data.put("native_publication", "acknowledged");
}
if (reason != null) data.put(type.equals("combat.healed") ? "native_boundary" : "reason", reason);
try {
var event = new EventInput(id(action.actionId(), type), action.actionId(), clock.instant(),
type, action.context(), data);
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event);
else sink.accept(event);
} catch (RuntimeException ignored) { /* No audit failure can heal or replay a pulse. */ }
}
private EventInput.Context context(Player player, EquipmentSessions.Active active, String name) {
return new EventInput.Context(player.getUniqueId(), player.getName(), active.character(), name,
active.session(), location(player));
}
private EventInput.Location location(Player player) {
try {
Location value = player.getLocation();
if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(),
value.getX(), value.getY(), value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID action, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:recovery:" + action + ":" + type)
.getBytes(StandardCharsets.UTF_8));
}
public void replay() { if (outbox != null) outbox.replay(); }
public CompletionStage<Void> drained() {
return outbox == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : outbox.drained();
}
@Override public void close() { if (outbox != null) outbox.close(); }
}
@@ -91,6 +91,7 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private games.dmg.fantasystick.events.SheetResourcePackEventBridge sheetResourcePackEvents;
private games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings outOfCombatRecoverySettings;
private OutOfCombatRecovery outOfCombatRecovery;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private games.dmg.fantasystick.persistence.QuickerRegenerationSettings quickerRegenerationSettings;
private games.dmg.fantasystick.passive.QuickerRegenerationI quickerRegeneration;
private games.dmg.fantasystick.events.PassiveEventBridge passiveEvents;
@@ -383,6 +384,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
outOfCombatRecoverySettings = Objects.requireNonNull(settings);
outOfCombatRecovery = Objects.requireNonNull(recovery);
}
public void attachRecoveryEvents(games.dmg.fantasystick.events.RecoveryEventBridge events) {
if (closed || outOfCombatRecoverySettings == null || recoveryEvents != null)
throw new IllegalStateException("Recovery config events unavailable");
recoveryEvents = Objects.requireNonNull(events);
}
public void attachPassiveEvents(games.dmg.fantasystick.events.PassiveEventBridge events){if(closed||passiveEvents!=null)throw new IllegalStateException("Passive events already attached or command closed");passiveEvents=Objects.requireNonNull(events);}
@@ -1199,12 +1205,16 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void outOfCombatRecoveryConfiguration(CommandSender sender, String[] args) {
String key = args[1];
String key = args[1]; var audit = recoveryEvents;
if (args.length == 2) {
sender.sendMessage(key + "=" + outOfCombatRecoverySettings.value(key)
var effective = outOfCombatRecoverySettings.value(key);
if (audit != null) audit.configRead(audit.actor(sender), key, effective);
sender.sendMessage(key + "=" + effective
+ " (applies to future recovery evaluations; the current activity timestamp is retained)");
return;
}
var actor = audit == null ? null : audit.actor(sender);
var operationId = UUID.randomUUID();
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings.Update> operation;
try { operation = outOfCombatRecoverySettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
@@ -1217,8 +1227,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
boolean applied = false;
if (failure == null) {
var before = outOfCombatRecoverySettings.value(key);
applied = outOfCombatRecoverySettings.publish(saved);
if (applied) outOfCombatRecovery.configure(saved.settings());
if (applied) {
outOfCombatRecovery.configure(saved.settings());
var effective = outOfCombatRecoverySettings.value(key);
if (audit != null && !before.equals(effective))
audit.configChanged(actor, operationId, key, before, effective, saved);
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -52,6 +52,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
Instant activity;
Instant lastAttempt;
long generation;
long pulses;
Object pending;
State(EquipmentSessions.Active active, Instant activity) { this.active = active; this.activity = activity; }
}
@@ -67,6 +68,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
private final CombatSources sources;
private java.util.function.Function<CharacterRecord, BigInteger> bonusHealing = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationContribution = ignored -> BigInteger.ZERO;
private java.util.function.Function<CharacterRecord, BigInteger> quickerRegenerationIIContribution = ignored -> BigInteger.ZERO;
private games.dmg.fantasystick.events.RecoveryEventBridge recoveryEvents;
private final Map<UUID, State> states = new HashMap<>();
private final ArrayDeque<UUID> order = new ArrayDeque<>();
@@ -98,7 +100,18 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
if (closed || task != null || !states.isEmpty()) throw new IllegalStateException("Recovery evaluation already started");
bonusHealing = Objects.requireNonNull(bonus);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,java.util.function.Function<CharacterRecord,BigInteger> quickerContribution){if(closed||recoveryEvents!=null)throw new IllegalStateException("Recovery events already attached or recovery closed");recoveryEvents=Objects.requireNonNull(events);quickerRegenerationContribution=Objects.requireNonNull(quickerContribution);}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,
java.util.function.Function<CharacterRecord, BigInteger> quickerContribution) {
attachEvents(events, quickerContribution, ignored -> BigInteger.ZERO);
}
public synchronized void attachEvents(games.dmg.fantasystick.events.RecoveryEventBridge events,
java.util.function.Function<CharacterRecord, BigInteger> quickerContribution,
java.util.function.Function<CharacterRecord, BigInteger> quickerIIContribution) {
if (closed || recoveryEvents != null) throw new IllegalStateException("Recovery events already attached or recovery closed");
recoveryEvents = Objects.requireNonNull(events);
quickerRegenerationContribution = Objects.requireNonNull(quickerContribution);
quickerRegenerationIIContribution = Objects.requireNonNull(quickerIIContribution);
}
public synchronized void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
@@ -159,6 +172,7 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
} else {
state.activity = now;
state.lastAttempt = null;
state.pulses = 0;
state.generation++;
}
}
@@ -180,14 +194,19 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
state.lastAttempt = now; // Full-health checks are not banked for later replay.
return;
}
BigInteger exactAmount;BigInteger quickerContribution;
try { exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));quickerContribution=Objects.requireNonNull(quickerRegenerationContribution.apply(record)); }
BigInteger exactAmount; BigInteger quickerContribution; BigInteger quickerIIContribution;
try {
exactAmount = configured.amount().add(Objects.requireNonNull(bonusHealing.apply(record)));
quickerContribution = Objects.requireNonNull(quickerRegenerationContribution.apply(record));
quickerIIContribution = Objects.requireNonNull(quickerRegenerationIIContribution.apply(record));
}
catch (RuntimeException failure) { state.lastAttempt = now; report(record.id(), failure); return; }
if (exactAmount.signum() < 0) { state.lastAttempt = now; report(record.id(), new IllegalStateException("Negative recovery bonus")); return; }
double amount = exactAmount.bitLength() > 1023 ? maximum - current : exactAmount.doubleValue();
double proposed = Math.min(maximum - current, amount);
if (!Double.isFinite(proposed) || proposed <= 0) return;
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,configured.amount(),quickerContribution);
var audit=recoveryEvents==null?null:recoveryEvents.begin(player,state.active,record,current,proposed,
configured.amount(),quickerContribution,quickerIIContribution,state.activity,eligible,maximum,state.pulses+1);
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
try{healingEvents.accept(event);}catch(RuntimeException failure){if(audit!=null)recoveryEvents.failed(audit,"native_healing_event_failed");throw failure;}
double accepted = Math.min(proposed, Math.min(maximum - current, event.getAmount()));
@@ -203,7 +222,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
catch (RuntimeException failure) { state.pending = null; state.lastAttempt = clock.instant();if(audit!=null)recoveryEvents.failed(audit,"native_health_limit_unavailable"); report(record.id(), failure); return; }
characters.saveHealth(record.id(), expected, target, limit).whenComplete((saved, failure) -> {
try {
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(), quickerContribution, audit, saved, failure));
nextTick.accept(() -> complete(state, token, generation, expected, current, configured.amount(),
quickerContribution, quickerIIContribution, audit, saved, failure));
} catch (RuntimeException rejected) {
synchronized (OutOfCombatRecovery.this) {
if (state.pending == token) { state.pending = null; state.lastAttempt = clock.instant(); }
@@ -214,7 +234,8 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
}
private synchronized void complete(State state, Object token, long generation, BigInteger expected,
double nativeBefore, BigInteger configuredBase,BigInteger quickerConfigured,games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
double nativeBefore, BigInteger configuredBase, BigInteger quickerConfigured, BigInteger quickerIIConfigured,
games.dmg.fantasystick.events.RecoveryEventBridge.Action audit,java.util.Optional<CharacterRecord> saved, Throwable failure) {
if (state.pending != token) return;
state.pending = null;
if (failure != null || saved == null || saved.isEmpty()) {
@@ -238,7 +259,14 @@ public final class OutOfCombatRecovery implements Listener, AutoCloseable {
double publish = Math.min(maximum, persisted.health().doubleValue());
if (!Double.isFinite(maximum) || !Double.isFinite(publish) || publish <= nativeBefore) {if(audit!=null)recoveryEvents.failed(audit,"saved_but_no_native_heal_available");return;}
try{player.setHealth(publish);}catch(RuntimeException nativeFailure){if(audit!=null)recoveryEvents.failed(audit,"native_health_publication_failed");throw nativeFailure;}
if(audit!=null){var total=BigDecimal.valueOf(publish-nativeBefore);var base=total.min(new BigDecimal(configuredBase));var passive=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,passive);}
state.pulses++;
if(audit!=null){
var total=BigDecimal.valueOf(publish-nativeBefore);
var base=total.min(new BigDecimal(configuredBase));
var quicker=total.subtract(base).min(new BigDecimal(quickerConfigured)).max(BigDecimal.ZERO);
var quickerII=total.subtract(base).subtract(quicker).min(new BigDecimal(quickerIIConfigured)).max(BigDecimal.ZERO);
recoveryEvents.healed(audit,BigDecimal.valueOf(publish),total,base,quicker,quickerII);
}
if (feedback != null) feedback.report("Out-of-Combat Recovery", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
@@ -0,0 +1,211 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.persistence.OutOfCombatRecoverySettings;
import games.dmg.fantasystick.session.OutOfCombatRecovery;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityRegainHealthEvent;
/** Registered recovery and config publication must be observed in exported JSONL. */
class OutOfCombatRecoveryEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredReadAndSavedAmountGovernActualPersistedNativePulseWithFullAuditTiming() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender();
for (var key : List.of(OutOfCombatRecoverySettings.DELAY,
OutOfCombatRecoverySettings.INTERVAL, OutOfCombatRecoverySettings.AMOUNT))
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{"config", key});
var player = new FantasyStickLifecycleNativeTest.Actor(data);
injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().startsWith("Saved "); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().startsWith("Saved "));
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(6).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
f.plugin.setEnabled(false);
}
var entries = events();
var reads = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.read")).toList();
assertEquals(3, reads.size());
assertEquals(List.of(OutOfCombatRecoverySettings.DELAY, OutOfCombatRecoverySettings.INTERVAL,
OutOfCombatRecoverySettings.AMOUNT), reads.stream()
.map(e -> e.getAsJsonObject("data").get("key").getAsString()).toList());
for (var read : reads) {
var value = read.getAsJsonObject("data");
assertTrue(value.has("default")); assertTrue(value.has("effective"));
assertEquals("future_recovery_evaluations", value.get("application_scope").getAsString());
assertTrue(value.get("activity_timestamp_retained").getAsBoolean());
assertEquals("console", value.get("actor_kind").getAsString());
}
var changes = entries.stream().filter(e -> e.get("type").getAsString().endsWith("config.changed")).toList();
assertEquals(1, changes.size());
var change = changes.getFirst().getAsJsonObject("data");
assertEquals("1", change.get("before").getAsString());
assertEquals("2", change.get("after").getAsString());
assertEquals("2", change.get("effective").getAsString());
assertEquals("acknowledged_future_evaluations", change.get("outcome").getAsString());
assertTrue(change.has("policy_revision"));
var healed = entries.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).toList();
assertEquals(1, healed.size());
var values = healed.getFirst().getAsJsonObject("data");
assertEquals("out_of_combat_recovery", values.get("healing_source").getAsString());
assertEquals("1", values.get("pulse_ordinal").getAsString());
assertTrue(values.has("last_activity_at")); assertTrue(values.has("eligible_at"));
assertTrue(values.has("maximum_health"));
assertEquals("2", values.get("configured_base").getAsString());
assertEquals("2.0", values.get("accepted_total").getAsString());
}
@Test void playerFeedbackOffStillExportsActualPersistedRecovery() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player);
assertTrue(f.playerCommand.getExecutor().onCommand(player.player, f.playerCommand,
"fstick", new String[]{"feedback", "off"}));
f.tick();
f.activate(player);
int componentsBefore = player.messageComponents.size();
((OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,
"outOfCombatRecovery")).sweep();
PartyBookNativeTest.drain(f);
assertEquals(5, player.player.getHealth());
assertEquals(componentsBefore, player.messageComponents.size(),
"Opting out of combat presentation does not opt out of healing or its audit");
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
}
assertEquals(1, events().stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).count());
}
@Test void writerFailureReplaysOneAcceptedPulseWithoutHealingAgainOnRestart() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
var log = (games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.close();
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
f.plugin.setEnabled(false);
assertTrue(events().isEmpty(), "Pending work is not exported evidence");
f.plugin.setEnabled(true);
for (int i = 0; i < 200 && events().stream().noneMatch(e -> e.get("type").getAsString()
.endsWith("combat.healed")); i++) Thread.sleep(10);
assertEquals(0, BigDecimal.valueOf(5).compareTo(((CharacterStorage)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters"))
.find(player.core.record.id()).get().orElseThrow().health()));
f.plugin.setEnabled(false);
}
var healed = events().stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")).toList();
assertEquals(1, healed.size(), "Replaying the event must not replay native or persisted healing");
assertEquals("1", healed.getFirst().getAsJsonObject("data").get("pulse_ordinal").getAsString());
}
@Test void cancelledNativePulseAndInvalidOrFailedConfigNeverExportSuccess() throws Exception {
Files.writeString(data.resolve("config.yml"), "out-of-combat-healing-delay-seconds: 0\n");
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var player = new FantasyStickLifecycleNativeTest.Actor(data); injured(player, BigDecimal.valueOf(4));
f.players.put(player.core.id, player.player); f.activate(player);
f.dispatcher.register(new Listener() {
@EventHandler public void veto(EntityRegainHealthEvent event) {
if (event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM)
event.setCancelled(true);
}
}, f.plugin);
var recovery = (OutOfCombatRecovery) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "outOfCombatRecovery");
recovery.sweep(); PartyBookNativeTest.drain(f);
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(4).compareTo(storage.find(player.core.record.id())
.get().orElseThrow().health()));
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = false;
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT});
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
admin.allowed = true;
for (var invalid : List.of("-1", "1025", "1.5", "oops"))
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, invalid});
Files.delete(data.resolve("config.yml")); Files.createDirectory(data.resolve("config.yml"));
f.adminCommand.execute(admin.port, "fstickadmin", new String[]{
"config", OutOfCombatRecoverySettings.AMOUNT, "2"});
for (int i = 0; i < 30 && !admin.messages.getLast().contains("save failed"); i++) {
var callback = f.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(callback); callback.run();
}
assertTrue(admin.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
}
assertTrue(events().isEmpty(), "Cancelled healing and refused configuration cannot claim success");
}
private void injured(FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), old.stats(),
old.level(), old.experience(), old.unspentPoints(), health, old.learned(), old.loadout(), old.cooldowns(),
old.restrictedKills(), false, null, null), old.revision());
}
private List<JsonObject> events() throws Exception {
var entries = new ArrayList<JsonObject>(); var root = data.resolve("events");
if (!Files.isDirectory(root)) return entries;
try (var days = Files.list(root)) {
for (var day : days.filter(p -> p.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")
&& Files.isDirectory(p, java.nio.file.LinkOption.NOFOLLOW_LINKS)).toList()) {
try (var segments = Files.list(day)) {
for (var path : segments.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path); int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList()) if (!line.isBlank()) {
var event = JsonParser.parseString(line).getAsJsonObject();
var type = event.get("type").getAsString();
if (type.endsWith("combat.healed") && event.getAsJsonObject("data").has("recovery_kind")
|| (type.endsWith("config.read") || type.endsWith("config.changed"))
&& event.getAsJsonObject("data").has("key")
&& event.getAsJsonObject("data").get("key").getAsString()
.startsWith("out-of-combat-healing-")) entries.add(event);
}
}
}
}
}
return entries;
}
}
@@ -39,6 +39,9 @@ class OutOfCombatRecoveryNativeTest {
@Test void exactDelayRepeatedIntervalsAndLargeClockJumpsPersistOnePulseBeforeNativePublication() {
var f = new Direct();
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.ZERO);
f.recovery.sweep();
f.clock.advance(Duration.ofSeconds(9)); f.recovery.sweep();
assertTrue(f.events.isEmpty());
@@ -62,12 +65,18 @@ class OutOfCombatRecoveryNativeTest {
f.clock.advance(Duration.ofSeconds(100)); f.recovery.sweep(); f.runNext();
assertEquals(7, f.health.get(), "A late evaluation applies one pulse rather than replaying missed intervals");
assertEquals(3, f.events.size());
var heals = audit.stream().filter(event -> event.type().equals("combat.healed")).toList();
assertEquals(List.of("1", "2", "3"), heals.stream()
.map(event -> event.data().get("pulse_ordinal")).toList());
assertEquals(f.clock.now.minusSeconds(110).toString(), heals.getFirst().data().get("eligible_at"));
assertEquals(f.clock.now.minusSeconds(120).toString(), heals.getFirst().data().get("last_activity_at"));
f.health.set(10d);
var revision = f.record.get().revision();
f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(3, f.events.size(), "Full health dispatches no native healing event");
assertEquals(revision, f.record.get().revision(), "Full health performs no persistence write");
assertEquals(3, audit.stream().filter(event -> event.type().equals("combat.healed")).count());
}
@Test void acceptedEnvironmentalAndDealtDamageResetWhileCancelledAndZeroAttemptsDoNot() {
@@ -108,11 +117,16 @@ class OutOfCombatRecoveryNativeTest {
failed.clock.advance(Duration.ofSeconds(9)); failed.recovery.sweep();
assertEquals(1, failed.events.size(), "Failed persistence does not immediately replay a missed pulse");
var stale = new Direct(); stale.automatic = false; stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
var stale = new Direct(); stale.automatic = false;
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
stale.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, stale.clock),
ignored -> BigInteger.ZERO);
stale.recovery.sweep(); stale.clock.advance(Duration.ofSeconds(10));
stale.recovery.sweep();
stale.recovery.acceptedDamage(damage(stale.player, 1));
stale.pending.complete(Optional.of(stale.saved(BigDecimal.valueOf(5)))); stale.runNext();
assertEquals(4, stale.health.get(), "Damage during the save invalidates native publication");
assertEquals(0, audit.stream().filter(event -> event.type().equals("combat.healed")).count());
stale.clock.advance(Duration.ofSeconds(9)); stale.recovery.sweep(); assertEquals(1, stale.events.size());
}
@@ -210,9 +224,51 @@ class OutOfCombatRecoveryNativeTest {
@Test void stackedRegenerationBonusProducesOneMaximumCappedNativeAndPersistedPulse() {
var f = new Direct(); f.health.set(9d); f.record.set(f.saved(BigDecimal.valueOf(9)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.valueOf(3));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep();
assertEquals(1, f.events.size()); assertEquals(1, f.events.getFirst().getAmount());
f.runNext(); assertEquals(10, f.health.get()); assertEquals(0, BigDecimal.TEN.compareTo(f.record.get().health()));
var healed = audit.stream().filter(input -> input.type().equals("combat.healed")).toList();
assertEquals(1, healed.size());
assertEquals("1.0", healed.getFirst().data().get("accepted_total"));
assertEquals("1.0", healed.getFirst().data().get("base_contribution"));
assertEquals("3", healed.getFirst().data().get("configured_quicker_regeneration_i"));
assertFalse(healed.getFirst().data().containsKey("passive_contributions"),
"The capped pulse cannot claim a bonus it did not deliver");
var fullBonus = new Direct();
fullBonus.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(3));
var fullAudit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
fullBonus.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(
fullAudit::add, fullBonus.clock), ignored -> BigInteger.valueOf(3));
fullBonus.recovery.sweep(); fullBonus.clock.advance(Duration.ofSeconds(10));
fullBonus.recovery.sweep(); fullBonus.runNext();
assertEquals(8, fullBonus.health.get());
var fullHeal = fullAudit.stream().filter(input -> input.type().equals("combat.healed")).toList();
assertEquals(1, fullHeal.size());
assertEquals("4.0", fullHeal.getFirst().data().get("accepted_total"));
assertEquals(java.util.Map.of("quicker_regeneration_i", "3.0"),
fullHeal.getFirst().data().get("passive_contributions"));
}
@Test void namedPassiveContributionsDescribeOnlyActualAcceptedHealing() {
var f = new Direct(); f.health.set(5d); f.record.set(f.saved(BigDecimal.valueOf(5)));
f.recovery.attachBonusHealing(ignored -> BigInteger.valueOf(5));
var audit = new java.util.ArrayList<games.dmg.fantasystick.events.EventInput>();
f.recovery.attachEvents(new games.dmg.fantasystick.events.RecoveryEventBridge(audit::add, f.clock),
ignored -> BigInteger.valueOf(3), ignored -> BigInteger.valueOf(2));
f.recovery.sweep(); f.clock.advance(Duration.ofSeconds(10)); f.recovery.sweep(); f.runNext();
assertEquals(10, f.health.get(), "The cap delivers only one of Quicker II's two configured points");
var healed = audit.stream().filter(event -> event.type().equals("combat.healed")).toList();
assertEquals(1, healed.size());
assertEquals("5.0", healed.getFirst().data().get("accepted_total"));
@SuppressWarnings("unchecked")
var contributions = (java.util.Map<String, String>) healed.getFirst().data().get("passive_contributions");
assertEquals(java.util.Set.of("quicker_regeneration_i", "quicker_regeneration_ii"), contributions.keySet());
assertEquals(0, new BigDecimal(contributions.get("quicker_regeneration_i")).compareTo(BigDecimal.valueOf(3)));
assertEquals(0, new BigDecimal(contributions.get("quicker_regeneration_ii")).compareTo(BigDecimal.ONE));
}
@Test void zeroAmountDisablesPulsesAndReconfigurationRetainsTheActivityTimestamp() {
@@ -259,27 +315,33 @@ class OutOfCombatRecoveryNativeTest {
assertEquals(5, a.player.getHealth());
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(characterId).get().orElseThrow().health()));
}
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
var healed = new java.util.ArrayList<com.google.gson.JsonObject>();
for (int attempt = 0; attempt < 200; attempt++) {
healed.clear();
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
for (var segment : files.filter(file -> file.getFileName().toString().startsWith("events-")
&& file.getFileName().toString().endsWith(".jsonl")).toList()) {
for (var line : java.nio.file.Files.readAllLines(segment)) {
if (line.isBlank()) continue;
var event = com.google.gson.JsonParser.parseString(line).getAsJsonObject();
if (event.get("type").getAsString().endsWith("combat.healed")
&& event.getAsJsonObject("data").has("recovery_kind")) healed.add(event);
}
}
}
if (!healed.isEmpty()) break;
Thread.sleep(10);
}
// Acknowledged recovery intents export asynchronously; retain the exact exported-event assertion.
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
assertEquals(1, healed.size());
var event = healed.getFirst(); var payload = event.getAsJsonObject("data");
assertEquals(playerId.toString(), payload.get("player_id").getAsString());
assertEquals(characterId.toString(), payload.get("character_id").getAsString());
assertTrue(payload.has("session_id"));
assertEquals("unavailable", payload.get("location_status").getAsString());
assertFalse(payload.has("location"));
assertEquals("5.0", payload.get("health_after").getAsString());
}
@Test void packagedPluginConnectsZeroDelayRecoveryConfigurationAndRestart() throws Exception {