This commit is contained in:
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("", "", ""));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user