Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93897ce516 | ||
|
|
fc49ee1cfb | ||
|
|
ee7d9036a2 | ||
|
|
4616ee80e3 | ||
|
|
e6b66d9e34 |
No files matched your search
@@ -744,6 +744,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(shieldBash::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
|
||||
@@ -761,6 +762,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("great_sword");
|
||||
abilityEngine.enableCastEventsFor("frost_shard");
|
||||
abilityEngine.enableCastEventsFor("longbow");
|
||||
abilityEngine.enableCastEventsFor("quickstep");
|
||||
abilityEngine.enableCastEventsFor("second_wind");
|
||||
abilityEngine.enableCastEventsFor("stone_skin");
|
||||
abilityEngine.enableCastEventsFor("shield_bash");
|
||||
abilityEngine.enableCastEventsFor("hex");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
|
||||
@@ -71,7 +71,9 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
|
||||
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
|
||||
if (castEvents == null || record == null
|
||||
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow"))
|
||||
|| !(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"))
|
||||
|| !eventOwnedAbilities.contains(definition.id())) return;
|
||||
resolve(castEvents.begin(input, record, definition), "refused", reason);
|
||||
}
|
||||
@@ -252,25 +254,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return;
|
||||
}
|
||||
CharacterRecord saved = mutation.record().orElseThrow();
|
||||
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
|
||||
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
boolean synchronizedProgress = unchangedDeadline
|
||||
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
|
||||
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
|
||||
if (!synchronizedProgress) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
resolve(audit, "interrupted", "session_publication_changed");
|
||||
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
|
||||
}
|
||||
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|
||||
|| !eligible(definition.targetPolicy(), input, input.target())) {
|
||||
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
|
||||
resolve(audit, "interrupted", "eligibility_changed");
|
||||
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
|
||||
}
|
||||
var runtime = new Runtime(key, input, definition, generation, audit);
|
||||
var runtime = new Runtime(key, input, definition, generation, audit,
|
||||
deferNativeCast ? saved : null, readyAt);
|
||||
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
|
||||
try {
|
||||
definition.effect().activate(runtime);
|
||||
runtime.publishCast();
|
||||
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
|
||||
} catch (RuntimeException effectFailure) {
|
||||
runtime.publishCast();
|
||||
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
|
||||
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
|
||||
}
|
||||
@@ -341,17 +349,40 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
private final AbilityDefinition definition;
|
||||
private final long generation;
|
||||
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
|
||||
private final CharacterRecord deferredCastRecord;
|
||||
private final Instant deferredDeadline;
|
||||
private final Map<String, Object> castEvidence = new HashMap<>();
|
||||
private boolean castPublished;
|
||||
private final Map<UUID, String> observedEffects = new HashMap<>();
|
||||
private final Set<AutoCloseable> owned = new HashSet<>();
|
||||
private String outcome;
|
||||
private Map<String, Object> resolutionEvidence = Map.of();
|
||||
private boolean finished;
|
||||
private boolean closing;
|
||||
private int scheduled;
|
||||
|
||||
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
|
||||
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
|
||||
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
|
||||
CharacterRecord deferredCastRecord, Instant deferredDeadline) {
|
||||
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
|
||||
this.audit = audit;
|
||||
this.audit = audit; this.deferredCastRecord = deferredCastRecord; this.deferredDeadline = deferredDeadline;
|
||||
}
|
||||
void publishCast() {
|
||||
if (deferredCastRecord == null || castPublished) return;
|
||||
castPublished = true;
|
||||
if (castEvents != null) castEvents.cast(audit, deferredCastRecord, deferredDeadline, definition,
|
||||
Map.copyOf(castEvidence));
|
||||
}
|
||||
@Override public void castEvidence(Map<String, Object> evidence) {
|
||||
if (!castPublished && !finished && deferredCastRecord != null)
|
||||
castEvidence.putAll(Objects.requireNonNull(evidence));
|
||||
}
|
||||
@Override public void resolutionEvidence(Map<String, Object> evidence) {
|
||||
if (!finished) {
|
||||
var combined = new java.util.LinkedHashMap<>(resolutionEvidence);
|
||||
combined.putAll(Objects.requireNonNull(evidence));
|
||||
resolutionEvidence = Map.copyOf(combined);
|
||||
}
|
||||
}
|
||||
@Override public AbilityDefinition definition() { return definition; }
|
||||
@Override public ControlInput input() { return input; }
|
||||
@@ -359,7 +390,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
|
||||
}
|
||||
@Override public boolean stillCurrent() {
|
||||
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
|
||||
return !closed && !finished && !closing && input.stillCurrent() && sessions.current(input.session())
|
||||
&& generations.getOrDefault(key, 0L) == generation
|
||||
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
|
||||
}
|
||||
@@ -414,6 +445,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
try { feedback.report(definition, input, type, target, report.amount()); }
|
||||
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
}
|
||||
@Override public java.time.Instant now() { return clock.instant(); }
|
||||
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
|
||||
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
|
||||
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
|
||||
@@ -452,6 +484,18 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
|
||||
incoming, actual, remaining, bondApplication);
|
||||
}
|
||||
@Override public void acceptedOutgoingMitigation(UUID application, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual) {
|
||||
if (castEvents == null || audit == null) return;
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
if (protectedTarget instanceof Player player)
|
||||
sessions.active(player.getUniqueId()).filter(sessions::current).ifPresent(active -> {
|
||||
evidence.put("protected_character_id", active.character().toString());
|
||||
evidence.put("protected_session_id", active.session().toString());
|
||||
});
|
||||
castEvents.outgoingPrevented(audit, application, debuffedAttacker, protectedTarget,
|
||||
incoming, remaining, actual, evidence);
|
||||
}
|
||||
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
|
||||
Entity target, double base, double bonus, double finalDamage,
|
||||
java.math.BigDecimal percent) {
|
||||
@@ -470,7 +514,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
@Override public void resolve(String result) { resolve(result, Map.of()); }
|
||||
@Override public void resolve(String result, Map<String, Object> evidence) {
|
||||
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
|
||||
"healed", "cleansed", "protected", "boosted").contains(result))
|
||||
"healed", "cleansed", "protected", "boosted", "obstructed", "replaced").contains(result))
|
||||
throw new IllegalArgumentException("Unsupported ability resolution");
|
||||
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
|
||||
}
|
||||
@@ -479,11 +523,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
close();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (finished || closing) return;
|
||||
closing = true;
|
||||
publishCast();
|
||||
if (outcome == null) outcome = "interrupted";
|
||||
var resources = List.copyOf(owned); owned.clear();
|
||||
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
|
||||
finished = true;
|
||||
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
|
||||
if (castEvents != null) castEvents.resolved(audit, outcome,
|
||||
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
|
||||
|
||||
@@ -17,6 +17,8 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
ControlInput input();
|
||||
games.dmg.fantasystick.persistence.CharacterRecord character();
|
||||
boolean stillCurrent();
|
||||
/** Native-thread clock shared with cast and effect audit timestamps. */
|
||||
default java.time.Instant now() { return java.time.Instant.now(); }
|
||||
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
|
||||
AbilityScheduler.Task later(Duration delay, Runnable action);
|
||||
void own(AutoCloseable resource);
|
||||
@@ -40,6 +42,9 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
void acceptedPrevention(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player ally, Entity attacker, double incoming,
|
||||
double actual, double remaining, UUID bondApplication);
|
||||
/** 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 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,
|
||||
@@ -50,6 +55,10 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
|
||||
effectEnded(applicationId, reason);
|
||||
}
|
||||
/** 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. */
|
||||
default void resolutionEvidence(Map<String, Object> evidence) { }
|
||||
/** An owner may specify a truthful terminal outcome before finishing. */
|
||||
void resolve(String outcome);
|
||||
void resolve(String outcome, Map<String, Object> evidence);
|
||||
|
||||
@@ -19,9 +19,9 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
private final class Grant implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
|
||||
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
|
||||
final java.util.function.Consumer<Accepted> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
java.util.function.Consumer<Accepted> acceptedObservation) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
this.acceptedObservation = acceptedObservation;
|
||||
}
|
||||
@@ -35,7 +35,8 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Prevention(Grant grant, BigDecimal amount) { }
|
||||
public record Accepted(EntityDamageByEntityEvent event, double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, double incoming, double reduced) { }
|
||||
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
|
||||
private boolean closed;
|
||||
@@ -48,9 +49,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
return grant(execution, player, percent, current, null);
|
||||
}
|
||||
|
||||
/** An owner may observe only the selected, positive native prevention after final cancellation. */
|
||||
/** Legacy owner observation; keep existing prevention consumers unchanged. */
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> observation) {
|
||||
return grantAccepted(execution, player, percent, current,
|
||||
observation == null ? null : accepted -> observation.accept(accepted.event(), accepted.prevented()));
|
||||
}
|
||||
|
||||
/** An owner may observe the selected, positive native prevention and its original hit value. */
|
||||
public AutoCloseable grantAccepted(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.Consumer<Accepted> acceptedObservation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid combat mitigation grant");
|
||||
@@ -75,8 +83,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if (!Double.isFinite(reduced) || reduced < 0) return;
|
||||
event.setDamage(reduced);
|
||||
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
|
||||
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
|
||||
if (original > reduced) pending.put(event, new Prevention(strongest, original, reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
@@ -84,9 +91,15 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(),
|
||||
BigDecimal.valueOf(actual)); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt an accepted native hit or its audit. */ }
|
||||
if (prevention.grant.acceptedObservation != null) try {
|
||||
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
|
||||
prevention.grant.acceptedObservation.accept(new Accepted((EntityDamageByEntityEvent) event,
|
||||
prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,15 +35,37 @@ public final class Hex implements AbilityEffect,AutoCloseable{
|
||||
"duration-seconds",BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9),"damage-reduction-percent",current.reductionPercent()),
|
||||
AbilityDefinition.TargetPolicy.HOSTILE,this);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target() instanceof LivingEntity target&&inRange(input,target,settings.range());}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent()||!(execution.input().target() instanceof LivingEntity target))return;
|
||||
var current=from(execution.definition());if(!inRange(execution.input(),target,current.range())||execution.trainingDummy(target)){execution.finish();return;}
|
||||
var state=new State(execution,target,mitigation.grant(execution,target,current.reductionPercent()));var previous=hexes.put(target.getUniqueId(),state);
|
||||
if(previous!=null)previous.execution.finish();execution.own(state);execution.later(current.duration(),execution::finish);}
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition());
|
||||
if (!inRange(execution.input(), target, current.range()) || execution.trainingDummy(target)) {
|
||||
execution.resolve("miss", Map.of("reason", "target_ineligible_after_cooldown"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var application = new java.util.concurrent.atomic.AtomicReference<UUID>();
|
||||
var grant = mitigation.grantObserved(execution, target, current.reductionPercent(), accepted -> {
|
||||
try { execution.acceptedOutgoingMitigation(application.get(), accepted.debuffedAttacker(),
|
||||
accepted.recipient(), accepted.incoming(), accepted.remaining(), accepted.prevented()); }
|
||||
catch (RuntimeException ignored) { /* Native damage and support do not depend on auditing. */ }
|
||||
});
|
||||
var state = new State(execution, target, grant);
|
||||
var previous = hexes.put(target.getUniqueId(), state);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", seconds(current.duration()));
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
application.set(execution.effectApplied("hex", target, evidence));
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
private static boolean inRange(ControlInput input,LivingEntity target,double range){try{Location source=input.player().getLocation(),destination=target.getLocation();
|
||||
return source.getWorld()==destination.getWorld()&&source.distanceSquared(destination)<=range*range&&input.player().hasLineOfSight(target);
|
||||
}catch(RuntimeException invalid){return false;}}
|
||||
private static Settings from(AbilityDefinition definition){var p=definition.parameters();return new Settings(definition.cooldown(),p.get("range").doubleValue(),
|
||||
Duration.ofNanos(p.get("duration-seconds").movePointRight(9).longValueExact()),p.get("damage-reduction-percent"));}
|
||||
private static String seconds(Duration value){return BigDecimal.valueOf(value.toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values()))state.execution.finish();hexes.clear();}
|
||||
@Override public void close(){if(closed)return;closed=true;for(var state:List.copyOf(hexes.values())){
|
||||
state.execution.resolve("interrupted"); state.execution.finish();}hexes.clear();}
|
||||
}
|
||||
@@ -25,24 +25,36 @@ import org.bukkit.projectiles.ProjectileSource;
|
||||
public final class OutgoingDamageMitigation 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 LivingEntity target; final BigDecimal percent; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
final long sequence; final AbilityExecution execution; final LivingEntity target; final BigDecimal percent;
|
||||
final Consumer<Accepted> observation; boolean closed;
|
||||
Grant(AbilityExecution execution, LivingEntity target, BigDecimal percent, Consumer<Accepted> observation) {
|
||||
this.sequence=++sequences;this.execution=execution;this.target=target;this.percent=percent;
|
||||
this.observation = observation;
|
||||
}
|
||||
@Override public void close(){if(closed)return;closed=true;var owned=grants.get(target.getUniqueId());
|
||||
if(owned!=null){owned.remove(this);if(owned.isEmpty())grants.remove(target.getUniqueId());}}
|
||||
}
|
||||
private record Prevention(Grant grant, Entity recipient, BigDecimal amount) { }
|
||||
public record Accepted(LivingEntity debuffedAttacker, Entity recipient,
|
||||
double incoming, double remaining, double prevented) { }
|
||||
private record Prevention(Grant grant, Entity recipient, double incoming, double reduced) { }
|
||||
private final EquipmentSessions sessions; private final PartyRoster parties; private final Consumer<Support> supports;
|
||||
private final Map<UUID,List<Grant>> grants=new java.util.HashMap<>();
|
||||
private final Map<EntityDamageEvent,Prevention> pending=new IdentityHashMap<>();
|
||||
private long sequences; private boolean closed;
|
||||
public OutgoingDamageMitigation(EquipmentSessions sessions,PartyRoster parties,Consumer<Support> supports){
|
||||
this.sessions=Objects.requireNonNull(sessions);this.parties=Objects.requireNonNull(parties);this.supports=Objects.requireNonNull(supports);}
|
||||
public AutoCloseable grant(AbilityExecution execution,LivingEntity target,BigDecimal percent){
|
||||
Objects.requireNonNull(execution);Objects.requireNonNull(target);Objects.requireNonNull(percent);
|
||||
if(closed||percent.signum()<0||percent.compareTo(BigDecimal.valueOf(100))>0)throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant=new Grant(execution,target,percent);grants.computeIfAbsent(target.getUniqueId(),ignored->new ArrayList<>()).add(grant);return grant;}
|
||||
public AutoCloseable grant(AbilityExecution execution, LivingEntity target, BigDecimal percent) {
|
||||
return grantObserved(execution, target, percent, null);
|
||||
}
|
||||
public AutoCloseable grantObserved(AbilityExecution execution, LivingEntity target, BigDecimal percent,
|
||||
Consumer<Accepted> observation) {
|
||||
Objects.requireNonNull(execution); Objects.requireNonNull(target); Objects.requireNonNull(percent);
|
||||
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
|
||||
throw new IllegalArgumentException("Invalid outgoing mitigation grant");
|
||||
var grant = new Grant(execution, target, percent, observation);
|
||||
grants.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.NORMAL,ignoreCancelled=true)
|
||||
public void reduce(EntityDamageByEntityEvent event){
|
||||
@@ -53,20 +65,31 @@ public final class OutgoingDamageMitigation implements Listener, AutoCloseable {
|
||||
double original=event.getDamage();if(strongest==null||!Double.isFinite(original)||original<=0)return;
|
||||
double reduced=BigDecimal.valueOf(original).multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
|
||||
if(!Double.isFinite(reduced)||reduced<0)return;event.setDamage(reduced);var prevented=BigDecimal.valueOf(original-reduced);
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),prevented));
|
||||
if(prevented.signum()>0)pending.put(event,new Prevention(strongest,event.getEntity(),original,reduced));
|
||||
}
|
||||
|
||||
@EventHandler(priority=EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event){
|
||||
var prevention=pending.remove(event);if(prevention==null||event.isCancelled()||!prevention.grant.execution.stillCurrent())return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION,prevention.grant.target,prevention.amount);
|
||||
if(!(prevention.recipient instanceof Player player))return;
|
||||
var prevention = pending.remove(event);
|
||||
if (prevention == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !prevention.grant.execution.stillCurrent()) return;
|
||||
double remaining = event.getDamage();
|
||||
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
|
||||
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, prevention.grant.target, amount); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot interrupt accepted native prevention. */ }
|
||||
if (prevention.grant.observation != null) try {
|
||||
prevention.grant.observation.accept(new Accepted(prevention.grant.target,
|
||||
prevention.recipient, prevention.incoming, remaining, actual));
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter damage or rewards. */ }
|
||||
if (!(prevention.recipient instanceof Player player)) return;
|
||||
var aided=sessions.active(player.getUniqueId()).orElse(null);if(aided==null||!sessions.current(aided))return;
|
||||
var input=prevention.grant.execution.input();if(aided.character().equals(input.session().character()))return;
|
||||
var sourceParty=parties.partyOf(input.player().getUniqueId()).orElse(null);
|
||||
var aidedParty=parties.partyOf(player.getUniqueId()).orElse(null);
|
||||
if(sourceParty==null||aidedParty==null||!sourceParty.id().equals(aidedParty.id()))return;
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,prevention.amount));
|
||||
supports.accept(new Support(prevention.grant.target.getUniqueId(),input.session().character(),aided,amount));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event){
|
||||
|
||||
@@ -41,11 +41,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Location origin;
|
||||
final Vector horizontal;
|
||||
final Settings settings;
|
||||
final long started = System.nanoTime();
|
||||
final long boundTicks;
|
||||
Location lastObserved;
|
||||
boolean owned = true;
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
|
||||
State(AbilityExecution execution, Player player, Location origin, Vector horizontal,
|
||||
Settings settings, long boundTicks) {
|
||||
this.execution = execution; this.player = player; this.origin = origin;
|
||||
this.horizontal = horizontal; this.settings = settings;
|
||||
this.horizontal = horizontal; this.settings = settings; this.boundTicks = boundTicks;
|
||||
}
|
||||
void finish(String outcome, Location observed) {
|
||||
lastObserved = observed == null ? null : observed.clone();
|
||||
execution.resolve(outcome);
|
||||
execution.finish();
|
||||
}
|
||||
boolean stillOwns(Vector current) {
|
||||
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
|
||||
@@ -57,6 +66,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true; dashes.remove(player.getUniqueId(), this);
|
||||
try {
|
||||
var to = lastObserved == null ? player.getLocation() : lastObserved;
|
||||
if (to != null) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("to_location", locationData(to));
|
||||
evidence.put("elapsed_ms", Math.max(0, (System.nanoTime() - started) / 1_000_000L));
|
||||
evidence.put("bound_ticks", boundTicks);
|
||||
if (to.getWorld() != null && to.getWorld().equals(origin.getWorld())) {
|
||||
double dx = to.getX() - origin.getX(), dz = to.getZ() - origin.getZ();
|
||||
evidence.put("horizontal_distance", Math.hypot(dx, dz));
|
||||
} else evidence.put("horizontal_distance_status", "not_comparable_cross_world");
|
||||
execution.resolutionEvidence(evidence);
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change native motion or cleanup. */ }
|
||||
if (!owned) return;
|
||||
Vector current = player.getVelocity();
|
||||
if (!stillOwns(current)) return;
|
||||
@@ -98,24 +121,41 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition());
|
||||
var player = execution.input().player();
|
||||
if (!accepts(execution.input())) { execution.finish(); return; }
|
||||
var origin = player.getLocation().clone();
|
||||
try { execution.castEvidence(Map.of("from_location", locationData(origin))); }
|
||||
catch (RuntimeException ignored) { /* Capturing an origin cannot affect admission. */ }
|
||||
if (!accepts(execution.input())) {
|
||||
rejectedVelocity(execution, origin, "native_recheck_refused"); return;
|
||||
}
|
||||
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
|
||||
Vector before = player.getVelocity();
|
||||
Vector horizontal = direction.multiply(current.speedPerTick());
|
||||
Vector horizontal = direction.clone().multiply(current.speedPerTick());
|
||||
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
|
||||
try { execution.castEvidence(Map.of("intended_direction", vectorData(direction),
|
||||
"native_velocity_status", "pending_native_decision")); }
|
||||
catch (RuntimeException ignored) { /* Capturing intent cannot affect native velocity. */ }
|
||||
var event = new PlayerVelocityEvent(player, proposed.clone());
|
||||
velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_cancelled_or_invalid"); return;
|
||||
}
|
||||
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
|
||||
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
|
||||
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
|
||||
if (acceptedHorizontal.lengthSquared() < 1.0e-12) {
|
||||
rejectedVelocity(execution, origin, "native_velocity_without_horizontal_motion"); return;
|
||||
}
|
||||
try { execution.castEvidence(Map.of("accepted_velocity", vectorData(accepted),
|
||||
"native_velocity_status", "accepted")); }
|
||||
catch (RuntimeException ignored) { /* Capturing accepted velocity cannot affect movement. */ }
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
var state = new State(execution, player, origin, acceptedHorizontal, current, ticks + 1);
|
||||
var previous = dashes.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
if (previous != null) previous.finish("replaced", previous.player.getLocation());
|
||||
execution.own(state);
|
||||
player.setVelocity(accepted);
|
||||
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), () -> {
|
||||
execution.resolve("completed", Map.of("reason", "elapsed_bound")); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -123,18 +163,44 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = dashes.get(event.getPlayer().getUniqueId());
|
||||
if (state == null || state.player != event.getPlayer()) return;
|
||||
var to = event.getTo();
|
||||
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
|
||||
if (to == null || !state.execution.stillCurrent()) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
state.lastObserved = to.clone();
|
||||
if (to.getWorld() == null || !to.getWorld().equals(state.origin.getWorld())) {
|
||||
state.finish("interrupted", to); return;
|
||||
}
|
||||
Vector current = state.player.getVelocity();
|
||||
if (!state.stillOwns(current)) {
|
||||
state.owned = false; state.execution.finish(); return;
|
||||
state.owned = false; state.finish("replaced", to); return;
|
||||
}
|
||||
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|
||||
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.execution.finish();
|
||||
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()) {
|
||||
state.finish("completed", to);
|
||||
} else if (new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
|
||||
state.finish("obstructed", to);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectedVelocity(AbilityExecution execution, Location origin, String reason) {
|
||||
try { execution.castEvidence(Map.of("native_velocity_status", "cancelled"));
|
||||
execution.resolve("cancelled", Map.of("reason", reason,
|
||||
"to_location", locationData(origin), "horizontal_distance", 0)); }
|
||||
catch (RuntimeException ignored) { /* Native rejection is not contingent on audit capture. */ }
|
||||
execution.finish();
|
||||
}
|
||||
|
||||
private static Map<String, Object> vectorData(Vector vector) {
|
||||
return Map.of("x", vector.getX(), "y", vector.getY(), "z", vector.getZ());
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static boolean finite(Vector vector) {
|
||||
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
|
||||
}
|
||||
|
||||
@@ -80,17 +80,24 @@ public final class SecondWind implements AbilityEffect {
|
||||
BigInteger base = definition.parameters().get("base-healing").toBigIntegerExact();
|
||||
var player = execution.input().player();
|
||||
var attribute = player.getAttribute(Attribute.MAX_HEALTH);
|
||||
if (attribute == null) { execution.finish(); return; }
|
||||
if (attribute == null) { execution.resolve("cancelled", Map.of("reason", "maximum_unavailable")); execution.finish(); return; }
|
||||
double current = player.getHealth(); double maximum = attribute.getValue();
|
||||
double missing = maximum - current;
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || missing <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "health_unavailable_or_full")); execution.finish(); return;
|
||||
}
|
||||
BigInteger exact = healing(base, execution.character().stats().get(Stat.WIS));
|
||||
double proposed = exact.bitLength() > 1023 ? missing : Math.min(missing, exact.doubleValue());
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(proposed) || proposed <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "healing_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var event = new EntityRegainHealthEvent(player, proposed, EntityRegainHealthEvent.RegainReason.CUSTOM);
|
||||
healingEvents.accept(event);
|
||||
double accepted = Math.min(missing, event.getAmount());
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) { execution.finish(); return; }
|
||||
if (event.isCancelled() || !Double.isFinite(accepted) || accepted <= 0) {
|
||||
execution.resolve("cancelled", Map.of("reason", "native_healing_cancelled_or_nonpositive"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var record = execution.character(); var expected = record.revision();
|
||||
BigDecimal target = BigDecimal.valueOf(current + accepted);
|
||||
BigDecimal limit = nativeLimit.get();
|
||||
@@ -99,18 +106,33 @@ public final class SecondWind implements AbilityEffect {
|
||||
nextTick.accept(() -> {
|
||||
try {
|
||||
if (failure != null || saved == null || saved.isEmpty() || !execution.stillCurrent()) {
|
||||
execution.resolve(failure != null || saved == null || saved.isEmpty() ? "failed" : "interrupted",
|
||||
Map.of("reason", failure != null ? "health_save_failed" : saved == null || saved.isEmpty()
|
||||
? "health_save_unconfirmed" : "session_changed_before_healing"));
|
||||
player.sendMessage("Second Wind health could not be saved; no healing was applied."); return;
|
||||
}
|
||||
var persisted = saved.orElseThrow();
|
||||
if (!sessions.acknowledgeProgress(execution.input().session(), expected, persisted)) {
|
||||
execution.resolve("interrupted", Map.of("reason", "health_saved_session_changed"));
|
||||
player.sendMessage("Second Wind was saved, but the session changed; no native healing was applied."); return;
|
||||
}
|
||||
double nativeBefore = player.getHealth();
|
||||
double publish = Math.min(attribute.getValue(), Math.max(nativeBefore, persisted.health().doubleValue()));
|
||||
player.setHealth(publish);
|
||||
double restored = publish - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) execution.report(AbilityReport.Type.HEALING,
|
||||
player, BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
try { player.setHealth(publish); }
|
||||
catch (RuntimeException nativeFailure) {
|
||||
execution.resolve("failed", Map.of("reason", "native_health_publication_failed"));
|
||||
throw nativeFailure;
|
||||
}
|
||||
double nativeAfter = player.getHealth();
|
||||
double restored = nativeAfter - nativeBefore;
|
||||
if (Double.isFinite(restored) && restored > 0) {
|
||||
try { execution.acceptedHeal(execution.input().session(), player, persisted, proposed,
|
||||
restored, nativeBefore, nativeAfter, maximum); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot change an applied native heal. */ }
|
||||
execution.resolve("healed", Map.of("reason", "native_self_heal_applied"));
|
||||
execution.report(AbilityReport.Type.HEALING, player,
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
} else execution.resolve("cancelled", Map.of("reason", "native_heal_not_observed"));
|
||||
} finally { execution.finish(); }
|
||||
});
|
||||
} catch (RuntimeException rejected) { execution.finish(); }
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
|
||||
final AbilityExecution execution; final LivingEntity target; final Settings settings;
|
||||
final double requested; final double healthBefore;
|
||||
double finalDamage; boolean accepted; boolean cancelled;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
|
||||
this.execution = execution; this.target = target; this.settings = settings;
|
||||
this.requested = requested; this.healthBefore = healthBefore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
if (!inRange(player, target, current.range())) { execution.finish(); return; }
|
||||
if (!inRange(player, target, current.range())) {
|
||||
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
|
||||
execution.finish(); return;
|
||||
}
|
||||
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
|
||||
if (!Double.isFinite(amount) || amount <= 0) {
|
||||
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
|
||||
var attempt = new Attempt(execution, target, current); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; execution.finish(); }
|
||||
double beforeHealth;
|
||||
try { beforeHealth = target.getHealth(); }
|
||||
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
|
||||
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
|
||||
boolean nativeFailed = false;
|
||||
try {
|
||||
execution.damage(target, amount, source);
|
||||
if (attempt.accepted) publishAccepted(attempt);
|
||||
}
|
||||
catch (RuntimeException failed) {
|
||||
nativeFailed = true;
|
||||
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
|
||||
throw failed;
|
||||
} finally {
|
||||
if (damaging == attempt) damaging = null;
|
||||
if (!attempt.accepted && !nativeFailed)
|
||||
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
|
||||
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
|
||||
execution.finish();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
|
||||
}
|
||||
|
||||
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
|
||||
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target) return false;
|
||||
var source = event.getDamageSource();
|
||||
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
|
||||
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
public void acceptedDamage(EntityDamageByEntityEvent event) {
|
||||
var attempt = damaging;
|
||||
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|
||||
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
|
||||
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
|
||||
if (event.isCancelled()) { attempt.cancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
|
||||
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
|
||||
knockback(attempt);
|
||||
attempt.finalDamage = event.getFinalDamage();
|
||||
}
|
||||
|
||||
private void knockback(Attempt attempt) {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return;
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled() || !finite(event.getVelocity())) return;
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
private void publishAccepted(Attempt attempt) {
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("stage", "native_damage_accepted");
|
||||
try {
|
||||
double afterHealth = attempt.target.getHealth();
|
||||
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
|
||||
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
|
||||
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
|
||||
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
|
||||
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
|
||||
attempt.execution.input().player(), evidence); }
|
||||
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
|
||||
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
|
||||
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
|
||||
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
|
||||
String knockbackStatus = knockback(attempt);
|
||||
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
|
||||
"knockback_status", knockbackStatus));
|
||||
}
|
||||
|
||||
private String knockback(Attempt attempt) {
|
||||
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
|
||||
try {
|
||||
var player = attempt.execution.input().player(); var target = attempt.target;
|
||||
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
|
||||
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
|
||||
Vector current = target.getVelocity();
|
||||
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
|
||||
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
|
||||
if (!finite(proposed)) return "velocity_invalid";
|
||||
if (target instanceof Player targetPlayer) {
|
||||
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
|
||||
if (event.isCancelled()) return "native_velocity_cancelled";
|
||||
if (!finite(event.getVelocity())) return "velocity_invalid";
|
||||
proposed = event.getVelocity().clone();
|
||||
}
|
||||
target.setVelocity(proposed);
|
||||
try { attempt.execution.effectApplied("shield_bash_knockback", target,
|
||||
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
|
||||
"accepted_velocity_x", decimal(proposed.getX()),
|
||||
"accepted_velocity_y", decimal(proposed.getY()),
|
||||
"accepted_velocity_z", decimal(proposed.getZ()),
|
||||
"status", "native_velocity_accepted")); }
|
||||
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
|
||||
return "native_velocity_accepted";
|
||||
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
|
||||
}
|
||||
|
||||
private static boolean inRange(Player player, LivingEntity target, double range) {
|
||||
|
||||
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = from(execution.definition()); var player = execution.input().player();
|
||||
var grant = mitigation.grant(execution, player, current.reductionPercent());
|
||||
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
|
||||
accepted -> {
|
||||
try {
|
||||
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
|
||||
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
|
||||
});
|
||||
var state = new State(execution, player, grant);
|
||||
var previous = protections.put(player.getUniqueId(), state);
|
||||
if (previous != null) previous.execution.finish();
|
||||
execution.own(state); execution.later(current.duration(), execution::finish);
|
||||
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
|
||||
execution.own(state);
|
||||
var evidence = new java.util.LinkedHashMap<String, Object>();
|
||||
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
|
||||
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
|
||||
var activatedAt = execution.now();
|
||||
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
|
||||
execution.effectApplied("stone_skin", player, evidence);
|
||||
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
for (var state : List.copyOf(protections.values())) state.execution.finish();
|
||||
for (var state : List.copyOf(protections.values())) {
|
||||
state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
protections.clear();
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,18 @@ import org.bukkit.entity.Entity;
|
||||
public final class AbilityCastEventBridge {
|
||||
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks, UUID executionId) {
|
||||
float attackCharge, int heldTicks, int slot, UUID executionId) {
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks, int slot) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, slot, UUID.randomUUID());
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, UUID.randomUUID());
|
||||
attackCharge, heldTicks, -1);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
@@ -55,7 +61,7 @@ public final class AbilityCastEventBridge {
|
||||
definition.id(), target == null ? null : target.getUniqueId(),
|
||||
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
|
||||
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
|
||||
clock.instant(), input.attackStrength(), input.heldTicks());
|
||||
clock.instant(), input.attackStrength(), input.heldTicks(), input.slot());
|
||||
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
|
||||
return cast;
|
||||
} catch (RuntimeException ignored) {
|
||||
@@ -64,6 +70,11 @@ public final class AbilityCastEventBridge {
|
||||
}
|
||||
|
||||
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
|
||||
cast(cast, saved, deadline, definition, Map.of());
|
||||
}
|
||||
|
||||
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition,
|
||||
Map<String, Object> nativeEvidence) {
|
||||
if (cast == null) return;
|
||||
try {
|
||||
var settings = new LinkedHashMap<String, Object>();
|
||||
@@ -71,11 +82,18 @@ public final class AbilityCastEventBridge {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("cooldown_until", deadline);
|
||||
data.put("after_revision", saved.revision().toString());
|
||||
if (cast.slot() >= 0) data.put("slot", cast.slot());
|
||||
data.put("stage", "cooldown_committed");
|
||||
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
|
||||
data.put("captured_settings", settings);
|
||||
data.put("settings_scope", "definition_at_request");
|
||||
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
|
||||
if (cast.abilityId().equals("quickstep")) {
|
||||
data.put("intended_distance", definition.parameters().get("distance").toPlainString());
|
||||
for (String key : java.util.List.of("from_location", "intended_direction", "accepted_velocity",
|
||||
"native_velocity_status"))
|
||||
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
|
||||
}
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
@@ -203,6 +221,10 @@ public final class AbilityCastEventBridge {
|
||||
data.put("target_location", locationData(ally.getLocation()));
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
if (cast.abilityId().equals("stone_skin")) {
|
||||
var attackLocation = attacker.getLocation();
|
||||
if (attackLocation != null) data.put("attacker_location", locationData(attackLocation));
|
||||
}
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("actual_amount", decimal(actual));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
@@ -210,6 +232,30 @@ public final class AbilityCastEventBridge {
|
||||
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
|
||||
}
|
||||
|
||||
/** Hex owns outgoing prevention even when the protected recipient is not a player. */
|
||||
public void outgoingPrevented(Cast cast, UUID effectApplication, Entity debuffedAttacker,
|
||||
Entity protectedTarget, double incoming, double remaining, double actual,
|
||||
Map<String, Object> recipientEvidence) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>(recipientEvidence);
|
||||
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("debuffed_attacker_id", debuffedAttacker.getUniqueId().toString());
|
||||
data.put("debuffed_attacker_type", entityType(debuffedAttacker));
|
||||
data.put("attacker_location", locationData(debuffedAttacker.getLocation()));
|
||||
data.put("protected_target_id", protectedTarget.getUniqueId().toString());
|
||||
data.put("protected_target_type", entityType(protectedTarget));
|
||||
data.put("protected_location", locationData(protectedTarget.getLocation()));
|
||||
data.put("incoming_amount", incoming);
|
||||
data.put("remaining_amount", remaining);
|
||||
data.put("actual_amount", actual);
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Logging cannot alter accepted native damage. */ }
|
||||
}
|
||||
|
||||
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 HexSettings implements AutoCloseable {
|
||||
public Hex.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) {
|
||||
|
||||
@@ -71,11 +71,12 @@ public final class QuickstepSettings implements AutoCloseable {
|
||||
public Quickstep.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) {
|
||||
|
||||
@@ -70,11 +70,12 @@ public final class SecondWindSettings implements AutoCloseable {
|
||||
public SecondWind.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) {
|
||||
|
||||
@@ -74,11 +74,12 @@ public final class ShieldBashSettings implements AutoCloseable {
|
||||
public ShieldBash.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) {
|
||||
|
||||
@@ -71,11 +71,12 @@ public final class StoneSkinSettings implements AutoCloseable {
|
||||
public StoneSkin.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) {
|
||||
|
||||
@@ -1324,8 +1324,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void quickstepConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = quickstepSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "quickstep", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Quickstep dashes only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
|
||||
@@ -1339,15 +1342,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
|
||||
quickstepSettings.publish(saved);
|
||||
boolean applied = failure == null && quickstepSettings.publish(saved);
|
||||
if (applied) {
|
||||
quickstep.configure(quickstepSettings.settings()); abilities.replace(quickstep.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "quickstep", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Quickstep configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Quickstep configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + quickstepSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
@@ -1637,19 +1642,47 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+hexSettings.value(key)+" (applies to future Hex casts only)");return;}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = hexSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "hex", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Hex casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.HexSettings.Update> operation;
|
||||
try{operation=hexSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Hex 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){hex.configure(saved.settings());abilities.replace(hex.definition());hexSettings.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 Hex casts; active effects and deadlines are unchanged."
|
||||
:"Hex configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
try { operation = hexSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Hex 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 ? hexSettings.value(key) : null;
|
||||
boolean applied = failure == null && hexSettings.publish(saved);
|
||||
if (applied) {
|
||||
hex.configure(hexSettings.settings()); abilities.replace(hex.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "hex", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure != null
|
||||
? "Hex configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Hex casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + hexSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void shieldBashConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = shieldBashSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "shield_bash", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Shield Bash strikes only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
|
||||
try { operation = shieldBashSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
|
||||
@@ -1658,22 +1691,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? shieldBashSettings.value(key) : null;
|
||||
boolean applied = failure == null && shieldBashSettings.publish(saved);
|
||||
if (applied) {
|
||||
shieldBash.configure(shieldBashSettings.settings()); abilities.replace(shieldBash.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "shield_bash", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Shield Bash configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Shield Bash configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + shieldBashSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = stoneSkinSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "stone_skin", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Stone Skin casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
|
||||
try { operation = stoneSkinSettings.set(key, args[2]); }
|
||||
@@ -1683,22 +1723,29 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? stoneSkinSettings.value(key) : null;
|
||||
boolean applied = failure == null && stoneSkinSettings.publish(saved);
|
||||
if (applied) {
|
||||
stoneSkin.configure(stoneSkinSettings.settings()); abilities.replace(stoneSkin.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "stone_skin", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Stone Skin configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Stone Skin configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + stoneSkinSettings.value(key) + ".");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void secondWindConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = secondWindSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + secondWindSettings.value(key) + " (applies to future Second Wind casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "second_wind", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Second Wind casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SecondWindSettings.Update> operation;
|
||||
@@ -1712,15 +1759,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
secondWind.configure(saved.settings()); abilities.replace(secondWind.definition());
|
||||
secondWindSettings.publish(saved);
|
||||
String effectiveBefore = failure == null ? secondWindSettings.value(key) : null;
|
||||
boolean applied = failure == null && secondWindSettings.publish(saved);
|
||||
if (applied) {
|
||||
secondWind.configure(secondWindSettings.settings()); abilities.replace(secondWind.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "second_wind", key, effectiveBefore, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Second Wind configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Second Wind configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Second Wind casts; active work and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + secondWindSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
|
||||
@@ -31,12 +31,12 @@ final class FishingQuestNativeTest {
|
||||
f.plugin.setEnabled(true);var actor=new FantasyStickLifecycleNativeTest.Actor(data);f.players.put(actor.core.id,actor.player);doReturn(true).when(actor.player).hasPermission("fantasystick.admin");UUID world=UUID.randomUUID();when(actor.world.getUID()).thenReturn(world);when(actor.world.getName()).thenReturn("Fishing Realm");f.activate(actor);doReturn(null).when(actor.player).getFishHook();
|
||||
assertEquals(List.of("fishing"),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","f"}));
|
||||
assertTrue(f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing",""}).contains("--name"));
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});drain(f);
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","\"Moonlit Pond\"","--item","\"Silver Scale\"","--required","1","--chance","100","--xp","10","--coins","4","--radius","16","--mode","individual-only"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Placed fishing site")&&message.contains("Moonlit Pond")));
|
||||
var adventures=(AdventureQuestDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuests");var fishing=(FishingDirectory)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"fishing");var site=adventures.snapshot().sites().getFirst();assertEquals(AdventureQuestState.SiteType.FISHING,site.type());assertEquals("Moonlit Pond",site.name());assertEquals("Silver Scale",site.fishing().itemName());assertEquals(AdventureQuestState.ParticipationMode.INDIVIDUAL_ONLY,site.participationMode());
|
||||
f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString()});assertTrue(actor.messages.getLast().contains("Silver Scale"));assertEquals(List.of(site.id().toString()),f.command.tabComplete(actor.player,"fantasystick",new String[]{"adventure-site","fishing",""}));
|
||||
int before=adventures.snapshot().sites().size();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","place","fishing","--name","Broken","--item","X","--required","0","--chance","101","--xp","1","--coins","1"});assertEquals(before,adventures.snapshot().sites().size());
|
||||
var oldCatalog=adventures.snapshot().catalog();f.command.execute(actor.player,"fantasystick",new String[]{"adventure","reroll","confirm"});await(f,()->adventures.snapshot().reset()==null&&adventures.snapshot().catalog()!=null&&(oldCatalog==null||!adventures.snapshot().catalog().revision().equals(oldCatalog.revision())));var catalog=adventures.snapshot().catalog();assertTrue(catalog.sites().contains(site.id()));
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});drain(f);var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
var controller=(AdventureQuestController)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"adventureQuestController");controller.open(actor.player);drain(f);var offerMenu=actor.inventories.getLast();var offer=offer(offerMenu,"Moonlit Pond");var lore=plain(offer);assertTrue(lore.stream().anyMatch(line->line.contains("Mode: individual-only")));assertTrue(lore.stream().anyMatch(line->line.contains("Silver Scale × 1")));assertTrue(lore.stream().anyMatch(line->line.contains("Catch chance: 100%")));assertTrue(lore.stream().anyMatch(line->line.contains("10 XP, 4 coins")));click(controller,actor,offerMenu,offer);drain(f);var quest=adventures.active().getFirst();assertNotNull(quest.fishing());UUID stableItem=quest.fishing().itemId();f.command.execute(actor.player,"fantasystick",new String[]{"adventure-site","fishing",site.id().toString(),"--item","\"Golden Scale\"","--required","2","--chance","0","--xp","20","--coins","8","--radius","4"});await(f,()->actor.messages.stream().anyMatch(message->message.contains("Updated fishing site")&&message.contains(site.id().toString())));var updatedSite=adventures.findSite(site.id()).orElseThrow();assertEquals(stableItem,updatedSite.fishing().itemId());assertEquals("Golden Scale",updatedSite.fishing().itemName());assertEquals("Silver Scale",quest.fishing().itemName(),"accepted terms remain immutable");assertEquals(16,quest.fishing().radius());actor.core.location.setX(10);
|
||||
for(int retry=0;retry<20&&actor.core.inventory.getItem(0).getType()!=Material.FISHING_ROD;retry++){pulse(f);drain(f);}var rod=actor.core.inventory.getItem(0);assertEquals(Material.FISHING_ROD,rod.getType());assertEquals(quest.id(),AdventureQuestNavigation.fishingQuest(rod));assertTrue(rod.getEnchantments().isEmpty());
|
||||
var use=new PlayerInteractEvent(actor.player,Action.RIGHT_CLICK_AIR,rod,null,org.bukkit.block.BlockFace.SELF,EquipmentSlot.HAND);f.dispatcher.accept(use);assertNotEquals(org.bukkit.event.Event.Result.DENY,use.useItemInHand(),"owned quest rod keeps native cast/reel mechanics");
|
||||
var ordinaryHook=mock(FishHook.class);when(ordinaryHook.getUniqueId()).thenReturn(UUID.randomUUID());var ordinaryEvent=new PlayerFishEvent(actor.player,null,ordinaryHook,EquipmentSlot.HAND,PlayerFishEvent.State.REEL_IN);actor.core.inventory.setItem(0,new ItemStack(Material.FISHING_ROD));f.dispatcher.accept(ordinaryEvent);assertFalse(ordinaryEvent.isCancelled(),"ordinary fishing remains unchanged");actor.core.inventory.setItem(0,rod);
|
||||
|
||||
@@ -28,6 +28,7 @@ class HexNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookCastActualPreventionAndQualifyingAllyDamagePersistTwoRewardShares() throws Exception {
|
||||
java.util.UUID actorId, characterId, sessionId, enemyId, protectedId, protectedCharacterId, protectedSessionId, worldId;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
@@ -39,11 +40,19 @@ class HexNativeTest {
|
||||
assertTrue(book.contains("Hex")); assertTrue(book.contains("CHR14")); assertTrue(book.contains("WIS12"));
|
||||
assertFalse(book.contains("Cost:")); assertTrue(book.contains("12")); assertTrue(book.contains("20%")); assertTrue(book.contains("4s"));
|
||||
PartyBookNativeTest.click(f, a, abilityButton(a, "[Learn]", "hex")); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
|
||||
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); var enemyId = java.util.UUID.randomUUID();
|
||||
actorId = a.core.id; characterId = a.core.record.id(); protectedId = b.core.id;
|
||||
protectedCharacterId = b.core.record.id();
|
||||
var activeSessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
|
||||
sessionId = activeSessions.active(actorId).orElseThrow().session();
|
||||
protectedSessionId = activeSessions.active(protectedId).orElseThrow().session();
|
||||
var enemy = mock(org.bukkit.entity.LivingEntity.class); enemyId = java.util.UUID.randomUUID();
|
||||
when(enemy.getUniqueId()).thenReturn(enemyId); when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
doReturn(new Location(a.world, 0, 64, 1)).when(b.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0)); doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
@@ -65,6 +74,75 @@ class HexNativeTest {
|
||||
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());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
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, "combat.prevented")); assertEquals(1, count(own, "effect.ended"));
|
||||
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
|
||||
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
|
||||
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(actorId.toString(), prevented.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(), prevented.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(), prevented.get("session_id").getAsString());
|
||||
assertEquals(enemyId.toString(), prevented.get("debuffed_attacker_id").getAsString());
|
||||
assertEquals(protectedId.toString(), prevented.get("protected_target_id").getAsString());
|
||||
assertEquals(protectedCharacterId.toString(), prevented.get("protected_character_id").getAsString());
|
||||
assertEquals(protectedSessionId.toString(), prevented.get("protected_session_id").getAsString());
|
||||
assertEquals(worldId.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
|
||||
assertEquals(8, prevented.getAsJsonObject("attacker_location").get("x").getAsDouble(), 1.0e-9);
|
||||
assertEquals(worldId.toString(), prevented.getAsJsonObject("protected_location").get("world_id").getAsString());
|
||||
assertEquals(1, prevented.getAsJsonObject("protected_location").get("z").getAsDouble(), 1.0e-9);
|
||||
assertEquals(10, prevented.get("incoming_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(8, prevented.get("remaining_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(2, prevented.get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), prevented.get("effect_application_id").getAsString());
|
||||
assertEquals("interrupted", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void rejectedEventWriterCannotAlterHexPreventionOrPersistedPartyReward() 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);
|
||||
withHex(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()));
|
||||
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());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
doReturn(new Location(a.world, 1, 64, 0)).when(b.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
var allySource = DamageSource.builder(DamageType.PLAYER_ATTACK)
|
||||
.withCausingEntity(b.player).withDirectEntity(b.player).build();
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(b.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, allySource, 3));
|
||||
var enemySource = DamageSource.builder(DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, b.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, enemySource, 10);
|
||||
f.dispatcher.accept(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
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());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString()
|
||||
.endsWith("combat.prevented")).count());
|
||||
}
|
||||
|
||||
@Test void trainingDummyConsumesNoOwnedHexEffect() {
|
||||
@@ -87,10 +165,14 @@ class HexNativeTest {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(data);withHex(data,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
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());
|
||||
doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
assertEquals(2,f.delayed.size());f.delayed.getFirst().run();
|
||||
var hit=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
var recipient=mock(org.bukkit.entity.Zombie.class);when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(recipient.getLocation()).thenReturn(new Location(a.world,3,64,0));
|
||||
var hit=new EntityDamageByEntityEvent(enemy,recipient,EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(hit);assertEquals(8,hit.getDamage(),1.0e-9);f.delayed.getLast().run();
|
||||
var expired=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1.0e-9);
|
||||
@@ -98,6 +180,15 @@ class HexNativeTest {
|
||||
var exited=new EntityDamageByEntityEvent(enemy,mock(org.bukkit.entity.Zombie.class),EntityDamageEvent.DamageCause.ENTITY_ATTACK,mock(DamageSource.class),10);
|
||||
f.dispatcher.accept(exited);assertEquals(10,exited.getDamage(),1.0e-9);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(3, count(own, "effect.applied"));
|
||||
assertEquals(3, count(own, "effect.ended"));
|
||||
assertEquals(1, count(own, "combat.prevented"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
|
||||
}
|
||||
|
||||
@Test void latestReplacementOwnsEffectivePreventionSupport() throws Exception {
|
||||
@@ -109,6 +200,41 @@ class HexNativeTest {
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");var ax=storage.find(a.core.record.id()).get().orElseThrow().experience();var cx=storage.find(c.core.record.id()).get().orElseThrow().experience();var bx=storage.find(b.core.record.id()).get().orElseThrow().experience();assertEquals(BigInteger.TEN,ax.add(cx).add(bx));assertTrue(List.of(ax,cx,bx).stream().allMatch(value->value.equals(BigInteger.valueOf(3))||value.equals(BigInteger.valueOf(4))));
|
||||
}}
|
||||
|
||||
@Test void unusedOrLaterOverriddenDamageCannotClaimHexPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
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());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var recipient = mock(org.bukkit.entity.Zombie.class);
|
||||
when(recipient.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(recipient.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(recipient.getLocation()).thenReturn(new Location(a.world, 3, 64, 0));
|
||||
class Override implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void restore(EntityDamageByEntityEvent event) { event.setDamage(10); }
|
||||
}
|
||||
f.dispatcher.register(new Override(), f.plugin);
|
||||
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, recipient, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, 10);
|
||||
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
}
|
||||
|
||||
@Test void unusedProtectionCannotManufactureSupportShare() 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);withHex(data,a,true);
|
||||
@@ -124,6 +250,55 @@ class HexNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedPreventionFeedbackCannotInterruptNativeDamageOrOwnerAudit() {
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
var parties = mock(games.dmg.fantasystick.party.PartyRoster.class);
|
||||
var mitigation = new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions, parties, ignored -> {});
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
doThrow(new IllegalStateException("simulated feedback failure")).when(execution).report(any(), any(), any());
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var target = mock(org.bukkit.entity.Zombie.class);
|
||||
var accepted = new java.util.ArrayList<games.dmg.fantasystick.ability.OutgoingDamageMitigation.Accepted>();
|
||||
mitigation.grantObserved(execution, attacker, new java.math.BigDecimal("20"), accepted::add);
|
||||
var source = DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(attacker).withDirectEntity(attacker).build();
|
||||
var hit = new EntityDamageByEntityEvent(attacker, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
mitigation.reduce(hit); assertEquals(8, hit.getDamage(), 1.0e-9);
|
||||
assertDoesNotThrow(() -> mitigation.accepted(hit));
|
||||
assertEquals(1, accepted.size()); assertEquals(2, accepted.getFirst().prevented(), 1.0e-9);
|
||||
mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownSaveCannotPublishHexEffectOrPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withHex(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
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());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(enemy.getLocation()).thenReturn(new Location(a.world, 8, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(enemy);
|
||||
doReturn(enemy).when(a.player).getTargetEntity(64);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
use(f, a); var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
var hit = new EntityDamageByEntityEvent(enemy, mock(org.bukkit.entity.Zombie.class),
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(DamageSource.class), 10);
|
||||
f.dispatcher.accept(hit); assertEquals(10, hit.getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(0, 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 failedCooldownPersistenceAppliesNoHex() {
|
||||
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 mitigation=new games.dmg.fantasystick.ability.OutgoingDamageMitigation(sessions,parties,ignored->{});var hex=new games.dmg.fantasystick.ability.Hex(games.dmg.fantasystick.ability.Hex.Settings.defaults(),mitigation);var registry=new games.dmg.fantasystick.ability.AbilityRegistry();registry.register(hex.definition());
|
||||
@@ -139,11 +314,15 @@ class HexNativeTest {
|
||||
@Test void cancelledDamageCreatesNoReductionOrSupportButRetainsAcknowledgedCooldown() 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);withHex(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()));
|
||||
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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.LOWEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
|
||||
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(10,hit.getDamage(),1.0e-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());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,8,64,0));doReturn(true).when(a.player).hasLineOfSight(enemy);doReturn(enemy).when(a.player).getTargetEntity(64);use(f,a);PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);
|
||||
class Veto implements org.bukkit.event.Listener{@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)public void damage(EntityDamageByEntityEvent event){if(event.getDamager()==enemy)event.setCancelled(true);}}
|
||||
f.dispatcher.register(new Veto(),f.plugin);var source=DamageSource.builder(DamageType.MOB_ATTACK).withCausingEntity(enemy).withDirectEntity(enemy).build();var hit=new EntityDamageByEntityEvent(enemy,b.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertTrue(hit.isCancelled());assertEquals(8,hit.getDamage(),1.0e-9);
|
||||
var storage=(CharacterStorage)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"characters");assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("hex"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"), "Cancellation after the outgoing stage cannot claim real prevention");
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedPenalty() throws Exception {
|
||||
@@ -176,6 +355,38 @@ class HexNativeTest {
|
||||
for(String invalid:List.of("hex-cooldown-seconds: -1\n","hex-range-blocks: 0\n","hex-duration-seconds: 0\n","hex-damage-reduction-percent: 101\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());}}
|
||||
}
|
||||
|
||||
@Test void authorizedHexConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() 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", "hex-damage-reduction-percent"});
|
||||
assertTrue(admin.messages.getLast().contains("hex-damage-reduction-percent=20"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "hex-damage-reduction-percent", "30"});
|
||||
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("30"), registry.find("hex").orElseThrow()
|
||||
.parameters().get("damage-reduction-percent"));
|
||||
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", "hex-damage-reduction-percent", "40"});
|
||||
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", "hex-damage-reduction-percent"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("20", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("30", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void partyAndMixedModeAimedPlayersRefuseWithoutCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("player-refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);org.bukkit.entity.Player target;
|
||||
if(route==0){var b=new FantasyStickLifecycleNativeTest.Actor(dir);target=b.player;f.players.put(b.core.id,b.player);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()));}else{target=mock(org.bukkit.entity.Player.class);when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());}
|
||||
@@ -184,8 +395,35 @@ class HexNativeTest {
|
||||
|
||||
@Test void firstAimedOutOfRangeOrObstructedTargetRefusesWithoutFallbackOrCooldown() throws Exception {
|
||||
for(int route=0;route<2;route++){var dir=data.resolve("refused-"+route);try(var f=new FantasyStickLifecycleNativeTest.Fixture(dir)){f.plugin.setEnabled(true);var a=new FantasyStickLifecycleNativeTest.Actor(dir);withHex(dir,a,true);f.players.put(a.core.id,a.player);f.activate(a);
|
||||
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);doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).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("hex"));}}
|
||||
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());doReturn(new Location(a.world,0,64,0)).when(a.player).getLocation();when(enemy.getLocation()).thenReturn(new Location(a.world,route==0?13:8,64,0));doReturn(route==0).when(a.player).hasLineOfSight(enemy);doReturn(enemy).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("hex"));}
|
||||
var own = events(dir).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("hex")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.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().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
private static long count(java.util.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(java.util.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,abilityButton(a,"[Learn]","hex"));PartyBookNativeTest.drain(f);PartyBookNativeTest.drain(f);}
|
||||
|
||||
@@ -45,10 +45,32 @@ class QuickstepNativeTest {
|
||||
assertEquals(new Vector(1, 0.25, 0), velocity.get());
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
|
||||
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
|
||||
var from = a.core.location.clone(); var to = from.clone().add(4, 0, 0);
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(1, 0, 0)));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(1, 0, 0), from.clone().add(2, 0, 0)));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(2, 0, 0), to));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("completed", result.get("outcome").getAsString());
|
||||
assertEquals("12", cast.get("captured_dex").getAsString());
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertEquals(4, cast.get("intended_distance").getAsDouble());
|
||||
assertEquals(1, cast.getAsJsonObject("accepted_velocity").get("x").getAsDouble());
|
||||
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
|
||||
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + 4,
|
||||
result.getAsJsonObject("to_location").get("x").getAsDouble());
|
||||
assertEquals(4, result.get("horizontal_distance").getAsDouble());
|
||||
assertEquals(cast.get("action_id").getAsString(), result.get("action_id").getAsString());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).count());
|
||||
}
|
||||
|
||||
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
|
||||
@@ -68,6 +90,14 @@ class QuickstepNativeTest {
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
|
||||
}
|
||||
for (int route = 0; route < 4; route++) {
|
||||
var own = events(data.resolve(Integer.toString(route))).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
|
||||
@@ -92,17 +122,60 @@ class QuickstepNativeTest {
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
|
||||
} else if (route == 1) {
|
||||
velocity.set(new Vector(0, 0.3, 0));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
|
||||
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
|
||||
} else {
|
||||
velocity.set(new Vector(0, 0.6, 2));
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
|
||||
new Location(a.world, 0.2, 64, 0)));
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
|
||||
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
|
||||
}
|
||||
verify(a.player, never()).setFallDistance(anyFloat());
|
||||
}
|
||||
for (int route = 0; route < 3; route++) {
|
||||
var own = events(data.resolve("motion-" + route)).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(new String[]{"cancelled", "obstructed", "replaced"}[route],
|
||||
result.get("outcome").getAsString());
|
||||
assertEquals(route == 0 ? 0 : 0.2, result.get("horizontal_distance").getAsDouble(), 1.0e-8);
|
||||
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + (route == 0 ? 0 : 0.2),
|
||||
result.getAsJsonObject("to_location").get("x").getAsDouble(), 1.0e-8);
|
||||
assertEquals(route == 0 ? "cancelled" : "accepted",
|
||||
cast.get("native_velocity_status").getAsString());
|
||||
assertEquals(route != 0, cast.has("accepted_velocity"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void crossWorldMoveInterruptsOwnedDashWithoutInventingComparableTravel() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var otherWorld = mock(org.bukkit.World.class);
|
||||
when(otherWorld.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(otherWorld.getName()).thenReturn("another-test-world");
|
||||
var from = a.core.location.clone();
|
||||
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, new Location(otherWorld, 123, 70, -8)));
|
||||
assertEquals(new Vector(0, 0.25, 0), velocity.get());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var result = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("interrupted", result.get("outcome").getAsString());
|
||||
assertEquals("another-test-world", result.getAsJsonObject("to_location").get("world_name").getAsString());
|
||||
assertEquals("not_comparable_cross_world", result.get("horizontal_distance_status").getAsString());
|
||||
assertFalse(result.has("horizontal_distance"));
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
|
||||
@@ -129,6 +202,90 @@ class QuickstepNativeTest {
|
||||
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
var interruption = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("interrupted")).toList();
|
||||
assertEquals(1, interruption.size());
|
||||
assertTrue(interruption.getFirst().getAsJsonObject("data").getAsJsonObject("to_location").has("world_id"));
|
||||
}
|
||||
|
||||
@Test void restartedNewHolderCannotRepeatCommittedDashBeforeTheOriginalDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "quickstep-cooldown-seconds: 60\n");
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.25, 0));
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(a.player, times(1)).setVelocity(new Vector(1, 0.25, 0));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
prepareMovement(b, new Vector(0, 0.3, 0)); use(f, b);
|
||||
verify(b.player, never()).setVelocity(any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
var outcomes = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(2, outcomes.size(), outcomes.toString());
|
||||
assertEquals(java.util.Set.of("completed", "refused"), java.util.Set.copyOf(outcomes),
|
||||
"Original bounded window ended before transfer; restart only refuses its retained deadline");
|
||||
}
|
||||
|
||||
@Test void airborneAfterCooldownAcknowledgmentCannotClaimCompletedMovement() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
use(f, a);
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
|
||||
doReturn(false).when(a.player).isOnGround(); response.run();
|
||||
verify(a.player, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("cancelled", cast.get("native_velocity_status").getAsString());
|
||||
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
}
|
||||
|
||||
@Test void failedNativeCooldownSaveEmitsFailureWithoutAClaimedDashOrVelocity() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
use(f, a);
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceAppliesNoVelocity() {
|
||||
@@ -166,6 +323,38 @@ class QuickstepNativeTest {
|
||||
engine.close(); quickstep.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void authorizedRegisteredConfigurationReadAndAcknowledgedChangeReachJsonlButFailuresDoNot() 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", "quickstep-distance-blocks"});
|
||||
assertTrue(admin.messages.getLast().contains("quickstep-distance-blocks=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks", "7"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("7"), registry.find("quickstep").orElseThrow()
|
||||
.parameters().get("distance"));
|
||||
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", "quickstep-distance-blocks", "9"});
|
||||
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", "quickstep-distance-blocks"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -240,6 +429,20 @@ class QuickstepNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.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().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -25,10 +25,16 @@ class SecondWindNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionActualNativeHealPersistsBeforePublicationAndRetainsCooldown() throws Exception {
|
||||
java.util.UUID expectedPlayer, expectedCharacter, expectedSession, expectedWorld;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withSecondWind(data, a, false, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
expectedPlayer = a.core.id; expectedCharacter = a.core.record.id();
|
||||
expectedSession = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(expectedPlayer).orElseThrow().session();
|
||||
expectedWorld = a.core.location.getWorld().getUID();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("SecondWind")); assertTrue(text.contains("CON12")); assertTrue(text.contains("WIS12"));
|
||||
@@ -57,6 +63,34 @@ class SecondWindNativeTest {
|
||||
learnAndAssign(f, a); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var healed = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertEquals("12", cast.get("captured_wis").getAsString());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(cast.get("action_id").getAsString(), healed.get("action_id").getAsString());
|
||||
assertEquals(expectedPlayer.toString(), cast.get("player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), cast.get("character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), cast.get("session_id").getAsString());
|
||||
assertEquals(expectedPlayer.toString(), healed.get("target_player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), healed.get("target_character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), healed.get("target_session_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), healed.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals("4", healed.get("health_before").getAsString());
|
||||
assertEquals("11", healed.get("health_after").getAsString());
|
||||
assertEquals("7", healed.get("actual_amount").getAsString());
|
||||
assertEquals("7", healed.get("requested_amount").getAsString());
|
||||
assertTrue(healed.getAsJsonObject("target_location").has("world_id"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("healed")).count());
|
||||
}
|
||||
|
||||
@Test void fullHealthAndCancelledNativeHealingApplyNoHealth() throws Exception {
|
||||
@@ -82,6 +116,114 @@ class SecondWindNativeTest {
|
||||
if (full) assertFalse(saved.cooldowns().containsKey("second_wind"));
|
||||
else assertNotNull(saved.cooldowns().get("second_wind"));
|
||||
}
|
||||
for (boolean full : new boolean[]{true, false}) {
|
||||
var own = events(data.resolve(Boolean.toString(full))).stream()
|
||||
.filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(full ? 0 : 1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals(full ? "refused" : "cancelled")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nativeAdjustedHealIsAuditedDespiteDisabledRoutineFeedback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"}));
|
||||
f.tick();
|
||||
class Reduce implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler public void heal(EntityRegainHealthEvent event) {
|
||||
if (event.getEntity() == a.player && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.CUSTOM)
|
||||
event.setAmount(2);
|
||||
}
|
||||
}
|
||||
f.dispatcher.register(new Reduce(), f.plugin);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(6, a.player.getHealth());
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Second Wind healed")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
var healed = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("7", healed.get("requested_amount").getAsString());
|
||||
assertEquals("2", healed.get("actual_amount").getAsString());
|
||||
assertEquals("6", healed.get("health_after").getAsString());
|
||||
}
|
||||
|
||||
@Test void rejectedEventStoreDoesNotChangeApprovedSelfHealingOrDurableHealth() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var eventLog = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
|
||||
eventLog.close();
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertEquals(11, a.player.getHealth());
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertEquals(0, BigDecimal.valueOf(11).compareTo(storage.find(a.core.record.id()).get().orElseThrow().health()));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
assertEquals(0, events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed"))
|
||||
.count(), "A rejected audit record cannot manufacture healing or undo real native health");
|
||||
}
|
||||
|
||||
@Test void staleHealthRevisionAfterCooldownCannotClaimAHealOrOverwriteAnotherWrite() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a); use(f, a);
|
||||
var cooldownResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(cooldownResponse);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
var before = storage.find(a.core.record.id()).get().orElseThrow();
|
||||
assertNotNull(before.cooldowns().get("second_wind"));
|
||||
assertTrue(storage.saveHealth(before.id(), before.revision(), BigDecimal.valueOf(5),
|
||||
BigDecimal.valueOf(1024)).get().isPresent());
|
||||
cooldownResponse.run();
|
||||
var healthResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(healthResponse);
|
||||
healthResponse.run();
|
||||
assertEquals(4, a.player.getHealth(), "Stale Second Wind cannot publish native health");
|
||||
assertEquals(0, BigDecimal.valueOf(5).compareTo(storage.find(before.id()).get().orElseThrow().health()));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void failedHealthSaveAfterAcknowledgedCooldownNeverClaimsNativeHealing() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withSecondWind(data, a, true, BigDecimal.valueOf(4));
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
use(f, a);
|
||||
var cooldownResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(cooldownResponse, "Cooldown must commit before the health-save failure");
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close(); cooldownResponse.run();
|
||||
var healthResponse = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(healthResponse); healthResponse.run();
|
||||
assertEquals(4, a.player.getHealth(), "Failed health save cannot publish native health");
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.healed")).count());
|
||||
}
|
||||
|
||||
@Test void failedHealthPersistencePublishesNoNativeHealing() {
|
||||
@@ -116,6 +258,38 @@ class SecondWindNativeTest {
|
||||
verify(player, never()).setHealth(anyDouble()); verify(execution).finish();
|
||||
}
|
||||
|
||||
@Test void authorizedRegisteredConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() 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", "second-wind-base-healing"});
|
||||
assertTrue(admin.messages.getLast().contains("second-wind-base-healing=6"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "second-wind-base-healing", "9"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new BigDecimal("9"), registry.find("second_wind").orElseThrow()
|
||||
.parameters().get("base-healing"));
|
||||
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", "second-wind-base-healing", "11"});
|
||||
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", "second-wind-base-healing"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("second_wind")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied reads and failed writes cannot claim success");
|
||||
assertEquals("6", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionFormulaAndAdminRoutesCoverBothSettings() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"second-wind-base-healing: 8\nsecond-wind-cooldown-seconds: 20\n");
|
||||
@@ -168,6 +342,20 @@ class SecondWindNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.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().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -18,6 +18,20 @@ class SessionRecoveryEventsNativeTest {
|
||||
@TempDir Path data;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void recoveryReaderCountsOnlyExportedUtcDaySegments() throws Exception {
|
||||
Path events = data.resolve("events");
|
||||
Path pending = events.resolve("pending");
|
||||
Path exported = events.resolve(java.time.LocalDate.now(java.time.ZoneOffset.UTC).toString());
|
||||
Files.createDirectories(pending);
|
||||
Files.createDirectories(exported);
|
||||
Files.writeString(pending.resolve("unexported.jsonl"), "{\"type\":\"phantom.recovery\"}\n");
|
||||
Files.writeString(exported.resolve("events-test-0.jsonl"), "{\"type\":\"recovery.resolved\"}\n");
|
||||
|
||||
var records = records();
|
||||
assertEquals(1, records.size(), "Pending records are not exported delivery");
|
||||
assertEquals("recovery.resolved", records.getFirst().get("type").getAsString());
|
||||
}
|
||||
|
||||
@Test void registeredAuthorizedInspectionOfPendingReceiptIsRecordedWithoutEquipmentContents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
@@ -502,13 +516,19 @@ class SessionRecoveryEventsNativeTest {
|
||||
var result = new ArrayList<JsonObject>();
|
||||
Path directory = data.resolve("events");
|
||||
if (!Files.exists(directory)) return result;
|
||||
try (var files = Files.walk(directory)) {
|
||||
for (var path : files.filter(item -> item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
try (var days = Files.list(directory)) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString()
|
||||
.matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = Files.list(day)) {
|
||||
for (var path : segments.filter(item -> item.getFileName().toString().startsWith("events-")
|
||||
&& item.getFileName().toString().endsWith(".jsonl")).toList()) {
|
||||
var contents = Files.readString(path);
|
||||
int end = contents.lastIndexOf('\n');
|
||||
if (end < 0) continue;
|
||||
for (var line : contents.substring(0, end).lines().toList())
|
||||
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -25,10 +25,15 @@ class ShieldBashNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
|
||||
java.util.UUID playerId, characterId, sessionId, targetId, worldId;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
playerId = a.core.id; characterId = a.core.record.id();
|
||||
sessionId = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(playerId).orElseThrow().session();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
|
||||
@@ -46,18 +51,23 @@ class ShieldBashNativeTest {
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
targetId = java.util.UUID.randomUUID(); when(target.getUniqueId()).thenReturn(targetId);
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
|
||||
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
|
||||
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
|
||||
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
|
||||
var targetHealth = new java.util.concurrent.atomic.AtomicReference<>(20.0);
|
||||
when(target.getHealth()).thenAnswer(ignored -> targetHealth.get());
|
||||
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
|
||||
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
|
||||
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
|
||||
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event);
|
||||
if (!event.isCancelled()) targetHealth.updateAndGet(health -> Math.max(0, health - event.getFinalDamage()));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
|
||||
@@ -73,6 +83,30 @@ class ShieldBashNativeTest {
|
||||
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.learned"));
|
||||
assertEquals(1, count(own, "ability.assigned"));
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(1, count(own, "combat.damage"));
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
var cast = record(own, "ability.cast"); var damage = record(own, "combat.damage");
|
||||
var knockback = record(own, "effect.applied"); var resolution = record(own, "ability.resolved");
|
||||
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(playerId.toString(), damage.get("player_id").getAsString());
|
||||
assertEquals(characterId.toString(), damage.get("character_id").getAsString());
|
||||
assertEquals(sessionId.toString(), damage.get("session_id").getAsString());
|
||||
assertEquals(targetId.toString(), damage.get("target_entity_id").getAsString());
|
||||
assertEquals(worldId.toString(), damage.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals("20", damage.get("target_health_before").getAsString());
|
||||
assertEquals("15", damage.get("target_health_after").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), knockback.get("action_id").getAsString());
|
||||
assertEquals("shield_bash_knockback", knockback.get("effect_id").getAsString());
|
||||
assertEquals("hit", resolution.get("outcome").getAsString());
|
||||
assertEquals("native_velocity_accepted", resolution.get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
|
||||
@@ -93,6 +127,11 @@ class ShieldBashNativeTest {
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +171,40 @@ class ShieldBashNativeTest {
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("cancelled", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeDamageNeverClaimsAHeldShieldHitOrKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)); event.setDamage(0); f.dispatcher.accept(event); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target, never()).setVelocity(any(Vector.class));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("miss", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() throws Exception {
|
||||
@@ -157,6 +230,81 @@ class ShieldBashNativeTest {
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
|
||||
}
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
|
||||
assertEquals(1, damage.size(), "Owned accepted strike must not also be generic incoming player damage");
|
||||
assertEquals("shield_bash", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "effect.applied"), "Cancelled velocity is not an applied knockback");
|
||||
assertEquals("native_velocity_cancelled", record(own, "ability.resolved").get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void disabledRoutineFeedbackKeepsAuditAndRejectedWriterCannotChangeAcceptedStrike() throws Exception {
|
||||
for (boolean rejectStore : new boolean[]{false, true}) {
|
||||
var directory = data.resolve(Boolean.toString(rejectStore));
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
|
||||
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
when(target.getVelocity()).thenReturn(new Vector());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(target).damage(eq(5.0), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(target).setVelocity(any(Vector.class));
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Shield Bash hit")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(rejectStore ? 0 : 1, count(own, "combat.damage"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedNativeVelocityPublicationKeepsAcceptedDamageButNeverClaimsKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
when(target.getVelocity()).thenReturn(new Vector());
|
||||
doThrow(new IllegalStateException("simulated native velocity refusal")).when(target).setVelocity(any(Vector.class));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
source, (Double) call.getArgument(0)));
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(1, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("hit", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
assertEquals("native_velocity_failed", record(own, "ability.resolved").get("knockback_status").getAsString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
|
||||
@@ -247,6 +395,38 @@ class ShieldBashNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void authorizedShieldConfigurationReadAndAcknowledgedChangeReachExportedJsonlOnlyAfterSave() 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", "shield-bash-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("shield-bash-base-damage=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage", "7"});
|
||||
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("7"), registry.find("shield_bash").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
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", "shield-bash-base-damage", "8"});
|
||||
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", "shield-bash-base-damage"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
|
||||
String retained = "shield-bash-cooldown-seconds: 6\n";
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
|
||||
@@ -270,6 +450,32 @@ class ShieldBashNativeTest {
|
||||
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
|
||||
}
|
||||
|
||||
@Test void failedCooldownSaveNeverPublishesShieldDamageOrKnockback() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
|
||||
doReturn(true).when(a.player).hasLineOfSight(target);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
verify(target, never()).setVelocity(any(Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
|
||||
assertEquals(0, count(own, "ability.cast"));
|
||||
assertEquals(0, count(own, "combat.damage"));
|
||||
assertEquals(0, count(own, "effect.applied"));
|
||||
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
|
||||
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);
|
||||
@@ -361,6 +567,27 @@ class ShieldBashNativeTest {
|
||||
assertEquals(expected, value.get());
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.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().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
private static long count(java.util.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(java.util.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 fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
@@ -24,10 +24,15 @@ class StoneSkinNativeTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
|
||||
java.util.UUID expectedPlayer, expectedCharacter, expectedSession, expectedWorld, expectedAttacker = java.util.UUID.randomUUID();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
expectedPlayer = a.core.id; expectedCharacter = a.core.record.id(); expectedWorld = a.core.location.getWorld().getUID();
|
||||
expectedSession = ((games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
|
||||
.active(expectedPlayer).orElseThrow().session();
|
||||
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
|
||||
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
|
||||
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
|
||||
@@ -39,6 +44,9 @@ class StoneSkinNativeTest {
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(expectedAttacker);
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(attacker.getLocation()).thenReturn(a.core.location.clone());
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
@@ -59,9 +67,38 @@ class StoneSkinNativeTest {
|
||||
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(2, count(own, "ability.learned"));
|
||||
assertEquals(2, count(own, "ability.assigned"));
|
||||
assertEquals(1, count(own, "ability.unlearned"));
|
||||
assertEquals(1, count(own, "ability.cast"));
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(1, count(own, "effect.ended"));
|
||||
assertEquals(1, count(own, "combat.prevented"));
|
||||
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
|
||||
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
|
||||
assertEquals(2, cast.get("slot").getAsInt());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertEquals(expectedPlayer.toString(), prevented.get("target_player_id").getAsString());
|
||||
assertEquals(expectedCharacter.toString(), prevented.get("target_character_id").getAsString());
|
||||
assertEquals(expectedSession.toString(), prevented.get("target_session_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("target_location").get("world_id").getAsString());
|
||||
assertEquals(expectedAttacker.toString(), prevented.get("attacker_entity_id").getAsString());
|
||||
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
|
||||
assertEquals("25", applied.get("reduction_percent").getAsString());
|
||||
assertTrue(applied.has("effect_until"));
|
||||
assertEquals("8", prevented.get("incoming_amount").getAsString());
|
||||
assertEquals("6", prevented.get("remaining_amount").getAsString());
|
||||
assertEquals("2", prevented.get("actual_amount").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("expired", record(own, "ability.resolved").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
|
||||
var arrowId = java.util.UUID.randomUUID();
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
@@ -71,18 +108,26 @@ class StoneSkinNativeTest {
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
|
||||
var arrow = mock(org.bukkit.entity.Arrow.class);
|
||||
when(arrow.getUniqueId()).thenReturn(arrowId);
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
when(arrow.getLocation()).thenReturn(a.core.location.clone());
|
||||
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
|
||||
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
|
||||
}
|
||||
f.dispatcher.register(new Veto(), f.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
|
||||
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
|
||||
assertEquals(6, cancelled.getDamage(), 1.0e-9, "Late cancellation wins even after reduction was proposed");
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(1, count(own, "combat.prevented"), "Fall and cancelled native damage cannot claim prevention");
|
||||
assertEquals(arrowId.toString(), record(own, "combat.prevented").get("attacker_entity_id").getAsString());
|
||||
}
|
||||
|
||||
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
|
||||
@@ -109,6 +154,63 @@ class StoneSkinNativeTest {
|
||||
f.players.put(b.core.id, b.player); f.activate(b);
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(3, count(own, "effect.applied"));
|
||||
assertEquals(3, count(own, "effect.ended"));
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
|
||||
}
|
||||
|
||||
@Test void laterNativeDamageOverrideCannotClaimAnUnusedReduction() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
class Override implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void restore(EntityDamageByEntityEvent event) { event.setDamage(8); }
|
||||
}
|
||||
f.dispatcher.register(new Override(), f.plugin);
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(1, count(own, "effect.applied"));
|
||||
assertEquals(0, count(own, "combat.prevented"));
|
||||
}
|
||||
|
||||
@Test void feedbackOffPreservesPreventionAuditAndRejectedEventStorePreservesNativeReduction() throws Exception {
|
||||
for (boolean rejectStore : new boolean[]{false, true}) {
|
||||
var directory = data.resolve(Boolean.toString(rejectStore));
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(directory); withStoneSkin(directory, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"}));
|
||||
f.tick();
|
||||
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
|
||||
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(attacker.getLocation()).thenReturn(a.core.location.clone());
|
||||
var hit = new EntityDamageByEntityEvent(attacker, a.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
|
||||
mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
f.dispatcher.accept(hit);
|
||||
assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertFalse(a.messages.stream().anyMatch(message -> message.contains("prevented")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(rejectStore ? 0 : 1, count(own, "combat.prevented"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
|
||||
@@ -158,6 +260,44 @@ class StoneSkinNativeTest {
|
||||
stone.close(); mitigation.close();
|
||||
}
|
||||
|
||||
@Test void rejectedCooldownSaveCannotEmitAStoneEffectOrClaimPrevention() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
|
||||
use(f, a); var rejected = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(rejected); rejected.run();
|
||||
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
|
||||
assertEquals(0, 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 failingPreventionFeedbackCannotInterruptAcceptedNativeDamageObservation() {
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
var player = mock(org.bukkit.entity.Player.class);
|
||||
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
doThrow(new IllegalStateException("broken feedback")).when(execution).report(any(), any(), any());
|
||||
var observed = new java.util.ArrayList<games.dmg.fantasystick.ability.CombatMitigation.Accepted>();
|
||||
mitigation.grantAccepted(execution, player, new java.math.BigDecimal("25"), () -> true, observed::add);
|
||||
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
|
||||
mitigation.reduce(hit);
|
||||
assertDoesNotThrow(() -> mitigation.accepted(hit));
|
||||
assertEquals(6, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, observed.size());
|
||||
assertEquals(2, observed.getFirst().prevented(), 1.0e-9);
|
||||
mitigation.close();
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceCreatesNoReduction() {
|
||||
var storage = mock(CharacterStorage.class);
|
||||
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
|
||||
@@ -192,6 +332,38 @@ class StoneSkinNativeTest {
|
||||
engine.close(); stone.close(); mitigation.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void authorizedStoneConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() 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", "stone-skin-damage-reduction-percent"});
|
||||
assertTrue(admin.messages.getLast().contains("stone-skin-damage-reduction-percent=25"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent", "40"});
|
||||
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(0, new java.math.BigDecimal("40").compareTo(
|
||||
registry.find("stone_skin").orElseThrow().parameters().get("damage-reduction-percent")));
|
||||
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", "stone-skin-damage-reduction-percent", "50"});
|
||||
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", "stone-skin-damage-reduction-percent"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size());
|
||||
assertEquals("25", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("40", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"),
|
||||
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
|
||||
@@ -237,6 +409,29 @@ class StoneSkinNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.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().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static long count(java.util.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(java.util.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 EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor target, double amount) {
|
||||
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,
|
||||
|
||||
Reference in new issue
Block a user