feat(events): audit Health Steal final-blow journeys
Release / release (push) Failing after 10m36s

This commit is contained in:
dmg committed 2026-10-01 01:11:10 -04:00
1 parent 908b0b02aa
commit 03c121287c
9 files changed
+560 -26

No files matched your search

@@ -159,6 +159,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.QuickerRegenerationII quickerRegenerationII;
private games.dmg.fantasystick.persistence.HealthStealSettings healthStealSettings;
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents;
private java.util.concurrent.CompletionStage<Void> healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.passive.SpeedI speedI;
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
@@ -864,6 +866,10 @@ 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);
healthStealEvents = new games.dmg.fantasystick.events.HealthStealEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Health Steal event intent failed", failure));
healthSteal.attachEvents(healthStealEvents);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
@@ -1049,6 +1055,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 replayHealthStealEvents = healthStealEvents;
healthStealAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous Health Steal event drain failed", failure);
if (replayHealthStealEvents != null) replayHealthStealEvents.replay();
});
var replayRecoveryEvents = recoveryEvents;
recoveryAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous recovery event intent drain failed", failure);
@@ -1137,6 +1148,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingPartyCapacityEvents = partyCapacityEvents; partyCapacityEvents = null;
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
@@ -1499,6 +1511,14 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
healthStealAuditDrain = storageDrained.handle((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Character storage drain failed", failure);
closingHealthStealEvents.close(); return null;
}).thenCompose(ignored -> closingHealthStealEvents.drained());
});
if (closingRecoveryEvents != null) cleanup("recovery event intent drain", () -> {
var settingsDrained = closingOutOfCombatRecoverySettings == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null)
@@ -897,7 +897,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|| !(event.getCommonConnection() instanceof io.papermc.paper.connection.PlayerGameConnection connection)
|| event.getTag() == null) return;
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive)
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive);
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive)
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
@@ -0,0 +1,141 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
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.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/** Audit-only final-blow snapshots; replaying a heal intent never invokes gameplay. */
public final class HealthStealEventBridge implements AutoCloseable {
public record Damage(UUID id, Instant time, UUID target, String targetType,
UUID directEntity, String cause, String amount, EventInput.Location location) { }
public record Action(UUID id, Damage damage, EventInput.Context context, EventInput.Location targetLocation,
UUID targetCharacter, UUID targetSession, BigInteger configured, BigDecimal requested,
CharacterRecord before, BigDecimal healthBefore, BigDecimal maximum) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
public HealthStealEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
}
public HealthStealEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "health-steal-event-intents", java.util.Set.of("combat.healed"), sink, failures);
}
/** Called only for an already accepted lethal candidate, before its native death callback. */
public Damage damage(EntityDamageEvent event) {
try {
var target = event.getEntity(); var direct = event.getDamageSource().getDirectEntity();
if (direct == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
direct = byEntity.getDamager();
return new Damage(UUID.randomUUID(), clock.instant(), target.getUniqueId(),
target instanceof Player ? "PLAYER" : target.getType().name(), direct == null ? null : direct.getUniqueId(),
event.getCause().name(), Double.toString(event.getFinalDamage()), location(target.getLocation()));
} catch (RuntimeException unavailable) { return null; }
}
public Action begin(Damage damage, LivingEntity target, Player player, EquipmentSessions.Active active,
CharacterRecord before, EquipmentSessions.Active targetActive,
BigInteger configured, double requested, double current, double maximum) {
if (damage == null) return null;
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
before.name(), active.session(), location(player.getLocation()));
var action = new Action(id(damage.id(), "defeat"), damage, context, location(target.getLocation()),
targetActive == null ? null : targetActive.character(), targetActive == null ? null : targetActive.session(),
configured, BigDecimal.valueOf(requested), before, BigDecimal.valueOf(current), BigDecimal.valueOf(maximum));
emit("action.observed", action, "observed", null, before, null, action.healthBefore(), null, action.maximum(), null);
return action;
} catch (RuntimeException unavailable) { return null; }
}
public void refused(Action action, String reason) {
if (action != null) emit("action.resolved", action, "refused", reason, action.before(), null,
action.healthBefore(), null, action.maximum(), null);
}
public void failed(Action action, String reason) {
if (action != null) emit("action.resolved", action, "failed", reason, action.before(), null,
action.healthBefore(), null, action.maximum(), null);
}
public void healed(Action action, CharacterRecord before, CharacterRecord after,
double nativeBefore, double nativeAfter, double maximum, double accepted) {
if (action == null) return;
var prior = BigDecimal.valueOf(nativeBefore); var next = BigDecimal.valueOf(nativeAfter);
var limit = BigDecimal.valueOf(maximum); var amount = BigDecimal.valueOf(accepted);
emit("action.resolved", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
emit("combat.healed", action, "accepted", "native_health_acknowledged", before, after, prior, next, limit, amount);
}
private void emit(String type, Action action, String outcome, String reason, CharacterRecord before,
CharacterRecord after, BigDecimal prior, BigDecimal next, BigDecimal maximum, BigDecimal accepted) {
try {
var data = new LinkedHashMap<String, Object>(); var damage = action.damage();
data.put("action_kind", "health_steal_final_blow"); data.put("passive_id", "health_steal");
data.put("healing_source", "health_steal"); data.put("defeat_id", action.id().toString());
data.put("damage_causation_id", damage.id().toString()); data.put("damage_accepted_at", damage.time().toString());
data.put("damage_cause", damage.cause()); data.put("damage_amount", damage.amount());
data.put("target_id", damage.target().toString()); data.put("target_type", damage.targetType());
if (damage.directEntity() != null) data.put("direct_entity_id", damage.directEntity().toString());
if (damage.location() != null) data.put("damage_target_location", values(damage.location()));
if (action.targetLocation() != null) data.put("target_location", values(action.targetLocation()));
data.put("target_location_capture_quality", action.targetLocation() == null ? "unavailable" : "exact");
data.put("location_capture_quality", action.context().location() == null ? "unavailable" : "exact");
if (action.targetCharacter() != null) {
data.put("target_character_id", action.targetCharacter().toString());
data.put("target_session_id", action.targetSession().toString());
}
data.put("defeat_consumption", "consumed_once"); data.put("configured_amount", action.configured().toString());
data.put("requested_amount", action.requested().toPlainString()); data.put("health_before", prior.toPlainString());
data.put("maximum_health", maximum.toPlainString()); data.put("before_revision", before.revision().toString());
data.put("points_before", before.unspentPoints().toString()); data.put("outcome", outcome);
if (reason != null) data.put("reason", reason);
if (after != null && next != null) {
data.put("health_after", next.toPlainString()); data.put("final_amount", next.subtract(prior).toPlainString());
data.put("native_accepted_amount", accepted.toPlainString()); data.put("persisted_health", after.health().toPlainString());
data.put("after_revision", after.revision().toString()); data.put("points_after", after.unspentPoints().toString());
data.put("native_publication", "acknowledged");
}
var event = new EventInput(id(action.id(), type), action.id(), clock.instant(), type, action.context(), Map.copyOf(data));
if (type.equals("combat.healed") && outbox != null) outbox.accepted(event); else sink.accept(event);
} catch (RuntimeException ignored) { /* Audit failure cannot authorize another defeat, save or native heal. */ }
}
private EventInput.Location location(Location value) {
try {
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 Map<String, Object> values(EventInput.Location location) {
return Map.of("world_id", location.worldId().toString(), "world_name", location.worldName(),
"x", location.x(), "y", location.y(), "z", location.z(), "captured_at", location.capturedAt().toString(),
"quality", location.quality());
}
private static UUID id(UUID operation, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:health-steal:" + operation + ":" + 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(); }
}
@@ -31,7 +31,10 @@ public final class PassiveEventBridge {
private void emit(String type,Action action,String outcome,String reason,CharacterRecord after){var before=action.before();var data=new LinkedHashMap<String,Object>();data.put("action_kind",action.learn()?"passive_learn":"passive_unlearn");data.put("passive_id",action.passive());data.put("expected_revision",before.revision().toString());data.put("before_revision",before.revision().toString());data.put("points_before",before.unspentPoints().toString());data.put("point_cost",action.definition().pointCost().toString());var requirements=new LinkedHashMap<String,Object>();action.definition().requirements().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry->requirements.put(entry.getKey().name(),entry.getValue().toString()));data.put("stat_prerequisites",requirements);var statValues=new LinkedHashMap<String,Object>();action.definition().requirements().keySet().stream().sorted().forEach(stat->{var value=before.stats().get(stat);if(value!=null)statValues.put(stat.name(),value.toString());});data.put("stat_values_before",statValues);data.put("passive_slots_before",before.passives());data.put("passive_prerequisites",action.definition().prerequisites().stream().sorted().toList());data.put("outcome",outcome);if(after!=null){data.put("after_revision",after.revision().toString());data.put("points_after",after.unspentPoints().toString());data.put("passive_slots_after",after.passives());int slot=(action.learn()?after.passives():before.passives()).indexOf(action.passive());data.put("passive_slot",slot);data.put("slot_index_base",0);data.put("native_publication","acknowledged");data.put("owned_cleanup",action.learn()?"passive_state_acknowledged":"passive_state_acknowledged");}if(reason!=null)data.put(type.startsWith("passive.")?"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){}
}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application","future_recovery_pulses");data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
public void configRead(String actorKind,UUID player,String playerName,String key,String value,String application){config("config.read",actorKind,player,playerName,key,null,value,application);}
public void configChanged(String actorKind,UUID player,String playerName,String key,String before,String after,String application){config("config.changed",actorKind,player,playerName,key,before,after,application);}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after){config(type,actorKind,player,playerName,key,before,after,"future_recovery_pulses");}
private void config(String type,String actorKind,UUID player,String playerName,String key,String before,String after,String application){var action=UUID.randomUUID();var data=new LinkedHashMap<String,Object>();data.put("actor_kind",actorKind);if(player!=null)data.put("actor_player_id",player.toString());if(playerName!=null)data.put("actor_player_name",playerName);data.put("key",key);if(before!=null)data.put("before",before);data.put("after",after);data.put("application",application);data.put("outcome","acknowledged");var context=player==null?null:new EventInput.Context(player,playerName,null,null,null,null);try{sink.accept(new EventInput(id(action,type),action,clock.instant(),type,context,Map.copyOf(data)));}catch(RuntimeException ignored){}
}
private EventInput.Context context(org.bukkit.entity.Player player,EquipmentSessions.Active active,String name){return new EventInput.Context(player.getUniqueId(),player.getName(),active.character(),name,active.session(),location(player.getLocation()));}
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");}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.ability.AbilityReport;
import games.dmg.fantasystick.events.HealthStealEventBridge;
import games.dmg.fantasystick.party.CombatSources;
import games.dmg.fantasystick.presentation.CombatFeedback;
import games.dmg.fantasystick.persistence.CharacterRecord;
@@ -46,8 +47,8 @@ public final class HealthSteal implements Listener, AutoCloseable {
public static Settings defaults() { return new Settings(BigInteger.ONE); }
}
public record Failure(UUID character, Throwable cause) { }
private record Candidate(LivingEntity target, CombatSources.Origin origin) { }
private record Heal(EquipmentSessions.Active active, double amount) { }
private record Candidate(LivingEntity target, CombatSources.Origin origin, HealthStealEventBridge.Damage audit) { }
private record Heal(EquipmentSessions.Active active, double amount, HealthStealEventBridge.Action audit) { }
private final CharacterStorage characters;
private final EquipmentSessions sessions;
@@ -80,6 +81,11 @@ public final class HealthSteal implements Listener, AutoCloseable {
this.failures = Objects.requireNonNull(failures); this.feedback = feedback; sources = new CombatSources(sessions);
}
private HealthStealEventBridge events;
public void attachEvents(HealthStealEventBridge bridge) {
if (closed || events != null || !lethal.isEmpty() || !pending.isEmpty()) throw new IllegalStateException("Health Steal events already attached or evaluation started");
events = Objects.requireNonNull(bridge);
}
public void configure(Settings value) { settings = Objects.requireNonNull(value); }
public BigInteger healing() { return settings.healing(); }
public PassiveDefinition definition() {
@@ -102,7 +108,7 @@ public final class HealthSteal implements Listener, AutoCloseable {
|| event.getFinalDamage() < remaining) {
lethal.remove(targetId); return;
}
lethal.put(targetId, new Candidate(target, origin));
lethal.put(targetId, new Candidate(target, origin, events == null ? null : events.damage(event)));
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
@@ -151,58 +157,75 @@ public final class HealthSteal implements Listener, AutoCloseable {
double amount = configured.bitLength() > 1023 ? maximum - current - reserved : configured.doubleValue();
double proposed = Math.min(maximum - current - reserved, amount);
if (!Double.isFinite(current) || !Double.isFinite(maximum) || current < 0 || maximum <= 0
|| !Double.isFinite(proposed) || proposed <= 0) return;
|| !Double.isFinite(proposed)) return;
var audit = events == null ? null : events.begin(candidate.audit(), target, player, active, record,
target instanceof Player ? sessions.active(target.getUniqueId()).orElse(null) : null,
configured, proposed, current, maximum);
if (proposed <= 0) { if (events != null) events.refused(audit, "full_health_or_no_available_capacity"); return; }
var healing = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
healingEvents.accept(healing);
try { healingEvents.accept(healing); }
catch (RuntimeException failure) { if (events != null) events.failed(audit, "native_healing_validation_failed"); throw failure; }
double accepted = Math.min(proposed, Math.min(maximum - current - reserved, healing.getAmount()));
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) return;
if (healing.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
if (events != null) events.refused(audit, healing.isCancelled() ? "native_event_cancelled" : "no_positive_healing_accepted"); return;
}
var queue = pending.computeIfAbsent(active.character(), ignored -> new ArrayDeque<>());
if (queue.size() >= MAX_PENDING_PER_CHARACTER) return;
queue.addLast(new Heal(active, accepted));
if (queue.size() >= MAX_PENDING_PER_CHARACTER) { if (events != null) events.refused(audit, "pending_heal_capacity"); return; }
queue.addLast(new Heal(active, accepted, audit));
if (queue.size() == 1) attempt(active.character());
}
private void attempt(UUID character) {
var queue = pending.get(character); var operation = queue == null ? null : queue.peekFirst();
if (closed || operation == null) return;
if (!sessions.current(operation.active())) { finish(character); return; }
if (!sessions.current(operation.active())) { auditFailed(operation, "source_session_changed"); finish(character); return; }
var record = sessions.currentRecord(operation.active()).orElse(null);
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) { finish(character); return; }
if (record == null || player == null || record.dead() || !record.passives().contains(ID)) {
auditFailed(operation, "character_unavailable_or_passive_removed"); finish(character); return;
}
double nativeBefore = player.getHealth();
BigDecimal target = record.health().add(BigDecimal.valueOf(operation.amount()));
BigDecimal limit;
try { limit = nativeLimit.get(); }
catch (RuntimeException failure) { report(character, failure); finish(character); return; }
catch (RuntimeException failure) { auditFailed(operation, "native_health_limit_unavailable"); report(character, failure); finish(character); return; }
BigInteger expected = record.revision();
characters.saveHealth(character, expected, target, limit).whenComplete((saved, failure) -> {
try { nextTick.accept(() -> complete(character, operation, expected, nativeBefore, saved, failure)); }
catch (RuntimeException rejected) { report(character, rejected); }
try { nextTick.accept(() -> complete(character, operation, expected, record, nativeBefore, saved, failure)); }
catch (RuntimeException rejected) { auditFailed(operation, "scheduler_rejected_after_save_uncertain"); report(character, rejected); }
});
}
private void complete(UUID character, Heal operation, BigInteger expected, double nativeBefore,
private void complete(UUID character, Heal operation, BigInteger expected, CharacterRecord before, double nativeBefore,
java.util.Optional<CharacterRecord> saved, Throwable failure) {
try {
if (closed || failure != null || saved == null || saved.isEmpty()) {
auditFailed(operation, closed ? "closed_after_save" : failure != null ? "health_save_failed" : "character_unavailable");
if (failure != null) report(character, failure); return;
}
var record = saved.orElseThrow();
if (!sessions.current(operation.active())
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) return;
|| !sessions.acknowledgeProgress(operation.active(), expected, record)) {
auditFailed(operation, "saved_but_session_publication_refused"); return;
}
var player = sessions.currentPlayer(operation.active()).orElse(null);
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) return;
if (player == null || Double.compare(player.getHealth(), nativeBefore) != 0) {
auditFailed(operation, "saved_but_native_health_changed"); return;
}
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
if (attribute == null) return;
if (attribute == null) { auditFailed(operation, "saved_but_maximum_health_unavailable"); return; }
double maximum = Math.min(attribute.getValue(), record.maximumHealth().doubleValue());
double publish = Math.min(maximum, record.health().doubleValue());
if (Double.isFinite(maximum) && Double.isFinite(publish) && publish > nativeBefore) {
player.setHealth(publish);
try { player.setHealth(publish); }
catch (RuntimeException nativeFailure) { auditFailed(operation, "native_health_publication_failed"); throw nativeFailure; }
if (events != null) events.healed(operation.audit(), before, record, nativeBefore, publish, maximum, operation.amount());
if (feedback != null) feedback.report("Health Steal", player, AbilityReport.Type.HEALING, player,
BigDecimal.valueOf(publish - nativeBefore).stripTrailingZeros());
}
} else auditFailed(operation, "saved_but_no_positive_native_heal");
} finally { finish(character); }
}
private void auditFailed(Heal operation, String reason) { if (events != null) events.failed(operation.audit(), reason); }
private void finish(UUID character) {
var queue = pending.get(character);
@@ -1320,9 +1320,15 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
}
private void healthStealConfiguration(CommandSender sender, String[] args) {
String key = games.dmg.fantasystick.persistence.HealthStealSettings.KEY;
String before = healthStealSettings.value();
String actorKind = sender instanceof org.bukkit.entity.Player ? "player"
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "console";
UUID actorPlayer = sender instanceof org.bukkit.entity.Player player ? player.getUniqueId() : null;
String actorName = sender instanceof org.bukkit.entity.Player ? sender.getName() : null;
if (args.length == 2) {
sender.sendMessage(games.dmg.fantasystick.persistence.HealthStealSettings.KEY + "="
+ healthStealSettings.value() + " (applies to later qualifying final blows only)"); return;
if (passiveEvents != null) passiveEvents.configRead(actorKind, actorPlayer, actorName, key, before, "future_qualifying_defeats");
sender.sendMessage(key + "=" + before + " (applies to later qualifying final blows only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HealthStealSettings.Update> operation;
try { operation = healthStealSettings.set(args[2]); }
@@ -1335,7 +1341,10 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return; boolean applied = false;
if (failure == null) {
applied = healthStealSettings.publish(saved);
if (applied) { healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition()); }
if (applied) {
healthSteal.configure(saved.settings()); passiveRegistry.replace(healthSteal.definition());
if (passiveEvents != null) passiveEvents.configChanged(actorKind, actorPlayer, actorName, key, before, saved.value(), "future_qualifying_defeats");
}
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
@@ -155,6 +155,17 @@ class FireballNativeTest {
assertEquals("7.0", damage.getAsJsonObject("data").get("actual_amount").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("action_id").getAsString(),
damage.getAsJsonObject("data").get("action_id").getAsString());
var steals = events.stream().filter(row -> row.get("type").getAsString().endsWith("combat.healed")
&& row.getAsJsonObject("data").has("healing_source")
&& row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).toList();
assertEquals(1, steals.size(), "The actual Fireball final blow owns one Health Steal heal, not another reward/damage");
var heal = steals.getFirst().getAsJsonObject("data");
assertEquals(damage.getAsJsonObject("data").get("target_entity_id").getAsString(), heal.get("target_id").getAsString());
assertEquals(resolution.getAsJsonObject("data").get("direct_entity_id").getAsString(), heal.get("direct_entity_id").getAsString());
assertEquals(castEvent.getAsJsonObject("data").get("character_id").getAsString(), heal.get("character_id").getAsString());
assertEquals("7.0", heal.get("damage_amount").getAsString());
assertEquals("4.0", heal.get("health_before").getAsString()); assertEquals("5.0", heal.get("health_after").getAsString());
assertEquals("1.0", heal.get("final_amount").getAsString());
}
@Test void acceptedFantasyPlayerHitHasOneOwnedDamageRecordNotAnIncomingDuplicate() throws Exception {
@@ -0,0 +1,249 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.HealthStealEventBridge;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.passive.HealthSteal;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.progression.Stat;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.bukkit.Location;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityRegainHealthEvent;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HealthStealEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void feedbackOffAndRejectedWriterRecoverExactHealWithoutAnotherHealthOrRewardMutation() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4"));
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick(); int components = a.messageComponents.size();
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
assertEquals(5, a.player.getHealth());
assertEquals(components, a.messageComponents.size());
var saved = current(f, a); assertEquals(0, new BigDecimal("5").compareTo(saved.health()));
assertEquals(BigInteger.ONE.shiftLeft(100), saved.unspentPoints());
die(f, target); drain(f); assertEquals(saved, current(f, a));
f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(heals().isEmpty());
var fields = new Properties();
try (var files = Files.list(data.resolve("health-steal-event-intents"))) {
var pending = files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList();
assertEquals(1, pending.size()); try (var input = Files.newInputStream(pending.getFirst())) { fields.load(input); }
}
f.plugin.setEnabled(true); ready(f); awaitHeals(1);
assertEquals(saved, storage(f).find(saved.id()).get().orElseThrow(), "Event replay cannot credit XP/coins/points or health");
f.plugin.setEnabled(false);
var rows = heals(); assertEquals(1, rows.size());
assertEquals(fields.getProperty("id"), rows.getFirst().get("id").getAsString());
var values = rows.getFirst().getAsJsonObject("data");
assertEquals(fields.getProperty("action"), values.get("action_id").getAsString());
assertEquals(target.getUniqueId().toString(), values.get("target_id").getAsString());
assertEquals("4.0", values.get("health_before").getAsString());
assertEquals("5.0", values.get("health_after").getAsString());
assertEquals("1.0", values.get("final_amount").getAsString());
}
}
@Test void nativeCancellationSaveFailureAndNativePublicationFailureCannotClaimOrReplayHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4"));
class Gate implements Listener {
boolean veto = true;
@EventHandler public void heal(EntityRegainHealthEvent e) { if (e.getEntity() == a.player) e.setCancelled(veto); }
}
var gate = new Gate(); f.dispatcher.register(gate, f.plugin);
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
// Isolate fault outcomes from unrelated reward persistence; the connected journey covers registered dispatch.
var cancelled = mob(a, EntityType.COW); service.damage(damage(a, cancelled)); service.mobDeath(death(cancelled)); drain(f);
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("4").compareTo(current(f, a).health()));
gate.veto = false;
var failed = mob(a, EntityType.COW); service.damage(damage(a, failed));
try (var channel = java.nio.channels.FileChannel.open(data.resolve("characters/.records.lock"),
java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid()); service.mobDeath(death(failed)); drain(f);
}
assertEquals(4, a.player.getHealth());
service.mobDeath(death(failed)); drain(f); assertEquals(4, a.player.getHealth(), "A failed consume-once defeat is not replayed");
var nativeFailure = mob(a, EntityType.COW); service.damage(damage(a, nativeFailure));
doThrow(new IllegalStateException("native health publication refused")).when(a.player).setHealth(5d);
service.mobDeath(death(nativeFailure));
assertThrows(IllegalStateException.class, () -> drain(f));
assertEquals(4, a.player.getHealth()); assertEquals(0, new BigDecimal("5").compareTo(current(f, a).health()));
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty());
var reasons = records().stream().filter(row -> row.getAsJsonObject("data").has("healing_source")
&& row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")
&& row.getAsJsonObject("data").has("reason"))
.map(row -> row.getAsJsonObject("data").get("reason").getAsString()).toList();
assertTrue(reasons.contains("native_event_cancelled"));
assertTrue(reasons.contains("health_save_failed"));
assertTrue(reasons.contains("native_health_publication_failed"));
}
@Test void throwingAuditAndUnavailableSourceLocationCannotStopCurrentOwnedNativeHeal() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4"));
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick();
var service = (HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal");
var events = HealthSteal.class.getDeclaredField("events"); events.setAccessible(true);
var attempts = new ArrayList<games.dmg.fantasystick.events.EventInput>();
events.set(service, new HealthStealEventBridge(event -> { attempts.add(event); throw new IllegalStateException("audit unavailable"); }, Clock.systemUTC()));
var target = mob(a, EntityType.COW); var damage = damage(a, target);
// Invoke the actual owner handlers in isolation: unrelated reward adapters are not this fault's subject.
service.damage(damage);
var missing = mock(Location.class); when(missing.getWorld()).thenThrow(new IllegalArgumentException("World unloaded"));
doReturn(missing).when(a.player).getLocation();
service.mobDeath(death(target)); drain(f);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
assertEquals(5, a.player.getHealth()); assertEquals(0, new BigDecimal("5").compareTo(current(f, a).health()));
service.mobDeath(death(target)); drain(f); assertEquals(5, a.player.getHealth());
assertEquals(1, attempts.stream().filter(event -> event.type().equals("combat.healed")).count());
var heal = attempts.stream().filter(event -> event.type().equals("combat.healed")).findFirst().orElseThrow();
assertNull(heal.context().location()); assertEquals("unavailable", heal.data().get("location_capture_quality"));
f.plugin.setEnabled(false); f.awaitWorkers();
}
assertTrue(heals().isEmpty(), "Throwing non-durable observer makes no export claim");
}
@Test void registeredFutureConfigurationCapsPositiveHealingAndFullHealthCannotClaimAnother() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("21.5"));
f.adminCommand.execute(a.player, "fstickadmin", new String[]{"config", "health-steal-healing-per-kill", "3"});
var settings = (games.dmg.fantasystick.persistence.HealthStealSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealSettings");
ReskillEventsNativeTest.await(f, () -> settings.value().equals("3"));
var target = mob(a, EntityType.COW); hit(f, a, target); die(f, target); drain(f);
assertEquals(22, a.player.getHealth()); awaitHeals(1);
var full = mob(a, EntityType.BEE); hit(f, a, full); die(f, full); drain(f);
assertEquals(22, a.player.getHealth());
f.plugin.setEnabled(false);
}
var rows = heals(); assertEquals(1, rows.size()); var values = rows.getFirst().getAsJsonObject("data");
assertEquals("3", values.get("configured_amount").getAsString());
assertEquals("0.5", values.get("requested_amount").getAsString());
assertEquals("0.5", values.get("final_amount").getAsString());
assertEquals("22.0", values.get("maximum_health").getAsString());
}
@SuppressWarnings("unchecked")
@Test void registeredTrainingDummyMeasurementCannotHealAnInjuredLearnedCharacter() throws Exception {
configure();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(f, new BigDecimal("4"));
var dummy = TrainingDummyNativeTest.zombie(a.core.location.clone().add(1, 0, 0));
int x = a.core.location.getBlockX() >> 4, z = a.core.location.getBlockZ() >> 4;
var chunk = mock(org.bukkit.Chunk.class); when(chunk.getWorld()).thenReturn(a.world);
when(chunk.getX()).thenReturn(x); when(chunk.getZ()).thenReturn(z); when(chunk.getEntities()).thenReturn(new org.bukkit.entity.Entity[0]);
when(a.world.isChunkLoaded(x, z)).thenReturn(true); when(a.world.getChunkAt(x, z)).thenReturn(chunk);
doAnswer(call -> { ((java.util.function.Consumer<org.bukkit.entity.Zombie>) call.getArgument(2)).accept(dummy); return dummy; })
.when(a.world).spawn(any(Location.class), eq(org.bukkit.entity.Zombie.class), any(java.util.function.Consumer.class));
f.adminCommand.execute(a.player, "fstickadmin", new String[]{"training-dummy", "place", "Audit Dummy"});
var controller = (games.dmg.fantasystick.training.TrainingDummyController)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "trainingDummyController");
ReskillEventsNativeTest.await(f, () -> controller.tagged(dummy));
when(dummy.getHealth()).thenReturn(1d);
var measured = new ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var event = damage(a, dummy); measured.add(event); f.dispatcher.accept(event); return null;
}).when(dummy).damage(anyDouble(), same(a.player));
var before = current(f, a); a.core.inventory.getInventory().setSelectedSlot(1);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, dummy, true));
assertEquals(1, measured.size()); assertTrue(measured.getFirst().isCancelled());
die(f, dummy); drain(f);
assertEquals(4, a.player.getHealth()); assertEquals(before.health(), current(f, a).health());
assertEquals(before.experience(), current(f, a).experience()); assertEquals(before.coins(), current(f, a).coins());
f.plugin.setEnabled(false);
}
assertTrue(heals().isEmpty());
}
private void configure() throws Exception {
Files.writeString(data.resolve("config.yml"), "character-swap-cooldown-seconds: 0\nhealth-steal-healing-per-kill: 1\n"
+ "out-of-combat-healing-delay-seconds: 31536000\n");
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f, BigDecimal health) throws Exception {
var a = new FantasyStickLifecycleNativeTest.Actor(data); var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(a.core.record.id()).orElseThrow(); var stats = new EnumMap<>(old.stats());
stats.put(Stat.STR, BigInteger.valueOf(14)); stats.put(Stat.CON, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats,
30, BigInteger.valueOf(2900), BigInteger.ONE.shiftLeft(100), health, old.learned(), old.loadout(),
List.of("health_steal", "", ""), old.cooldowns(), old.restrictedKills(), false, null, null), old.revision());
f.players.put(a.core.id, a.player); f.activate(a); return a;
}
private static LivingEntity mob(FantasyStickLifecycleNativeTest.Actor a, EntityType type) {
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(UUID.randomUUID());
when(target.getType()).thenReturn(type); when(target.getHealth()).thenReturn(1d); when(target.getAbsorptionAmount()).thenReturn(0d);
when(target.getLocation()).thenReturn(new Location(a.world, 1, 64, 0)); return target;
}
private static EntityDamageByEntityEvent damage(FantasyStickLifecycleNativeTest.Actor a, LivingEntity target) {
var source = mock(org.bukkit.damage.DamageSource.class); when(source.getDirectEntity()).thenReturn(a.player);
when(source.getCausingEntity()).thenReturn(a.player);
return new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 2);
}
private static void hit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a, LivingEntity target) {
f.dispatcher.accept(damage(a, target));
}
private static EntityDeathEvent death(LivingEntity target) {
return new EntityDeathEvent(target, mock(org.bukkit.damage.DamageSource.class), new ArrayList<>(), 0);
}
private static void die(FantasyStickLifecycleNativeTest.Fixture f, LivingEntity target) { f.dispatcher.accept(death(target)); }
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
private static CharacterStorage storage(FantasyStickLifecycleNativeTest.Fixture f) {
return (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
}
private static CharacterRecord current(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor a) {
var sessions = (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
return sessions.currentRecord(sessions.active(a.core.id).orElseThrow()).orElseThrow();
}
private static void drain(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private List<JsonObject> records() throws Exception { return ReskillEventsNativeTest.records(data); }
private List<JsonObject> heals() throws Exception {
return records().stream().filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + "combat.healed"))
.filter(row -> row.getAsJsonObject("data").get("healing_source").getAsString().equals("health_steal")).toList();
}
private void awaitHeals(int count) throws Exception {
for (int i = 0; i < 500 && heals().size() != count; i++) Thread.sleep(10);
assertEquals(count, heals().size());
}
}
@@ -41,8 +41,15 @@ class HealthStealNativeTest {
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "health_steal")); settle(f);
var learned = record(f, a); assertEquals("health_steal", learned.passives().getFirst());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); var activeLoadout = learned.loadout();
awaitExport(data, "passive.learned", 1);
var learnedAudit = events(data, "passive.learned").getFirst().getAsJsonObject("data");
assertEquals("40", learnedAudit.get("points_before").getAsString());
assertEquals("39", learnedAudit.get("points_after").getAsString());
assertEquals("14", learnedAudit.getAsJsonObject("stat_values_before").get("STR").getAsString());
assertEquals("12", learnedAudit.getAsJsonObject("stat_values_before").get("CON").getAsString());
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("arena");
var basicTarget = mob(EntityType.COW, new Location(world, 1, 64, 0), 1);
doAnswer(call -> { hit(f, a.player, basicTarget, (Double) call.getArgument(0), false); return null; })
.when(basicTarget).damage(anyDouble(), same(a.player));
@@ -50,6 +57,16 @@ class HealthStealNativeTest {
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, basicTarget, true));
die(f, basicTarget); settle(f); settle(f);
assertEquals(5, a.player.getHealth()); assertEquals(0, BigDecimal.valueOf(5).compareTo(record(f, a).health()));
awaitExport(data, "combat.healed", 1);
var firstHeal = events(data, "combat.healed").getFirst().getAsJsonObject("data");
assertEquals(basicTarget.getUniqueId().toString(), firstHeal.get("target_id").getAsString());
assertEquals(a.core.id.toString(), firstHeal.get("player_id").getAsString());
assertEquals(learned.id().toString(), firstHeal.get("character_id").getAsString());
assertEquals("4.0", firstHeal.get("health_before").getAsString());
assertEquals("5.0", firstHeal.get("health_after").getAsString());
assertEquals("1.0", firstHeal.get("final_amount").getAsString());
assertEquals("health_steal", firstHeal.get("healing_source").getAsString());
assertEquals("acknowledged", firstHeal.get("native_publication").getAsString());
die(f, basicTarget); settle(f); assertEquals(5, a.player.getHealth(), "A repeated death callback is consumed");
var projectileTarget = mob(EntityType.ZOMBIE, new Location(world, 2, 64, 0), 1);
@@ -68,6 +85,8 @@ class HealthStealNativeTest {
assertEquals(BigInteger.valueOf(2_900), capped.experience(),
"The final blower heals with multiple contributors even when XP is already capped");
var victimSession = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")).active(b.core.id).orElseThrow().session();
b.player.setHealth(1); hit(f, a.player, b.player, 4, false);
var defeat = new org.bukkit.event.entity.PlayerDeathEvent(b.player, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
@@ -89,6 +108,28 @@ class HealthStealNativeTest {
var afterUnlearn = mob(EntityType.COW, new Location(world, 5, 64, 0), 1);
hit(f, transferred.player, afterUnlearn, 4, false); die(f, afterUnlearn); settle(f);
assertEquals(8, transferred.player.getHealth(), "Unlearning removes only the passive's future healing");
awaitExport(data, "passive.unlearned", 1); awaitExport(data, "combat.healed", 5);
f.plugin.setEnabled(false);
var rows = events(data, "combat.healed"); assertEquals(5, rows.size());
assertEquals(java.util.Set.of(basicTarget.getUniqueId().toString(), projectileTarget.getUniqueId().toString(),
spacingBlocked.getUniqueId().toString(), assisted.getUniqueId().toString(), b.core.id.toString()),
rows.stream().map(row -> row.getAsJsonObject("data").get("target_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals(5, rows.stream().map(row -> row.get("id").getAsString()).distinct().count());
assertEquals(5, rows.stream().map(row -> row.getAsJsonObject("data").get("defeat_id").getAsString()).distinct().count());
for (var row : rows) {
var values = row.getAsJsonObject("data");
assertNotNull(UUID.fromString(values.get("damage_causation_id").getAsString()));
assertTrue(values.has("location")); assertTrue(values.has("target_location"));
assertEquals("consumed_once", values.get("defeat_consumption").getAsString());
if (values.get("target_id").getAsString().equals(b.core.id.toString())) {
assertEquals(capped.id().toString(), values.get("target_character_id").getAsString());
assertEquals(victimSession.toString(), values.get("target_session_id").getAsString());
}
}
var unlearnAudit = events(data, "passive.unlearned").getFirst().getAsJsonObject("data");
assertEquals(transferred.core.id.toString(), unlearnAudit.get("player_id").getAsString());
assertEquals(saved.unspentPoints().toString(), unlearnAudit.get("points_before").getAsString());
assertEquals(unlearned.unspentPoints().toString(), unlearnAudit.get("points_after").getAsString());
}
}
@@ -104,13 +145,16 @@ class HealthStealNativeTest {
hit(f, a.player, target, 4, false); die(f, target); settle(f);
assertEquals(0, before.health().compareTo(record(f, a).health())); assertEquals(4, a.player.getHealth());
((games.dmg.fantasystick.passive.HealthSteal) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthSteal"))
.configure(games.dmg.fantasystick.passive.HealthSteal.Settings.defaults());
var ordinary = mock(org.bukkit.entity.Player.class); when(ordinary.getUniqueId()).thenReturn(UUID.randomUUID());
when(ordinary.getHealth()).thenReturn(1d); when(ordinary.getAbsorptionAmount()).thenReturn(0d);
hit(f, a.player, ordinary, 4, false);
f.dispatcher.accept(new org.bukkit.event.entity.PlayerDeathEvent(ordinary, mock(org.bukkit.damage.DamageSource.class),
new ArrayList<>(), 0, net.kyori.adventure.text.Component.text("death"), false));
settle(f); assertEquals(4, a.player.getHealth(), "Ordinary player targets never qualify");
settle(f); assertEquals(4, a.player.getHealth(), "Ordinary player targets never qualify even with healing enabled");
}
assertTrue(events(data, "combat.healed").isEmpty());
}
@Test void changedValueCapsAtFullHealthAndCancellationFailureOrStaleSourceNeverPublishes() throws Exception {
@@ -182,7 +226,10 @@ class HealthStealNativeTest {
@Test void configurationIsDurablePermissionAwareAndPrefixFiltered() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var sender = new EquipmentAdminCommandNativeTest.Sender();
f.plugin.setEnabled(true);
((games.dmg.fantasystick.events.LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog"))
.ready().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var sender = new EquipmentAdminCommandNativeTest.Sender();
assertEquals(List.of("health-steal-healing-per-kill"),
f.command.tabComplete(sender.port, "fantasystick", new String[]{"config", "health-steal"}));
assertEquals(List.of("1"), f.command.tabComplete(sender.port, "fantasystick",
@@ -198,9 +245,39 @@ class HealthStealNativeTest {
var settings = (games.dmg.fantasystick.persistence.HealthStealSettings)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "healthStealSettings");
assertEquals("3", settings.value());
for (var invalid : List.of("-1", "1025", "1.5", "oops")) {
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "health-steal-healing-per-kill", invalid});
assertTrue(sender.messages.getLast().contains("invalid"));
}
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE); var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(sender.port, "fstickadmin", new String[]{"config", "health-steal-healing-per-kill", "4"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
assertEquals("3", settings.value()); assertTrue(sender.messages.getLast().contains("save failed"));
f.plugin.setEnabled(false);
var reads = events(data, "config.read"); var changes = events(data, "config.changed");
assertEquals(1, reads.size()); assertEquals("1", reads.getFirst().getAsJsonObject("data").get("after").getAsString());
assertEquals(1, changes.size()); var change = changes.getFirst().getAsJsonObject("data");
assertEquals("1", change.get("before").getAsString()); assertEquals("3", change.get("after").getAsString());
assertEquals("future_qualifying_defeats", change.get("application").getAsString());
}
}
private static List<com.google.gson.JsonObject> events(Path data, String type) {
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + type))
.filter(row -> { var values = row.getAsJsonObject("data");
return values.has("passive_id") && values.get("passive_id").getAsString().equals("health_steal")
|| values.has("key") && values.get("key").getAsString().equals("health-steal-healing-per-kill"); }).toList();
} catch (Exception failure) { throw new IllegalStateException(failure); }
}
private static void awaitExport(Path data, String type, int count) throws Exception {
for (int i = 0; i < 500 && events(data, type).size() != count; i++) Thread.sleep(10);
assertEquals(count, events(data, type).size(), "Health Steal " + type + " must reach actual JSONL");
}
private static void makeEligible(Path data, FantasyStickLifecycleNativeTest.Actor actor, BigDecimal health) throws Exception {
makeEligible(data, actor, health, List.of("", "", ""));
}