feat(inspire): export accepted native absorption events
Release / release (push) Failing after 9m42s

This commit is contained in:
dmg committed 2026-09-26 08:57:01 -04:00
1 parent 93897ce516
commit 9b378b4237
9 files changed
+403 -18

No files matched your search

@@ -767,6 +767,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("shield_bash");
abilityEngine.enableCastEventsFor("hex");
abilityEngine.enableCastEventsFor("inspire");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -73,7 +73,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|| definition.id().equals("shield_bash") || definition.id().equals("hex"))
|| definition.id().equals("shield_bash") || definition.id().equals("hex")
|| definition.id().equals("inspire"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
@@ -354,6 +355,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final Map<String, Object> castEvidence = new HashMap<>();
private boolean castPublished;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Map<UUID, Map<String, Object>> observedEndEvidence = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
@@ -496,6 +498,17 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
incoming, remaining, actual, evidence);
}
@Override public void acceptedAbsorption(UUID application, Player target, Entity attacker,
double incoming, double before, double after,
double consumed, double remaining,
org.bukkit.Location targetAtHit, org.bukkit.Location attackerAtHit) {
if (castEvents == null || audit == null) return;
try {
var aided = sessions.active(target.getUniqueId()).filter(sessions::current).orElse(null);
castEvents.absorbed(audit, application, target, aided, attacker, incoming,
before, after, consumed, remaining, targetAtHit, attackerAtHit);
} catch (RuntimeException ignored) { /* Recording cannot change native absorption or support. */ }
}
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
Entity target, double base, double bonus, double finalDamage,
java.math.BigDecimal percent) {
@@ -503,11 +516,16 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
}
@Override public void effectEnded(UUID application, String reason) {
effectEnded(application, reason, Map.of());
effectEnded(application, reason, observedEndEvidence.getOrDefault(application, Map.of()));
}
@Override public void effectEndEvidence(UUID application, Map<String, Object> evidence) {
if (observedEffects.containsKey(application))
observedEndEvidence.put(application, Map.copyOf(Objects.requireNonNull(evidence)));
}
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
var effectId = observedEffects.remove(application);
observedEndEvidence.remove(application);
if (effectId != null && castEvents != null)
castEvents.effectEnded(audit, application, effectId, reason, evidence);
}
@@ -45,6 +45,11 @@ public interface AbilityExecution extends AutoCloseable {
/** Record only the winning owned outgoing reduction after final uncancelled native damage. */
default void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker, Entity protectedTarget,
double incoming, double remaining, double actual) { }
/** Record only observed native consumption of this execution's owned temporary health. */
default void acceptedAbsorption(UUID application, org.bukkit.entity.Player target, Entity attacker,
double incoming, double before, double after, double consumed,
double remaining, org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit) { }
/** Record a winning outgoing bonus only after a positive uncancelled native hit. */
void acceptedBoost(games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target,
@@ -55,6 +60,8 @@ public interface AbilityExecution extends AutoCloseable {
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
effectEnded(applicationId, reason);
}
/** Capture remaining owned effect state during resource cleanup for the terminal effect record. */
default void effectEndEvidence(UUID applicationId, Map<String, Object> evidence) { }
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
default void castEvidence(Map<String, Object> evidence) { }
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
@@ -8,7 +8,38 @@ public final class Inspire implements AbilityEffect,AutoCloseable{
public AbilityDefinition definition(){var s=settings;return new AbilityDefinition("inspire","Inspire",Material.AMETHYST_SHARD,List.of("Grant "+s.baseTemporaryHealth()+" + CHR modifier temporary health for "+seconds(s.duration())+" seconds.","Range "+BigDecimal.valueOf(s.range()).stripTrailingZeros().toPlainString()+" blocks for an aimed fantasy player."),"Sneak+Use selects self; normal Use requires an aimed fantasy player.",Map.of(Stat.CHR,BigInteger.valueOf(12),Stat.WIS,BigInteger.TEN),BigInteger.ONE,AbilityDefinition.Category.BUF,AbilityDefinition.Activation.DELAYED,ControlInput.Gesture.USE,s.cooldown(),Map.of("base-temporary-health",new BigDecimal(s.baseTemporaryHealth()),"range",BigDecimal.valueOf(s.range()).stripTrailingZeros(),"duration-seconds",BigDecimal.valueOf(s.duration().toNanos()).movePointLeft(9)),AbilityDefinition.TargetPolicy.ALLY,this);}
@Override public boolean accepts(ControlInput input){if(closed||!(input.target() instanceof Player target))return false;if(input.player().isSneaking())return target==input.player();return target!=input.player()&&inRange(input.player(),target,settings.range());}
public static BigInteger amount(BigInteger charisma){return amount(BigInteger.valueOf(4),charisma);}static BigInteger amount(BigInteger base,BigInteger charisma){Objects.requireNonNull(base);Objects.requireNonNull(charisma);if(base.signum()<0||charisma.signum()<0)throw new IllegalArgumentException("Invalid Inspire amount");var delta=charisma.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&&divided[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;var s=from(execution.definition());if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){execution.finish();return;}var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.finish();return;}execution.own(temporaryHealth.grant(execution,target,nativeAmount));execution.later(s.duration(),execution::finish);}
@Override public void activate(AbilityExecution execution) {
if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof Player target))return;
var s=from(execution.definition());
if(execution.input().player().isSneaking()?target!=execution.input().player():target==execution.input().player()||!inRange(execution.input().player(),target,s.range())){
execution.resolve("miss",Map.of("reason","target_ineligible_after_cooldown"));execution.finish();return;
}
var exact=amount(s.baseTemporaryHealth(),execution.character().stats().get(Stat.CHR));
double nativeAmount=exact.bitLength()>1023?Double.MAX_VALUE:exact.doubleValue();
if(!Double.isFinite(nativeAmount)||nativeAmount<=0){execution.resolve("miss",Map.of("reason","no_positive_temporary_health"));execution.finish();return;}
var application=new java.util.concurrent.atomic.AtomicReference<UUID>();
var grant=temporaryHealth.grantObserved(execution,target,nativeAmount,accepted->{
try{execution.acceptedAbsorption(application.get(),accepted.target(),accepted.attacker(),accepted.incoming(),
accepted.before(),accepted.after(),accepted.consumed(),accepted.remaining(),accepted.targetAtHit(),accepted.attackerAtHit());}
catch(RuntimeException ignored){/* Audit cannot change native absorption or party rewards. */}
},remaining->{
var id=application.get();
if(id!=null)execution.effectEndEvidence(id,Map.of("remaining_amount",
BigDecimal.valueOf(remaining).stripTrailingZeros().toPlainString()));
});
execution.own(grant);
var evidence=new LinkedHashMap<String,Object>();
evidence.put("granted_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("remaining_amount",BigDecimal.valueOf(nativeAmount).stripTrailingZeros().toPlainString());
evidence.put("duration_seconds",seconds(s.duration()));
var activatedAt=execution.now();if(activatedAt!=null)evidence.put("effect_until",activatedAt.plus(s.duration()).toString());
temporaryHealth.currentSession(target).ifPresent(active->{
evidence.put("target_character_id",active.character().toString());
evidence.put("target_session_id",active.session().toString());
});
application.set(execution.effectApplied("inspire",target,evidence));
execution.later(s.duration(),()->{execution.resolve("expired");execution.finish();});
}
private static boolean inRange(Player source,Player target,double range){try{var a=source.getLocation();var b=target.getLocation();return a.getWorld()==b.getWorld()&&a.distanceSquared(b)<=range*range&&source.hasLineOfSight(target);}catch(RuntimeException invalid){return false;}}
private static Settings from(AbilityDefinition d){var p=d.parameters();return new Settings(p.get("base-temporary-health").toBigIntegerExact(),d.cooldown(),p.get("range").doubleValue(),Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()));}
private static String seconds(Duration d){return BigDecimal.valueOf(d.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
@@ -13,21 +13,83 @@ import org.bukkit.projectiles.ProjectileSource;
/** Plugin-owned temporary-health ledger layered over unrelated native absorption. */
public final class TemporaryHealth implements Listener,AutoCloseable{
public record Support(UUID target,UUID supportingCharacter,EquipmentSessions.Active aided,BigDecimal amount){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount){sequence=++sequences;execution=e;target=t;remaining=amount;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));}}
public record Accepted(Player target,Entity attacker,double incoming,double before,double after,
double consumed,double remaining,org.bukkit.Location targetAtHit,
org.bukkit.Location attackerAtHit){}
private final class Grant implements AutoCloseable{final long sequence;final AbilityExecution execution;final Player target;
final Consumer<Accepted> observation;final Consumer<Double> onEnd;double remaining;boolean closed;
Grant(AbilityExecution e,Player t,double amount,Consumer<Accepted> observation,Consumer<Double> onEnd){
sequence=++sequences;execution=e;target=t;remaining=amount;this.observation=observation;this.onEnd=onEnd;}
@Override public void close(){if(closed)return;double before=owned(target.getUniqueId());closed=true;var list=grants.get(target.getUniqueId());if(list!=null){list.remove(this);if(list.isEmpty())grants.remove(target.getUniqueId());}double after=owned(target.getUniqueId());removeNative(target,Math.max(0,before-after));if(onEnd!=null)try{onEnd.accept(Math.max(0,remaining));}catch(RuntimeException ignored){/* Cleanup is independent of recording. */}}
}
private final EquipmentSessions sessions;private final PartyRoster parties;private final Consumer<Runnable> nextTick;private final Consumer<Support> supports;
private final Map<UUID,List<Grant>> grants=new HashMap<>();private long sequences;private boolean closed;
private final Map<UUID,List<Grant>> grants=new HashMap<>();
private final Set<EntityDamageEvent> pending=Collections.newSetFromMap(new IdentityHashMap<>());
private long sequences;private boolean closed;
public TemporaryHealth(EquipmentSessions sessions,PartyRoster parties,Consumer<Runnable> nextTick,Consumer<Support> supports){this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.nextTick=Objects.requireNonNull(nextTick);this.supports=Objects.requireNonNull(supports);}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){Objects.requireNonNull(execution);Objects.requireNonNull(target);if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);double after=owned(target.getUniqueId());addNative(target,Math.max(0,after-before));return grant;}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){if(closed||!(event.getEntity() instanceof Player target))return;var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());if(!Double.isFinite(before)||before<=0||owned<=0)return;Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;var aided=sessions.active(target.getUniqueId()).orElse(null);boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);UUID enemy=attacker==null?null:attacker.getUniqueId();
try{nextTick.accept(()->consume(target,before,owned,owner,qualifies?aided:null,enemy));}catch(RuntimeException ignored){}}
private void consume(Player target,double before,double ownedBefore,Grant owner,EquipmentSessions.Active aided,UUID enemy){if(closed)return;double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));if(!Double.isFinite(consumed)||consumed<=0)return;var list=grants.get(target.getUniqueId());if(list==null)return;var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));}
public AutoCloseable grant(AbilityExecution execution,Player target,double amount){
return grantObserved(execution,target,amount,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,Consumer<Accepted> observation){
return grantObserved(execution,target,amount,observation,null);
}
public AutoCloseable grantObserved(AbilityExecution execution,Player target,double amount,
Consumer<Accepted> observation,Consumer<Double> onEnd){
Objects.requireNonNull(execution);Objects.requireNonNull(target);
if(closed||!Double.isFinite(amount)||amount<=0)throw new IllegalArgumentException("Invalid temporary health grant");
double before=owned(target.getUniqueId());var grant=new Grant(execution,target,amount,observation,onEnd);
addNative(target,Math.max(0,amount-before)); // Do not retain an owned grant if the native setter refuses it.
grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);
return grant;
}
public Optional<EquipmentSessions.Active> currentSession(Player target){
return sessions.active(target.getUniqueId()).filter(sessions::current);
}
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void damage(EntityDamageEvent event){
if(closed||pending.contains(event)||!(event.getEntity() instanceof Player target)
||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
var owner=strongest(target.getUniqueId());if(owner==null||!owner.execution.stillCurrent())return;
double before=target.getAbsorptionAmount(),owned=owned(target.getUniqueId());
if(!Double.isFinite(before)||before<=0||owned<=0)return;
Entity attacker=event instanceof EntityDamageByEntityEvent by?attacker(by):null;
var targetAtHit=auditLocation(target);var attackerAtHit=auditLocation(attacker);
var aided=sessions.active(target.getUniqueId()).orElse(null);
boolean qualifies=aided!=null&&sessions.current(aided)&&!aided.character().equals(owner.execution.input().session().character())&&sameParty(owner.execution.input().player(),target);
UUID enemy=attacker==null?null:attacker.getUniqueId();double incoming=event.getDamage();
pending.add(event);
try{nextTick.accept(()->{
try{consume(event,target,before,owned,owner,qualifies?aided:null,enemy,attacker,incoming,targetAtHit,attackerAtHit);}
finally{pending.remove(event);}
});}
catch(RuntimeException ignored){pending.remove(event);}
}
private void consume(EntityDamageEvent event,Player target,double before,double ownedBefore,Grant owner,
EquipmentSessions.Active aided,UUID enemy,Entity attacker,double incoming,
org.bukkit.Location targetAtHit,org.bukkit.Location attackerAtHit){
if(closed||event.isCancelled()||!Double.isFinite(event.getDamage())||event.getDamage()<=0)return;
double after=target.getAbsorptionAmount();double consumed=Math.min(ownedBefore,Math.max(0,before-after));
if(!Double.isFinite(consumed)||consumed<=0)return;
var list=grants.get(target.getUniqueId());if(list==null||owner.closed||!list.contains(owner)||!owner.execution.stillCurrent())return;
var amount=BigDecimal.valueOf(consumed).stripTrailingZeros();
try{owner.execution.report(AbilityReport.Type.PREVENTION,target,amount);}
catch(RuntimeException ignored){/* Feedback must not affect accepted absorption. */}
for(var grant:List.copyOf(list))grant.remaining=Math.max(0,grant.remaining-consumed);
list.removeIf(g->g.closed||g.remaining<=1e-9);if(list.isEmpty())grants.remove(target.getUniqueId());
if(owner.observation!=null)try{owner.observation.accept(new Accepted(target,attacker,incoming,before,after,
consumed,Math.max(0,owner.remaining),targetAtHit,attackerAtHit));}
catch(RuntimeException ignored){/* Audit must not affect native absorption or rewards. */}
if(aided!=null&&enemy!=null)supports.accept(new Support(enemy,owner.execution.input().session().character(),aided,amount));
}
private static org.bukkit.Location auditLocation(Entity entity){
if(entity==null)return null;
try{var location=entity.getLocation();return location==null?null:location.clone();}
catch(RuntimeException unavailable){return null;}
}
private boolean sameParty(Player source,Player target){var a=parties.partyOf(source.getUniqueId()).orElse(null);var b=parties.partyOf(target.getUniqueId()).orElse(null);return a!=null&&b!=null&&a.id().equals(b.id());}
private Grant strongest(UUID target){Grant result=null;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>0&&grant.execution.stillCurrent()&&(result==null||grant.remaining>result.remaining||grant.remaining==result.remaining&&grant.sequence>result.sequence))result=grant;return result;}
private double owned(UUID target){double result=0;var list=grants.get(target);if(list!=null)for(var grant:List.copyOf(list))if(!grant.closed&&grant.remaining>result)result=grant.remaining;return result;}
private static Entity attacker(EntityDamageByEntityEvent event){Entity causing=event.getDamageSource().getCausingEntity();if(causing!=null)return causing;if(event.getDamager() instanceof Projectile p){ProjectileSource shooter=p.getShooter();if(shooter instanceof Entity e)return e;}return event.getDamager();}
private static void addNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount(),next=current+amount;if(!Double.isFinite(current)||current<0||!Double.isFinite(next))throw new IllegalStateException("Temporary health exceeds native range");target.setAbsorptionAmount(next);}
private static void removeNative(Player target,double amount){if(amount<=0)return;double current=target.getAbsorptionAmount();if(Double.isFinite(current)&&current>=0)target.setAbsorptionAmount(Math.max(0,current-amount));}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();}
@Override public void close(){if(closed)return;for(var list:List.copyOf(grants.values()))for(var grant:List.copyOf(list))grant.close();closed=true;grants.clear();pending.clear();}
}
@@ -256,6 +256,39 @@ public final class AbilityCastEventBridge {
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
}
/** Inspire reports actual accepted native absorption, not a displayed health grant. */
public void absorbed(Cast cast, UUID effectApplication, org.bukkit.entity.Player protectedTarget,
games.dmg.fantasystick.session.EquipmentSessions.Active aided, Entity attacker,
double incoming, double before, double after, double consumed, double remaining,
Location targetAtHit, Location attackerAtHit) {
if (cast == null || effectApplication == null || !Double.isFinite(consumed) || consumed <= 0) return;
try {
var data = new LinkedHashMap<String, Object>();
UUID application = UUID.randomUUID();
data.put("application_id", application.toString());
data.put("effect_application_id", effectApplication.toString());
data.put("causation_id", cast.actionId().toString());
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
data.put("protected_target_type", "PLAYER");
if (aided != null) {
data.put("protected_character_id", aided.character().toString());
data.put("protected_session_id", aided.session().toString());
}
if (targetAtHit != null) data.put("protected_location", locationData(targetAtHit));
if (attacker != null) {
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
if (attackerAtHit != null) data.put("attacker_location", locationData(attackerAtHit));
} else data.put("attacker_status", "not_applicable");
data.put("incoming_amount", decimal(incoming));
data.put("absorption_before", decimal(before));
data.put("absorption_after", decimal(after));
data.put("actual_amount", decimal(consumed));
data.put("remaining_amount", decimal(remaining));
emit(cast, "combat.prevented", "accepted", data, application);
} catch (RuntimeException ignored) { /* Native absorption and party rewards are independent of logging. */ }
}
public void boosted(Cast cast, games.dmg.fantasystick.session.EquipmentSessions.Active attacking,
org.bukkit.entity.Player attacker, Entity target, double base, double bonus,
double finalDamage, java.math.BigDecimal percent) {
@@ -72,11 +72,12 @@ public final class InspireSettings implements AutoCloseable {
public Inspire.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1358,7 +1358,40 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
});
}
private void inspireConfiguration(CommandSender sender,String[] args){String key=args[1];if(args.length==2){sender.sendMessage(key+"="+inspireSettings.value(key)+" (applies to future Inspire casts only)");return;}java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;try{operation=inspireSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Inspire setting is invalid; the effective value is unchanged.");return;}operation.whenComplete((saved,failure)->{if(failure!=null)report(null,"config-"+key,failure);if(closed)return;try{nextTick.accept(()->{if(closed)return;if(failure==null){inspire.configure(saved.settings());abilities.replace(inspire.definition());inspireSettings.publish(saved);}if(!sender.hasPermission(PERMISSION)||sender instanceof org.bukkit.entity.Player player&&!player.isOnline())return;sender.sendMessage(failure==null?"Saved "+key+"="+saved.value()+" for future Inspire casts; active effects and deadlines are unchanged.":"Inspire configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});}
private void inspireConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = inspireSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "inspire", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Inspire casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.InspireSettings.Update> operation;
try { operation = inspireSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) {
sender.sendMessage("Inspire setting is invalid; the effective value is unchanged."); return;
}
operation.whenComplete((saved, failure) -> {
if (failure != null) report(null, "config-" + key, failure);
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
String effectiveBefore = failure == null ? inspireSettings.value(key) : null;
boolean applied = failure == null && inspireSettings.publish(saved);
if (applied) {
inspire.configure(inspireSettings.settings()); abilities.replace(inspire.definition());
if (abilityConfigEvents != null)
abilityConfigEvents.changed(actor, "inspire", key, effectiveBefore, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure != null
? "Inspire configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Inspire casts; active effects and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + inspireSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
private void mendingLightConfiguration(CommandSender sender, String[] args) {
String key = args[1];
@@ -2,24 +2,223 @@ package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;
import games.dmg.fantasystick.persistence.*;import games.dmg.fantasystick.progression.Stat;import java.math.BigInteger;import java.nio.file.Path;import java.util.*;import net.minecraft.network.chat.ClickEvent;import org.bukkit.event.player.PlayerInteractEvent;import org.bukkit.inventory.EquipmentSlot;import org.junit.jupiter.api.*;import org.junit.jupiter.api.io.TempDir;
class InspireNativeTest{@TempDir Path data;@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
@Test void registeredBookSelfCastExpiryReachesExportedJsonlWithoutInventingPrevention()throws Exception{
UUID playerId,characterId,sessionId;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,actor,false);
f.players.put(actor.core.id,actor.player);f.activate(actor);playerId=actor.core.id;characterId=actor.core.record.id();
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
sessionId=sessions.active(playerId).orElseThrow().session();
learnAndAssign(f,actor);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);
doAnswer(call->absorption.get()).when(actor.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(actor.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(actor.player).isSneaking();use(f,actor);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(7,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.learned"));assertEquals(1,count(own,"ability.assigned"));
assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
assertEquals(1,count(own,"effect.ended"));assertEquals(0,count(own,"combat.prevented"));
var cast=record(own,"ability.cast");var applied=record(own,"effect.applied");var ended=record(own,"effect.ended");
assertEquals(playerId.toString(),cast.get("player_id").getAsString());
assertEquals(characterId.toString(),cast.get("character_id").getAsString());
assertEquals(sessionId.toString(),cast.get("session_id").getAsString());
assertTrue(cast.has("cooldown_until"));assertEquals("5",applied.get("granted_amount").getAsString());
assertEquals(applied.get("application_id").getAsString(),ended.get("application_id").getAsString());
assertEquals("5",ended.get("remaining_amount").getAsString(),"unused native protection expires without prevention");
assertEquals("expired",ended.get("reason").getAsString());
assertEquals("expired",record(own,"ability.resolved").get("outcome").getAsString());
}
@Test void registeredAllyAbsorptionRecordsOnlyObservedConsumedHealth()throws Exception{
UUID actorId,characterId,sessionId,targetId,targetCharacterId,targetSessionId,worldId;
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);
withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);
var sessions=(games.dmg.fantasystick.session.EquipmentSessions)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"sessions");
actorId=a.core.id;characterId=a.core.record.id();sessionId=sessions.active(actorId).orElseThrow().session();
targetId=b.core.id;targetCharacterId=b.core.record.id();targetSessionId=sessions.active(targetId).orElseThrow().session();
worldId=UUID.randomUUID();when(a.world.getUID()).thenReturn(worldId);
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();
doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();
doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);
var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(b.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());
assertTrue(f.feedbackCommand.execute(a.player,"fantasyfeedback",new String[]{"off"}));f.tick();
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,1));
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var cancelled=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);cancelled.setCancelled(true);
f.dispatcher.accept(cancelled);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));assertEquals(1,count(own,"effect.applied"));
assertEquals(1,count(own,"combat.prevented"));var consumed=record(own,"combat.prevented");
assertEquals(actorId.toString(),consumed.get("player_id").getAsString());
assertEquals(characterId.toString(),consumed.get("character_id").getAsString());
assertEquals(sessionId.toString(),consumed.get("session_id").getAsString());
assertEquals(targetId.toString(),consumed.get("protected_target_id").getAsString());
assertEquals(targetCharacterId.toString(),consumed.get("protected_character_id").getAsString());
assertEquals(targetSessionId.toString(),consumed.get("protected_session_id").getAsString());
assertEquals(worldId.toString(),consumed.getAsJsonObject("protected_location").get("world_id").getAsString());
assertEquals(8,consumed.getAsJsonObject("protected_location").get("x").getAsDouble(),1e-9);
assertEquals(3,consumed.get("actual_amount").getAsDouble(),1e-9);
assertEquals(record(own,"ability.cast").get("action_id").getAsString(),consumed.get("action_id").getAsString());
assertEquals(record(own,"effect.applied").get("application_id").getAsString(),consumed.get("effect_application_id").getAsString());
}
@Test void unavailableAuditOnlyAttackerLocationCannotInterruptNativeAbsorption()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
when(enemy.getLocation()).thenThrow(new IllegalStateException("unavailable audit location"));
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
var owner=(games.dmg.fantasystick.ability.TemporaryHealth)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"temporaryHealth");
owner.damage(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
assertFalse(record(own,"combat.prevented").has("attacker_location"));
}
@Test void rejectedNativeAbsorptionSetterCannotLeaveAnOrphanOwnedGrant()throws Exception{
var attempts=new java.util.concurrent.atomic.AtomicInteger();
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);
doReturn(0.0).when(a.player).getAbsorptionAmount();
doAnswer(call->{if(attempts.incrementAndGet()==1)throw new IllegalStateException("native refused");
return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(1,attempts.get(),"native admission was attempted once");
}
assertEquals(1,attempts.get(),"an unadmitted effect must not be cleaned up as an owned native grant");
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
assertEquals(0,count(own,"effect.applied"));assertEquals(0,count(own,"combat.prevented"));
assertEquals("failed",record(own,"ability.resolved").get("outcome").getAsString());
}
@Test void duplicateNativeCallbackCannotDoubleClaimOneAbsorptionDrop()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);
}
var own=inspireEvents(data);assertEquals(1,count(own,"combat.prevented"));
assertEquals(3,record(own,"combat.prevented").get("actual_amount").getAsDouble(),1e-9);
}
@Test void rejectedWriterCannotChangeNativeAbsorptionOrInventPrevention()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);
f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);
doAnswer(call->absorption.get()).when(a.player).getAbsorptionAmount();
doAnswer(call->{absorption.set(call.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());
doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
((games.dmg.fantasystick.events.LocalEventLog)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"eventLog")).close();
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
.withCausingEntity(enemy).withDirectEntity(enemy).build();
var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,a.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3);
f.dispatcher.accept(hit);absorption.set(2.0);PartyBookNativeTest.drain(f);
assertEquals(2,absorption.get(),1e-9);f.delayed.getLast().run();assertEquals(0,absorption.get(),1e-9);
}
assertEquals(0,count(inspireEvents(data),"combat.prevented"));
}
@Test void acknowledgedInspireConfigurationReadAndChangeReachExportedJsonl()throws Exception{
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
f.plugin.setEnabled(true);var admin=new EquipmentAdminCommandNativeTest.Sender();admin.allowed=true;
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
assertTrue(admin.messages.getLast().contains("inspire-base-temporary-health=4"));
f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","9"});
var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();
var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");
assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
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(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health","10"});
var failed=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(failed);failed.run();
}
admin.allowed=false;f.command.execute(admin.port,"fstickadmin",new String[]{"config","inspire-base-temporary-health"});
}
var own=inspireEvents(data);assertEquals(1,count(own,"config.read"));assertEquals(1,count(own,"config.changed"));
var changed=record(own,"config.changed");assertEquals("4",changed.get("before").getAsString());
assertEquals("9",changed.get("after").getAsString());
}
@Test void bookAcquisitionSneakUseAddsOwnedTemporaryHealthAndExpiryPreservesUnrelatedAbsorption()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,false);f.players.put(a.core.id,a.player);f.activate(a);a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);String book=PartyBookNativeTest.text(a).replaceAll("\\s+","");assertTrue(book.contains("Inspire"));assertTrue(book.contains("CHR12"));assertTrue(book.contains("WIS10"));assertFalse(book.contains("Cost:"));assertTrue(book.contains("12"));assertTrue(book.contains("6s"));PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
double maximum=a.maximumHealth.getBaseValue();var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);assertEquals(maximum,a.maximumHealth.getBaseValue(),1e-9);assertEquals(120L,f.delayedTicks.getLast());f.delayed.getLast().run();assertEquals(2,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void aimedPartyAllyActualAbsorptionCreatesPersistedSupportShare()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));
doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(5,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3));absorption.set(2.0);PartyBookNativeTest.drain(f);String feedback="Inspire prevented 3 damage to "+b.core.record.name()+".";assertEquals(1,a.messages.stream().filter(feedback::equals).count());assertEquals(1,b.messages.stream().filter(feedback::equals).count());var sourceSheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var holder=new FantasyStickLifecycleNativeTest.Actor(data);holder.core.inventory.setItemInOffHand(sourceSheet);f.players.put(holder.core.id,holder.player);f.activate(holder);
var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}}}
@Test void normalSelfOrdinaryOutOfRangeAndObstructedTargetsRefuseBeforeCooldown()throws Exception{for(int route=0;route<4;route++){var dir=data.resolve("refuse-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;if(route==0)target=a.player;else if(route==1){target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}else{var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);f.activate(b);}doReturn(false).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,route==2?13:8,64,0)).when(target).getLocation();doReturn(route!=3).when(a.player).hasLineOfSight(target);doReturn(target).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("inspire"));}
var own=inspireEvents(dir);assertEquals(0,count(own,"ability.cast"));assertEquals(0,count(own,"effect.applied"));
assertEquals("refused",record(own,"ability.resolved").get("outcome").getAsString());
}}
@Test void nonPartyTargetIsAllowedAndStrongestRemainingCopiesPreserveUnrelatedAbsorption()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 0\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);var c=new FantasyStickLifecycleNativeTest.Actor(data);var b=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);withInspire(data,c,true);f.players.put(a.core.id,a.player);f.players.put(c.core.id,c.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(c);f.activate(b);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(b.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(b.player).setAbsorptionAmount(anyDouble());for(var caster:List.of(a,c)){doReturn(false).when(caster.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(caster.player).getLocation();doReturn(true).when(caster.player).hasLineOfSight(b.player);doReturn(b.player).when(caster.player).getTargetEntity(64);}doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var sender=new EquipmentAdminCommandNativeTest.Sender();f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","6"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();use(f,c);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(9,absorption.get(),1e-9);
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}}
var enemy=mock(org.bukkit.entity.Zombie.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());var source=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,b.player,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,3));absorption.set(6.0);PartyBookNativeTest.drain(f);f.delayed.getLast().run();assertEquals(4,absorption.get(),1e-9);f.delayed.getFirst().run();assertEquals(2,absorption.get(),1e-9);}
var own=inspireEvents(data);assertEquals(2,count(own,"ability.cast"));assertEquals(2,count(own,"effect.applied"));
assertEquals(1,count(own,"combat.prevented"));assertEquals(2,count(own,"effect.ended"));
var applied=own.stream().filter(event->event.get("type").getAsString().endsWith("effect.applied")).toList();
var prevented=record(own,"combat.prevented");
assertEquals(applied.getLast().getAsJsonObject("data").get("application_id").getAsString(),
prevented.get("effect_application_id").getAsString(),"strongest accepted grant owns observed absorption");
assertEquals("7",applied.getLast().getAsJsonObject("data").get("granted_amount").getAsString());
}
@Test void cancelledOrSelfOnlyProtectionCannotManufactureSupportShare()throws Exception{for(int route=0;route<2;route++){var dir=data.resolve("no-support-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);var b=new FantasyStickLifecycleNativeTest.Actor(dir);withInspire(dir,a,true);f.players.put(a.core.id,a.player);f.players.put(b.core.id,b.player);f.activate(a);f.activate(b);var parties=PartyLifecycleNativeTest.parties(f);var party=parties.create(a.core.id).orElseThrow();assertTrue(parties.accept(b.core.id,parties.invite(a.core.id,party.id(),b.core.id).orElseThrow().id()));var target=route==0?b.player:a.player;var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(x->absorption.get()).when(target).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(target).setAbsorptionAmount(anyDouble());doReturn(route==1).when(a.player).isSneaking();doReturn(new org.bukkit.Location(a.world,0,64,0)).when(a.player).getLocation();doReturn(new org.bukkit.Location(a.world,8,64,0)).when(b.player).getLocation();doReturn(true).when(a.player).hasLineOfSight(b.player);doReturn(b.player).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(java.util.UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new org.bukkit.Location(a.world,8,64,0));when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());var enemySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new org.bukkit.event.entity.EntityDamageByEntityEvent(enemy,target,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,enemySource,3);if(route==0)hit.setCancelled(true);f.dispatcher.accept(hit);if(route==1){absorption.set(2.0);PartyBookNativeTest.drain(f);}var allySource=org.bukkit.damage.DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(b.player).withDirectEntity(b.player).build();f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(b.player,enemy,org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,allySource,3));f.dispatcher.accept(new org.bukkit.event.entity.EntityDeathEvent(enemy,allySource,new java.util.ArrayList<>(),0));for(int i=0;i<4;i++)PartyBookNativeTest.drain(f);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertEquals(BigInteger.valueOf(5),storage.find(a.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage.find(b.core.record.id()).get().orElseThrow().experience());}}}
@Test void exactFormulaSupportsFiniteUncappedCharisma(){assertEquals(BigInteger.valueOf(5),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(12)));assertEquals(BigInteger.valueOf(3),games.dmg.fantasystick.ability.Inspire.amount(BigInteger.valueOf(9)));var huge=BigInteger.TEN.pow(500);assertEquals(huge.subtract(BigInteger.TEN).divide(BigInteger.TWO).add(BigInteger.valueOf(4)),games.dmg.fantasystick.ability.Inspire.amount(huge));}
@Test void failedCooldownPersistencePublishesNoTemporaryHealth(){var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);var temporary=new games.dmg.fantasystick.ability.TemporaryHealth(sessions,parties,Runnable::run,ignored->{});var inspire=new games.dmg.fantasystick.ability.Inspire(games.dmg.fantasystick.ability.Inspire.Settings.defaults(),temporary);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(inspire.definition());var base=new games.dmg.fantasystick.crafting.CharacterFactory().create(games.dmg.fantasystick.progression.ProgressionRules.defaults());var stats=new EnumMap<>(base.stats());stats.put(Stat.CHR,BigInteger.valueOf(12));stats.put(Stat.WIS,BigInteger.TEN);var record=new CharacterRecord(base.id(),base.name(),base.revision(),stats,base.level(),base.experience(),base.unspentPoints(),base.health(),Set.of("inspire"),List.of("inspire","","","","","",""),base.cooldowns(),base.restrictedKills(),false,null,null);var active=new games.dmg.fantasystick.session.EquipmentSessions.Active(java.util.UUID.randomUUID(),java.util.UUID.randomUUID(),record.id());var player=mock(org.bukkit.entity.Player.class);when(player.getUniqueId()).thenReturn(active.player());when(player.isSneaking()).thenReturn(true);when(sessions.active(active.player())).thenReturn(java.util.Optional.of(active));when(sessions.current(active)).thenReturn(true);when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record));var failed=new java.util.concurrent.CompletableFuture<CharacterStorage.AbilityMutation>();failed.completeExceptionally(new java.io.IOException("simulated"));when(storage.commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any())).thenReturn(failed);var engine=new games.dmg.fantasystick.ability.AbilityEngine(registry,storage,sessions,parties,java.time.Clock.systemUTC(),Runnable::run,(delay,action)->()->{},ignored->{},ignored->{});engine.accept(new games.dmg.fantasystick.session.ControlInput(player,active,2,"inspire",games.dmg.fantasystick.session.ControlInput.Kind.ABILITY,games.dmg.fantasystick.session.ControlInput.Gesture.USE,player,0,0,()->true));verify(storage).commitAbilityCooldown(eq(record.id()),eq(record.revision()),eq("inspire"),any());verify(player,never()).setAbsorptionAmount(anyDouble());engine.close();inspire.close();temporary.close();parties.close();}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedTemporaryHealth()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-cooldown-seconds: 60\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(2.0);doAnswer(x->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(x->{absorption.set(x.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(7,absorption.get(),1e-9);var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var deadline=storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire");a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);var unlearn=button(a,"[Unlearn]","inspire");PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.click(f,a,unlearn);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(2,absorption.get(),1e-9);var refunded=storage.find(a.core.record.id()).get().orElseThrow();assertEquals(BigInteger.valueOf(40),refunded.unspentPoints());assertEquals(deadline,refunded.cooldowns().get("inspire"));learnAndAssign(f,a);use(f,a);assertTrue(a.messages.stream().anyMatch(m->m.contains("ready in")));var sheet=a.core.inventory.getItemInOffHand().clone();a.core.inventory.setItemInOffHand(null);f.changed(a);PartyBookNativeTest.drain(f);f.awaitCharacterAndReceipt(a);var b=new FantasyStickLifecycleNativeTest.Actor(data);b.core.inventory.setItemInOffHand(sheet);f.players.put(b.core.id,b.player);f.activate(b);doReturn(true).when(b.player).isSneaking();use(f,b);assertTrue(b.messages.stream().anyMatch(m->m.contains("ready in")));assertEquals(deadline,storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("inspire"));}
var own=inspireEvents(data);assertEquals(1,count(own,"ability.cast"));
assertEquals(1,count(own,"effect.applied"));assertEquals(1,count(own,"effect.ended"));
assertEquals(0,count(own,"combat.prevented"));
assertEquals("interrupted",record(own,"effect.ended").get("reason").getAsString());
assertEquals("5",record(own,"effect.ended").get("remaining_amount").getAsString());
}
@Test void configuredDefinitionAdminRestartFutureCastAndStartupValidationCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"inspire-base-temporary-health: 8\ninspire-cooldown-seconds: 0\ninspire-range-blocks: 16\ninspire-duration-seconds: 8\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var d=registry.find("inspire").orElseThrow();assertEquals(java.time.Duration.ZERO,d.cooldown());assertEquals(new java.math.BigDecimal("8"),d.parameters().get("base-temporary-health"));assertEquals(0,new java.math.BigDecimal("16").compareTo(d.parameters().get("range")));assertEquals(0,new java.math.BigDecimal("8").compareTo(d.parameters().get("duration-seconds")));
var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(List.of("inspire-base-temporary-health","inspire-cooldown-seconds","inspire-duration-seconds","inspire-range-blocks"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-"}));assertEquals(List.of("4","8"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health",""}));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health"});assertTrue(sender.messages.getLast().contains("=8"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9"});var response=f.tasks.poll(5,java.util.concurrent.TimeUnit.SECONDS);assertNotNull(response);response.run();assertEquals(new java.math.BigDecimal("9"),registry.find("inspire").orElseThrow().parameters().get("base-temporary-health"));
var a=new FantasyStickLifecycleNativeTest.Actor(data);withInspire(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);var absorption=new java.util.concurrent.atomic.AtomicReference<>(0.0);doAnswer(c->absorption.get()).when(a.player).getAbsorptionAmount();doAnswer(c->{absorption.set(c.getArgument(0));return null;}).when(a.player).setAbsorptionAmount(anyDouble());doReturn(true).when(a.player).isSneaking();use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);assertEquals(10,absorption.get(),1e-9);assertEquals(160L,f.delayedTicks.getLast());
f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","-1"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config","inspire-base-temporary-health","9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reload=(games.dmg.fantasystick.ability.AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new java.math.BigDecimal("9"),reload.find("inspire").orElseThrow().parameters().get("base-temporary-health"));}
for(String invalid:List.of("inspire-base-temporary-health: -1\n","inspire-cooldown-seconds: -1\n","inspire-range-blocks: 0\n","inspire-duration-seconds: 0\n")){var dir=data.resolve(Integer.toHexString(invalid.hashCode()));java.nio.file.Files.createDirectories(dir);java.nio.file.Files.writeString(dir.resolve("config.yml"),invalid);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){assertThrows(IllegalArgumentException.class,()->f.plugin.setEnabled(true));assertTrue(f.order.isEmpty());}}}
private static List<com.google.gson.JsonObject> inspireEvents(Path root)throws Exception{
var results=new java.util.ArrayList<com.google.gson.JsonObject>();
try(var days=java.nio.file.Files.list(root.resolve("events"))){
for(var day:days.filter(path->path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList())
try(var segments=java.nio.file.Files.list(day)){
for(var path:segments.filter(file->file.getFileName().toString().startsWith("events-")
&&file.getFileName().toString().endsWith(".jsonl")).toList())
for(var line:java.nio.file.Files.readAllLines(path))
if(!line.isBlank()){
var event=com.google.gson.JsonParser.parseString(line).getAsJsonObject();
var data=event.getAsJsonObject("data");
if(data!=null&&data.has("ability_id")&&data.get("ability_id").getAsString().equals("inspire"))results.add(event);
}
}
}
return results;
}
private static long count(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).count();}
private static com.google.gson.JsonObject record(List<com.google.gson.JsonObject> own,String suffix){return own.stream().filter(event->event.get("type").getAsString().endsWith(suffix)).findFirst().orElseThrow().getAsJsonObject("data");}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a)throws Exception{a.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,a);PartyBookNativeTest.click(f,a,button(a,"[Learn]","inspire"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
private static void use(FantasyStickLifecycleNativeTest.Fixture f,FantasyStickLifecycleNativeTest.Actor a){a.core.inventory.getInventory().setSelectedSlot(2);f.dispatcher.accept(new PlayerInteractEvent(a.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,a.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND));}
private static ClickEvent.Custom button(FantasyStickLifecycleNativeTest.Actor a,String label,String ability){var b=org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(a.books.getLast()).get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT);assertNotNull(b);for(var p:b.pages()){var x=button(p.raw(),label,ability);if(x!=null)return x;}throw new AssertionError("missing action");}