feat(recovery): export acknowledged pulses and config events
Release / release (push) Failing after 9m29s
Release / release (push) Failing after 9m29s
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user