Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13828054e5 | ||
|
|
39c58ef4d9 | ||
|
|
ec73018f93 | ||
|
|
b86f703d14 | ||
|
|
2231dd058a | ||
|
|
ff28f2e98d | ||
|
|
21877f6820 | ||
|
|
228de3d08f | ||
|
|
e3bc854481 | ||
|
|
3163a491a0 |
No files matched your search
@@ -163,6 +163,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
|
||||
private games.dmg.fantasystick.passive.PassiveEngine passiveEngine;
|
||||
private games.dmg.fantasystick.events.LocalEventLog eventLog;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge preferenceEvents;
|
||||
private games.dmg.fantasystick.events.EventCaptureListener eventCapture;
|
||||
private games.dmg.fantasystick.events.DownedEventBridge downedEvents;
|
||||
private games.dmg.fantasystick.events.RareEventBridge rareEvents;
|
||||
@@ -712,7 +713,16 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
getServer().getVersion(), getServer().getMinecraftVersion(), getDescription().getVersion()), 1024, 134_217_728L,
|
||||
message -> getLogger().warning(message));
|
||||
eventCapture = new games.dmg.fantasystick.events.EventCaptureListener(eventLog, sessions, Clock.systemUTC());
|
||||
preferenceEvents = new games.dmg.fantasystick.events.PreferenceEventBridge(
|
||||
eventLog::emit, Clock.systemUTC(), sessions, feedbackPreferences,
|
||||
failure -> getLogger().log(Level.WARNING, "Preference event publication proof failed", failure));
|
||||
particlesCommand.attachEvents(preferenceEvents);
|
||||
feedbackCommand.attachEvents(preferenceEvents);
|
||||
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
|
||||
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
|
||||
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
|
||||
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
|
||||
@@ -722,6 +732,13 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("fireball");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
abilityEngine.enableCastEventsFor("rallying_cry");
|
||||
abilityEngine.enableCastEventsFor("frost_nova");
|
||||
abilityEngine.enableCastEventsFor("chain_lightning");
|
||||
abilityEngine.enableCastEventsFor("sanctuary");
|
||||
abilityEngine.enableCastEventsFor("war_banner");
|
||||
abilityEngine.enableCastEventsFor("meteor");
|
||||
admin.attachAbilityConfigEvents(new games.dmg.fantasystick.events.AbilityConfigEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
passiveEvents = new games.dmg.fantasystick.events.PassiveEventBridge(eventLog::emit, Clock.systemUTC());books.attachPassiveEvents(passiveEvents);admin.attachPassiveEvents(passiveEvents);
|
||||
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(eventLog::emit, Clock.systemUTC());outOfCombatRecovery.attachEvents(recoveryEvents,record->record.passives().contains(games.dmg.fantasystick.passive.QuickerRegenerationI.ID)?quickerRegeneration.bonus():java.math.BigInteger.ZERO);
|
||||
@@ -819,7 +836,15 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
recipeDiscovery.discoverAll(onlinePlayers);
|
||||
for (var player : onlinePlayers) { sessions.arrive(player); revive.reconcile(player); }
|
||||
sheetResourcePack.deliverAll(onlinePlayers);
|
||||
if(eventLog!=null)eventLog.ready();
|
||||
if (eventLog != null) {
|
||||
var preferenceReplay = preferenceEvents;
|
||||
var savedPreferences = feedbackPreferences;
|
||||
eventLog.ready().whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != games.dmg.fantasystick.events.LocalEventLog.Delivery.REJECTED
|
||||
&& preferenceReplay != null && savedPreferences != null)
|
||||
for (var pendingPreference : savedPreferences.pendingEvents()) preferenceReplay.replay(pendingPreference);
|
||||
});
|
||||
}
|
||||
} catch (RuntimeException | LinkageError failure) {
|
||||
try { onDisable(); }
|
||||
catch (RuntimeException | LinkageError cleanupFailure) { failure.addSuppressed(cleanupFailure); }
|
||||
@@ -841,7 +866,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particleDelivery = null;
|
||||
combatFeedback = null;
|
||||
var closingEvents = sessionEvents; sessionEvents = null;
|
||||
var closingEventLog = eventLog; eventLog = null;
|
||||
var closingEventLog = eventLog; eventLog = null; preferenceEvents = null;
|
||||
var closingEventCapture = eventCapture; eventCapture = null; var closingMessageAuditEvents = messageAuditEvents; messageAuditEvents = null; downedEvents = null; rareEvents = null; bookEvents = null; progressionEvents = null; abilityEvents = null; passiveEvents = null; recoveryEvents = null; reviveEvents = null; partyAuditEvents = null; combatRewardEvents = null; coinEvents = null; sessionAuditEvents = null;
|
||||
var closingBooks = books; books = null;
|
||||
var closingPartyEvents = partyEvents; partyEvents = null;
|
||||
|
||||
@@ -432,6 +432,19 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.cleansed(audit, target, aided.character(), aided.session(), removed);
|
||||
}
|
||||
@Override public void acceptedPrevention(EquipmentSessions.Active aided, Player ally, Entity attacker,
|
||||
double incoming, double actual, double remaining,
|
||||
UUID bondApplication) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.prevented(audit, ally, aided.character(), aided.session(), attacker,
|
||||
incoming, actual, remaining, bondApplication);
|
||||
}
|
||||
@Override public void acceptedBoost(EquipmentSessions.Active attacking, Player attacker,
|
||||
Entity target, double base, double bonus, double finalDamage,
|
||||
java.math.BigDecimal percent) {
|
||||
if (castEvents != null && audit != null)
|
||||
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason);
|
||||
var effectId = observedEffects.remove(application);
|
||||
|
||||
@@ -36,6 +36,14 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
/** Record only harmful native effects actually removed from this target. */
|
||||
void acceptedCleanse(games.dmg.fantasystick.session.EquipmentSessions.Active aided,
|
||||
org.bukkit.entity.Player target, List<String> removedEffects);
|
||||
/** Report only the final uncancelled native ally reduction, not a proposed redirect. */
|
||||
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 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,
|
||||
double base, double bonus, double finalDamage, java.math.BigDecimal percent);
|
||||
/** End an owned observed effect once; ordinary close ends any remaining observed effects. */
|
||||
void effectEnded(UUID applicationId, String reason);
|
||||
/** An owner may specify a truthful terminal outcome before finishing. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -48,8 +49,17 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested;
|
||||
final BigDecimal defined; final UUID chainId; final int ordinal;
|
||||
final UUID previousId; final Map<String, Object> previousLocation;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, BigDecimal defined,
|
||||
UUID chainId, int ordinal, LivingEntity previous) {
|
||||
this.execution = execution; this.target = target; this.requested = requested;
|
||||
this.defined = defined; this.chainId = chainId; this.ordinal = ordinal;
|
||||
previousId = previous == null ? null : previous.getUniqueId();
|
||||
previousLocation = previous == null ? Map.of() : locationData(previous.getLocation());
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -103,27 +113,41 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var captured = from(execution.definition()); var caster = execution.input().player();
|
||||
var initial = selectInitial(execution.input(), captured.initialRange());
|
||||
if (initial == null) return;
|
||||
if (initial == null) { execution.resolve("miss", Map.of("reason", "initial_target_unavailable")); return; }
|
||||
BigDecimal defined = new BigDecimal(initialDamage(captured.baseDamage(), execution.character().stats().get(Stat.INT)));
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial;
|
||||
var visited = new HashSet<UUID>(); LivingEntity current = initial, previous = null;
|
||||
var chainId = UUID.randomUUID(); int attempted = 0, accepted = 0;
|
||||
int maximum = Math.min(64, 1 + captured.additionalTargets());
|
||||
for (int strike = 0; strike < maximum && current != null && execution.stillCurrent(); strike++) {
|
||||
for (int hop = 0; hop < maximum && current != null && execution.stillCurrent(); hop++) {
|
||||
if (!visited.add(current.getUniqueId()) || !eligible(caster, current)) break;
|
||||
strike(execution, caster, current, defined); visual(caster, current);
|
||||
if (strike + 1 == maximum) break;
|
||||
var attempt = strike(execution, caster, current, defined, chainId, hop + 1, previous);
|
||||
attempted++; if (attempt.accepted) accepted++;
|
||||
visual(caster, current);
|
||||
if (hop + 1 == maximum) break;
|
||||
defined = defined.multiply(captured.jumpMultiplier());
|
||||
previous = current;
|
||||
current = next(caster, current, visited, captured.jumpRange());
|
||||
}
|
||||
var outcome = new LinkedHashMap<String, Object>();
|
||||
outcome.put("chain_id", chainId.toString());
|
||||
outcome.put("initial_target_id", initial.getUniqueId().toString());
|
||||
outcome.put("initial_target_location", locationData(initial.getLocation()));
|
||||
outcome.put("attempted_hops", attempted); outcome.put("accepted_hops", accepted);
|
||||
outcome.put("terminal_reason", !execution.stillCurrent() ? "source_interrupted"
|
||||
: attempted == maximum ? "target_limit" : "no_eligible_next_target");
|
||||
execution.resolve(!execution.stillCurrent() ? "interrupted" : accepted > 0 ? "hit" : "miss", outcome);
|
||||
}
|
||||
|
||||
private void strike(AbilityExecution execution, Player caster, LivingEntity target, BigDecimal defined) {
|
||||
private Attempt strike(AbilityExecution execution, Player caster, LivingEntity target,
|
||||
BigDecimal defined, UUID chainId, int ordinal, LivingEntity previous) {
|
||||
double amount = finite(definiteNonnegative(defined));
|
||||
var origin = caster.getEyeLocation().clone();
|
||||
var source = DamageSource.builder(DamageType.LIGHTNING_BOLT).withCausingEntity(caster)
|
||||
.withDirectEntity(caster).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, defined, chainId, ordinal, previous); damaging = attempt;
|
||||
try { execution.damage(target, amount, source); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private LivingEntity selectInitial(ControlInput input, double range) {
|
||||
@@ -179,13 +203,42 @@ public final class ChainLightning implements AbilityEffect, Listener, AutoClosea
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_kind", "owned_chain_strike");
|
||||
evidence.put("chain_id", attempt.chainId.toString());
|
||||
evidence.put("hop_ordinal", attempt.ordinal);
|
||||
evidence.put("defined_amount", attempt.defined.stripTrailingZeros().toPlainString());
|
||||
if (attempt.previousId != null) {
|
||||
evidence.put("previous_target_id", attempt.previousId.toString());
|
||||
evidence.put("previous_target_location", attempt.previousLocation);
|
||||
}
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), evidence);
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** Exclude only this in-flight native hop from the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.LIGHTNING) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
|
||||
private void visual(Player caster, LivingEntity target) {
|
||||
try {
|
||||
var location = target.getLocation().clone().add(0, target.getHeight() / 2, 0);
|
||||
|
||||
@@ -18,9 +18,12 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
/** Internal strongest-reduction stage shared by Fantasy Stick protection effects. */
|
||||
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; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
|
||||
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
|
||||
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
|
||||
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
|
||||
this.acceptedObservation = acceptedObservation;
|
||||
}
|
||||
boolean valid() {
|
||||
try { return !closed && execution.stillCurrent() && current.getAsBoolean(); }
|
||||
@@ -42,10 +45,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
}
|
||||
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
return grant(execution, player, percent, current, null);
|
||||
}
|
||||
|
||||
/** An owner may observe only the selected, positive native prevention after final cancellation. */
|
||||
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
|
||||
java.util.function.BiConsumer<EntityDamageEvent, Double> 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");
|
||||
var grant = new Grant(execution, player, percent, current);
|
||||
var grant = new Grant(execution, player, percent, current, acceptedObservation);
|
||||
grants.computeIfAbsent(player.getUniqueId(), ignored -> new ArrayList<>()).add(grant);
|
||||
return grant;
|
||||
}
|
||||
@@ -76,6 +85,9 @@ public final class CombatMitigation implements Listener, AutoCloseable {
|
||||
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|
||||
|| !prevention.grant.valid()) return;
|
||||
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
|
||||
if (prevention.grant.acceptedObservation != null) try {
|
||||
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
|
||||
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
|
||||
@@ -50,36 +50,61 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
|
||||
private static final class SlowLease implements AutoCloseable {
|
||||
final LivingEntity target; final PotionEffect previous; final PotionEffect applied;
|
||||
final AbilityExecution execution; UUID application;
|
||||
final long started = System.nanoTime(); boolean owned; boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
@Override public void close() {
|
||||
boolean apply() {
|
||||
owned = target.addPotionEffect(applied);
|
||||
if (owned) try {
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("amplifier", applied.getAmplifier());
|
||||
evidence.put("duration_ticks", applied.getDuration());
|
||||
evidence.put("deadline", java.time.Instant.now().plusMillis(applied.getDuration() * 50L).toString());
|
||||
if (previous != null) {
|
||||
evidence.put("replaced_amplifier", previous.getAmplifier());
|
||||
evidence.put("replaced_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_nova_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native effect. */ }
|
||||
return owned;
|
||||
}
|
||||
@Override public void close() { close("interrupted"); }
|
||||
void close(String reason) {
|
||||
if (closed) return; closed = true; if (!owned) return;
|
||||
var current = target.getPotionEffect(PotionEffectType.SLOWNESS);
|
||||
long elapsed = Math.max(0, (System.nanoTime() - started) / 50_000_000L);
|
||||
int expected = (int) Math.max(0, (long) applied.getDuration() - elapsed);
|
||||
if (current == null || current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2) return;
|
||||
|| current.getDuration() > expected + 2) {
|
||||
if (application != null) execution.effectEnded(application, "superseded");
|
||||
return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
}
|
||||
if (application != null) execution.effectEnded(application, reason);
|
||||
}
|
||||
}
|
||||
private final class LeaseGroup implements AutoCloseable {
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>(); boolean closed;
|
||||
final AbilityExecution execution; final List<SlowLease> leases = new ArrayList<>();
|
||||
String endReason = "interrupted"; boolean closed;
|
||||
LeaseGroup(AbilityExecution execution) { this.execution = execution; }
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; active.remove(execution, this);
|
||||
for (var lease : List.copyOf(leases)) lease.close(); leases.clear();
|
||||
for (var lease : List.copyOf(leases)) lease.close(endReason); leases.clear();
|
||||
}
|
||||
}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution; final LivingEntity target; boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target) { this.execution = execution; this.target = target; }
|
||||
final AbilityExecution execution; final LivingEntity target; final double requested; final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution, LivingEntity target, double requested, int ordinal) {
|
||||
this.execution = execution; this.target = target; this.requested = requested; this.ordinal = ordinal;
|
||||
}
|
||||
}
|
||||
|
||||
private volatile Settings settings;
|
||||
@@ -133,28 +158,39 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
var group = new LeaseGroup(execution); active.put(execution, group); execution.own(group);
|
||||
BigInteger exact = damage(captured.baseDamage(), execution.character().stats().get(Stat.INT));
|
||||
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
|
||||
int affected = 0;
|
||||
for (var target : candidates(source, origin, captured.radius())) {
|
||||
int affected = 0, accepted = 0;
|
||||
var candidates = candidates(source, origin, captured.radius());
|
||||
for (var target : candidates) {
|
||||
if (affected == 64 || !execution.stillCurrent()) break;
|
||||
if (!eligible(execution, target, origin, captured.radius())) continue;
|
||||
var sourceDamage = DamageSource.builder(DamageType.FREEZE).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(origin).build();
|
||||
var attempt = new Attempt(execution, target); damaging = attempt;
|
||||
var attempt = new Attempt(execution, target, amount, affected + 1); damaging = attempt;
|
||||
try { execution.damage(target, amount, sourceDamage); }
|
||||
finally { if (damaging == attempt) damaging = null; }
|
||||
affected++;
|
||||
if (!attempt.accepted || !execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
if (!attempt.accepted) continue;
|
||||
accepted++;
|
||||
if (!execution.stillCurrent() || !eligible(execution, target, origin, captured.radius())) continue;
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(captured.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
captured.slownessAmplifier(), false, true, true));
|
||||
captured.slownessAmplifier(), false, true, true), execution);
|
||||
if (lease.apply()) group.leases.add(lease);
|
||||
}
|
||||
try { particles.world(Particle.SNOWFLAKE, origin, 32,
|
||||
captured.radius() / 2, captured.radius() / 2, captured.radius() / 2, 0.01); }
|
||||
catch (RuntimeException ignored) { }
|
||||
var result = new LinkedHashMap<String, Object>();
|
||||
result.put("candidate_count", candidates.size()); result.put("attempt_count", affected);
|
||||
result.put("accepted_count", accepted); result.put("slowness_count", group.leases.size());
|
||||
result.put("radius", captured.radius()); result.put("requested_amount", amount);
|
||||
result.put("burst_location", locationData(origin));
|
||||
execution.resolve(accepted > 0 ? "hit" : "miss", result);
|
||||
if (group.leases.isEmpty()) execution.finish();
|
||||
else execution.later(captured.slownessDuration(), execution::finish);
|
||||
else execution.later(captured.slownessDuration(), () -> {
|
||||
group.endReason = "expired"; execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private List<LivingEntity> candidates(Player source, Location origin, double radius) {
|
||||
@@ -188,13 +224,33 @@ public final class FrostNova implements AbilityEffect, Listener, AutoCloseable {
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || attempt.accepted || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE
|
||||
if (!ownsAcceptedDamage(event) || attempt.accepted
|
||||
|| event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
attempt.accepted = true;
|
||||
attempt.execution.acceptedDamage(attempt.target, attempt.requested, event.getFinalDamage(),
|
||||
attempt.execution.input().player(), Map.of("damage_kind", "owned_area_burst", "ordinal", attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target, BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
/** The per-target native hit belongs to this cast, not the generic damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var attempt = damaging;
|
||||
if (attempt == null || event.getEntity() != attempt.target
|
||||
|| event.getCause() != EntityDamageEvent.DamageCause.FREEZE) return false;
|
||||
var source = attempt.execution.input().player();
|
||||
var causing = event.getDamageSource().getCausingEntity();
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return (causing == null || causing == source) && (direct == null || direct == source);
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location origin) {
|
||||
var world = origin.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", origin.getX(), "y", origin.getY(), "z", origin.getZ());
|
||||
}
|
||||
|
||||
private static boolean unobstructed(Location origin, LivingEntity target) {
|
||||
var world = origin.getWorld(); if (world == null) return false;
|
||||
var location = target.getLocation(); if (location.getWorld() != world) return false;
|
||||
|
||||
@@ -56,6 +56,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
final Player ally;
|
||||
final EquipmentSessions.Active aided;
|
||||
final Settings settings;
|
||||
UUID application;
|
||||
String endReason = "interrupted";
|
||||
boolean closed;
|
||||
State(AbilityExecution execution, Player guardian, Player ally,
|
||||
EquipmentSessions.Active aided, Settings settings) {
|
||||
@@ -67,14 +69,16 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
closed = true;
|
||||
byGuardian.remove(guardian.getUniqueId(), this);
|
||||
byAlly.remove(ally.getUniqueId(), this);
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NativeTransfer {
|
||||
final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
final State owner; final Player guardian; final Entity attacker; final BigDecimal requested; final BigDecimal available;
|
||||
BigDecimal accepted = BigDecimal.ZERO;
|
||||
NativeTransfer(Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.guardian = guardian; this.attacker = attacker; this.requested = requested; this.available = available;
|
||||
NativeTransfer(State owner, Player guardian, Entity attacker, BigDecimal requested, BigDecimal available) {
|
||||
this.owner = owner; this.guardian = guardian; this.attacker = attacker;
|
||||
this.requested = requested; this.available = available;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +88,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
private final Damage damage;
|
||||
private final boolean nativeDamageMode;
|
||||
private final Consumer<Support> supports;
|
||||
private final Map<UUID, State> byGuardian = new java.util.HashMap<>();
|
||||
private final Map<UUID, State> byAlly = new java.util.HashMap<>();
|
||||
private int transferring;
|
||||
private NativeTransfer nativeTransfer;
|
||||
private record Reduction(State state, Entity attacker, double incoming, double accepted) { }
|
||||
private final Map<EntityDamageByEntityEvent, Reduction> pendingReductions = new java.util.IdentityHashMap<>();
|
||||
private org.bukkit.scheduler.BukkitTask sweepTask;
|
||||
private boolean closed;
|
||||
|
||||
@@ -102,7 +109,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
this.settings = Objects.requireNonNull(settings);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.parties = Objects.requireNonNull(parties);
|
||||
this.damage = damage == null ? this::nativeDamage : damage;
|
||||
this.nativeDamageMode = damage == null;
|
||||
this.damage = nativeDamageMode ? this::nativeDamage : damage;
|
||||
this.supports = Objects.requireNonNull(supports);
|
||||
}
|
||||
|
||||
@@ -153,7 +161,14 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
var state = new State(execution, guardian, ally, aided, captured);
|
||||
byGuardian.put(guardian.getUniqueId(), state); byAlly.put(ally.getUniqueId(), state);
|
||||
execution.own(state); execution.later(captured.duration(), execution::finish);
|
||||
execution.own(state);
|
||||
state.application = execution.effectApplied("guardian_bond", ally,
|
||||
Map.of("redirect_percent", captured.redirectPercent().toPlainString(),
|
||||
"range", Double.toString(captured.range()),
|
||||
"duration_seconds", seconds(captured.duration())));
|
||||
execution.later(captured.duration(), () -> {
|
||||
state.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
@@ -184,8 +199,30 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
double remaining = BigDecimal.valueOf(incoming).subtract(accepted).max(BigDecimal.ZERO).doubleValue();
|
||||
if (!Double.isFinite(remaining)) return;
|
||||
event.setDamage(remaining);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
pendingReductions.put(event, new Reduction(state, attacker, incoming, accepted.doubleValue()));
|
||||
if (!nativeDamageMode) {
|
||||
// Injected test adapters return their final accepted result directly.
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, ally, accepted);
|
||||
supports.accept(new Support(attacker.getUniqueId(), state.execution.input().session().character(), state.aided, accepted));
|
||||
}
|
||||
}
|
||||
|
||||
/** Only an uncancelled original hit can claim actual prevention. */
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedPrevention(EntityDamageByEntityEvent event) {
|
||||
var reduction = pendingReductions.remove(event);
|
||||
if (reduction == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())) return;
|
||||
double actual = Math.min(reduction.accepted(), Math.max(0, reduction.incoming() - event.getFinalDamage()));
|
||||
if (actual <= 0) return;
|
||||
var state = reduction.state();
|
||||
state.execution.acceptedPrevention(state.aided, state.ally, reduction.attacker(),
|
||||
reduction.incoming(), actual, event.getFinalDamage(), state.application);
|
||||
if (nativeDamageMode) {
|
||||
var amount = BigDecimal.valueOf(actual);
|
||||
state.execution.report(AbilityReport.Type.PREVENTION, state.ally, amount);
|
||||
supports.accept(new Support(reduction.attacker().getUniqueId(),
|
||||
state.execution.input().session().character(), state.aided, amount));
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture accepted marked damage before synchronous fantasy-defeat restoration can replace native health. */
|
||||
@@ -193,7 +230,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
Objects.requireNonNull(guardian); Objects.requireNonNull(attacker); Objects.requireNonNull(requested);
|
||||
double amount = requested.doubleValue(); double health = effectiveHealth(guardian);
|
||||
if (!Double.isFinite(amount) || amount <= 0 || !Double.isFinite(health) || health <= 0) return BigDecimal.ZERO;
|
||||
var transfer = new NativeTransfer(guardian, attacker, requested, BigDecimal.valueOf(health));
|
||||
var transfer = new NativeTransfer(byGuardian.get(guardian.getUniqueId()), guardian, attacker,
|
||||
requested, BigDecimal.valueOf(health));
|
||||
nativeTransfer = transfer;
|
||||
try { guardian.damage(amount, attacker); }
|
||||
finally { nativeTransfer = null; }
|
||||
@@ -206,6 +244,17 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
if (transfer == null || event.getEntity() != transfer.guardian || attacker(event) != transfer.attacker
|
||||
|| event.isCancelled() || !Double.isFinite(event.getFinalDamage()) || event.getFinalDamage() <= 0) return;
|
||||
transfer.accepted = BigDecimal.valueOf(event.getFinalDamage()).min(transfer.requested).min(transfer.available);
|
||||
if (transfer.owner != null && transfer.accepted.signum() > 0)
|
||||
transfer.owner.execution.acceptedDamage(transfer.guardian, transfer.requested.doubleValue(),
|
||||
transfer.accepted.doubleValue(), transfer.attacker,
|
||||
Map.of("damage_kind", "guardian_bond_redirection"));
|
||||
}
|
||||
|
||||
/** The guardian's accepted native hit has one richer bond-owned damage record. */
|
||||
public boolean ownsAcceptedRedirect(org.bukkit.event.entity.EntityDamageEvent event) {
|
||||
var transfer = nativeTransfer;
|
||||
return transfer != null && event instanceof EntityDamageByEntityEvent hit
|
||||
&& hit.getEntity() == transfer.guardian && attacker(hit) == transfer.attacker;
|
||||
}
|
||||
|
||||
/** True only during synchronous dispatch of Guardian Bond's marked redirected damage. */
|
||||
@@ -230,7 +279,8 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
}
|
||||
|
||||
private void breakBond(State state) {
|
||||
state.close(); state.execution.finish();
|
||||
state.endReason = "eligibility_changed";
|
||||
state.close(); state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
|
||||
private static boolean eligible(Player guardian, Player ally, double range) {
|
||||
@@ -268,7 +318,9 @@ public final class GuardianBond implements AbilityEffect, Listener, AutoCloseabl
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
if (sweepTask != null) sweepTask.cancel();
|
||||
for (var state : List.copyOf(byGuardian.values())) state.execution.finish();
|
||||
byGuardian.clear(); byAlly.clear();
|
||||
for (var state : List.copyOf(byGuardian.values())) {
|
||||
state.endReason = "shutdown"; state.execution.resolve("interrupted"); state.execution.finish();
|
||||
}
|
||||
byGuardian.clear(); byAlly.clear(); pendingReductions.clear();
|
||||
}
|
||||
}
|
||||
@@ -39,14 +39,54 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
Duration.ofSeconds(2),5,4);}
|
||||
}
|
||||
private record Ground(Block block,Location origin){ }
|
||||
private static final class Attempt {final AbilityExecution execution;final LivingEntity target;boolean accepted;Attempt(AbilityExecution e,LivingEntity t){execution=e;target=t;}}
|
||||
private static final class Attempt {
|
||||
final AbilityExecution execution;final LivingEntity target;final double requested;final int ordinal;
|
||||
boolean accepted;
|
||||
Attempt(AbilityExecution execution,LivingEntity target,double requested,int ordinal){
|
||||
this.execution=execution;this.target=target;this.requested=requested;this.ordinal=ordinal;
|
||||
}
|
||||
}
|
||||
private final class Warning implements AutoCloseable {
|
||||
final AbilityExecution execution;final Player source;final Ground ground;final Settings captured;final BigInteger intelligence;
|
||||
final int maximumTicks;int elapsed;boolean impacted;boolean closed;
|
||||
Warning(AbilityExecution execution,Ground ground,Settings captured){this.execution=execution;source=execution.input().player();this.ground=ground;this.captured=captured;intelligence=execution.character().stats().get(Stat.INT);maximumTicks=(int)Math.max(1,(captured.warning().toNanos()+49_999_999L)/50_000_000L);}
|
||||
void start(){warnings.put(execution,this);execution.own(this);warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void step(){if(closed||impacted)return;if(!execution.stillCurrent()){execution.finish();return;}elapsed++;if(elapsed>=maximumTicks){impact();execution.finish();return;}warningVisual();execution.later(Duration.ofMillis(50),this::step);}
|
||||
void impact(){if(closed||impacted)return;impacted=true;if(!groundCurrent()||!execution.stillCurrent())return;impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));int affected=0;for(var target:candidates(ground.origin(),captured.radius(),captured.verticalRange())){if(affected==64||!execution.stillCurrent())break;if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source).withDirectEntity(source).withDamageLocation(ground.origin()).build();var attempt=new Attempt(execution,target);damaging=attempt;try{execution.damage(target,amount,sourceDamage);}finally{if(damaging==attempt)damaging=null;}affected++;}}
|
||||
void step(){
|
||||
if(closed||impacted)return;
|
||||
if(!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","warning_ticks_elapsed",elapsed));
|
||||
execution.finish();return;
|
||||
}
|
||||
elapsed++;
|
||||
if(elapsed>=maximumTicks){impact();execution.finish();return;}
|
||||
warningVisual();execution.later(Duration.ofMillis(50),this::step);
|
||||
}
|
||||
void impact(){
|
||||
if(closed||impacted)return;impacted=true;
|
||||
if(!groundCurrent()||!execution.stillCurrent()){
|
||||
execution.resolve("interrupted",Map.of("stage","warning","reason","ground_or_source_unavailable"));
|
||||
return;
|
||||
}
|
||||
impactVisual();double amount=finite(damage(captured.baseDamage(),intelligence));
|
||||
int attempted=0,accepted=0;var candidates=candidates(ground.origin(),captured.radius(),captured.verticalRange());
|
||||
for(var target:candidates){
|
||||
if(attempted==64||!execution.stillCurrent())break;
|
||||
if(!eligible(execution,target,ground.origin(),captured.radius(),captured.verticalRange()))continue;
|
||||
var sourceDamage=DamageSource.builder(DamageType.MAGIC).withCausingEntity(source)
|
||||
.withDirectEntity(source).withDamageLocation(ground.origin()).build();
|
||||
var attempt=new Attempt(execution,target,amount,attempted+1);damaging=attempt;
|
||||
try{execution.damage(target,amount,sourceDamage);}
|
||||
finally{if(damaging==attempt)damaging=null;}
|
||||
attempted++;if(attempt.accepted)accepted++;
|
||||
}
|
||||
var evidence=new LinkedHashMap<String,Object>();
|
||||
evidence.put("stage","impact");evidence.put("warning_ticks_elapsed",elapsed);
|
||||
evidence.put("impact_location",locationData(ground.origin()));
|
||||
evidence.put("candidate_count",candidates.size());evidence.put("attempted_count",attempted);
|
||||
evidence.put("accepted_count",accepted);evidence.put("requested_amount",amount);
|
||||
evidence.put("terminal_reason",!execution.stillCurrent()?"source_interrupted":accepted>0?"accepted_hits":"no_accepted_targets");
|
||||
execution.resolve(!execution.stillCurrent()?"interrupted":accepted>0?"hit":"miss",evidence);
|
||||
}
|
||||
boolean groundCurrent(){try{return ground.origin().getWorld()!=null&&ground.block().getWorld()==ground.origin().getWorld()&&ground.block().getType().isSolid();}catch(RuntimeException invalid){return false;}}
|
||||
void warningVisual(){try{var dust=new Particle.DustOptions(Color.fromRGB(255,90,20),1.6f);particles.world(Particle.DUST,ground.origin().clone().add(0,.15,0),24,captured.radius()/2,.05,captured.radius()/2,0,dust);}catch(RuntimeException ignored){}}
|
||||
void impactVisual(){try{var world=ground.origin().getWorld();particles.world(Particle.EXPLOSION_EMITTER,ground.origin(),2,0,0,0,0);particles.world(Particle.FLAME,ground.origin(),48,captured.radius()/2,1,captured.radius()/2,.04);world.playSound(ground.origin(),Sound.ENTITY_GENERIC_EXPLODE,1f,.7f);}catch(RuntimeException ignored){}}
|
||||
@@ -66,11 +106,39 @@ public final class Meteor implements AbilityEffect, Listener, AutoCloseable {
|
||||
public static BigInteger damage(BigInteger intelligence){return damage(BigInteger.valueOf(14),intelligence);}
|
||||
static BigInteger damage(BigInteger base,BigInteger intelligence){Objects.requireNonNull(base);Objects.requireNonNull(intelligence);if(base.signum()<0||intelligence.signum()<0)throw new IllegalArgumentException("Invalid Meteor damage input");var delta=intelligence.subtract(BigInteger.TEN);var divided=delta.divideAndRemainder(BigInteger.TWO);var modifier=divided[0];if(delta.signum()<0&÷d[1].signum()!=0)modifier=modifier.subtract(BigInteger.ONE);return base.add(modifier);}
|
||||
@Override public boolean accepts(ControlInput input){return !closed&&input.target()==null&&ground(input.player(),settings.targetRange())!=null;}
|
||||
@Override public void activate(AbilityExecution execution){if(closed||!execution.stillCurrent())return;var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());if(target==null){execution.finish();return;}new Warning(execution,target,captured).start();}
|
||||
@Override public void activate(AbilityExecution execution){
|
||||
if(closed||!execution.stillCurrent())return;
|
||||
var captured=from(execution.definition());var target=ground(execution.input().player(),captured.targetRange());
|
||||
if(target==null){execution.resolve("miss",Map.of("stage","targeting","reason","ground_unavailable"));execution.finish();return;}
|
||||
new Warning(execution,target,captured).start();
|
||||
}
|
||||
private static Ground ground(Player player,double range){try{var eye=player.getEyeLocation();var hit=player.getWorld().rayTraceBlocks(eye,eye.getDirection(),range,FluidCollisionMode.NEVER,true);if(hit==null||hit.getHitBlock()==null||hit.getHitBlockFace()!=BlockFace.UP||!hit.getHitBlock().getType().isSolid())return null;var block=hit.getHitBlock();return new Ground(block,block.getLocation().add(.5,1,.5));}catch(RuntimeException invalid){return null;}}
|
||||
private List<LivingEntity> candidates(Location origin,double radius,double vertical){List<Entity> nearby;try{nearby=new ArrayList<>(origin.getWorld().getNearbyEntities(origin,radius,vertical,radius));}catch(RuntimeException unavailable){return List.of();}var unique=new LinkedHashMap<UUID,LivingEntity>();int inspected=0;for(var entity:nearby){if(++inspected>256)break;if(entity instanceof LivingEntity living)unique.putIfAbsent(living.getUniqueId(),living);}var result=new ArrayList<>(unique.values());result.sort(Comparator.comparingDouble((LivingEntity target)->distance(origin,target)).thenComparing(LivingEntity::getUniqueId));return result;}
|
||||
private boolean eligible(AbilityExecution execution,LivingEntity target,Location origin,double radius,double vertical){var source=execution.input().player();if(target==source||!inside(origin,target,radius,vertical)||!unobstructed(origin,target))return false;if(!(target instanceof Player player))return true;var active=sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);if(active==null)return false;var sourceParty=parties.partyOf(source.getUniqueId()).orElse(null);var targetParty=parties.partyOf(player.getUniqueId()).orElse(null);return sourceParty==null||targetParty==null||!sourceParty.id().equals(targetParty.id());}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)public void acceptedDamage(EntityDamageEvent event){var attempt=damaging;if(attempt==null||attempt.accepted||event.isCancelled()||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;attempt.accepted=true;attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));}
|
||||
@EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true)
|
||||
public void acceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(!ownsAcceptedDamage(event)||attempt.accepted||event.isCancelled()
|
||||
||event.getFinalDamage()<=0||!Double.isFinite(event.getFinalDamage()))return;
|
||||
attempt.accepted=true;
|
||||
attempt.execution.acceptedDamage(attempt.target,attempt.requested,event.getFinalDamage(),
|
||||
attempt.execution.input().player(),Map.of("damage_kind","owned_meteor_impact","ordinal",attempt.ordinal));
|
||||
attempt.execution.report(AbilityReport.Type.DAMAGE,attempt.target,BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
/** Exclude only this accepted in-flight impact from the generic native damage observer. */
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event){
|
||||
var attempt=damaging;
|
||||
if(attempt==null||event.getEntity()!=attempt.target||event.getCause()!=EntityDamageEvent.DamageCause.MAGIC)return false;
|
||||
var caster=attempt.execution.input().player();
|
||||
var causing=event.getDamageSource().getCausingEntity();var direct=event.getDamageSource().getDirectEntity();
|
||||
return (causing==null||causing==caster)&&(direct==null||direct==caster);
|
||||
}
|
||||
private static Map<String,Object> locationData(Location location){
|
||||
var world=location==null?null:location.getWorld();
|
||||
if(world==null||world.getUID()==null||world.getName()==null)return Map.of("location_status","unavailable");
|
||||
return Map.of("world_id",world.getUID().toString(),"world_name",world.getName(),
|
||||
"x",location.getX(),"y",location.getY(),"z",location.getZ());
|
||||
}
|
||||
private static boolean inside(Location origin,LivingEntity target,double radius,double vertical){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return false;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz<=radius*radius&&Math.abs(at.getY()-origin.getY())<=vertical;}catch(RuntimeException invalid){return false;}}
|
||||
private static boolean unobstructed(Location origin,LivingEntity target){try{var world=origin.getWorld();var at=target.getLocation();if(world==null||at.getWorld()!=world)return false;var bounds=target.getBoundingBox();var center=bounds==null?at.toVector().add(new org.bukkit.util.Vector(0,target.getHeight()/2,0)):bounds.getCenter();var offset=center.subtract(origin.toVector());double distance=offset.length();if(!Double.isFinite(distance)||distance==0)return distance==0;return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;}catch(RuntimeException invalid){return false;}}
|
||||
private static double distance(Location origin,LivingEntity target){try{var at=target.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;}catch(RuntimeException invalid){return Double.POSITIVE_INFINITY;}}
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(recipient.getUniqueId()); }
|
||||
}
|
||||
}
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount) { }
|
||||
private record Used(Grant grant, Entity target, BigDecimal amount, double base) { }
|
||||
|
||||
private final EquipmentSessions sessions;
|
||||
private final PartyRoster parties;
|
||||
@@ -82,20 +82,26 @@ public final class OutgoingDamageBonus implements Listener, AutoCloseable {
|
||||
BigDecimal added = BigDecimal.valueOf(original).multiply(strongest.percent.movePointLeft(2));
|
||||
double increased = BigDecimal.valueOf(original).add(added).doubleValue();
|
||||
if (!Double.isFinite(increased) || increased < 0 || added.signum() <= 0) return;
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added));
|
||||
event.setDamage(increased); pending.put(event, new Used(strongest, event.getEntity(), added, original));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void accepted(EntityDamageEvent event) {
|
||||
var used = pending.remove(event);
|
||||
if (used == null || event.isCancelled() || event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, used.amount);
|
||||
if (used == null || event.isCancelled() || !Double.isFinite(event.getFinalDamage())
|
||||
|| event.getFinalDamage() <= 0 || !used.grant.valid()) return;
|
||||
double effective = Math.min(used.amount.doubleValue(), event.getFinalDamage());
|
||||
if (!Double.isFinite(effective) || effective <= 0) return;
|
||||
var aided = sessions.active(used.grant.recipient.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
used.grant.execution.acceptedBoost(aided, used.grant.recipient, used.target,
|
||||
used.base, effective, event.getFinalDamage(), used.grant.percent);
|
||||
var actual = BigDecimal.valueOf(effective);
|
||||
used.grant.execution.report(AbilityReport.Type.BOOST_USED, used.target, actual);
|
||||
if (aided == null || aided.character().equals(used.grant.execution.input().session().character())) return;
|
||||
var sourceParty = parties.partyOf(used.grant.execution.input().player().getUniqueId()).orElse(null);
|
||||
var aidedParty = parties.partyOf(used.grant.recipient.getUniqueId()).orElse(null);
|
||||
if (sourceParty == null || aidedParty == null || !sourceParty.id().equals(aidedParty.id())) return;
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, used.amount));
|
||||
supports.accept(new Support(used.target.getUniqueId(), used.grant.execution.input().session().character(), aided, actual));
|
||||
}
|
||||
|
||||
private static Entity attacker(EntityDamageByEntityEvent event) {
|
||||
|
||||
@@ -40,7 +40,8 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence; final AbilityExecution execution; final Player source; final Settings settings;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>(); boolean closed;
|
||||
final Map<UUID, Benefit> recipients = new java.util.HashMap<>();
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Settings settings) {
|
||||
sequence = ++sequences; this.execution = execution; source = execution.input().player(); this.settings = settings;
|
||||
}
|
||||
@@ -86,19 +87,33 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable speed; final AutoCloseable bonus;
|
||||
final UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
speed = speed(player, area.settings.speedAmplifier());
|
||||
bonus = bonuses.grant(area.execution, player, area.settings.damageBonusPercent(), () -> !closed && area.eligible(player));
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("speed_amplifier", area.settings.speedAmplifier());
|
||||
evidence.put("damage_bonus_percent", area.settings.damageBonusPercent().toPlainString());
|
||||
evidence.put("speed_status", "owned_or_superseded_by_existing_speed");
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("rallying_cry_benefit", player, evidence);
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(speed);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,14 +202,23 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var area = new Area(execution, from(execution.definition())); areas.put(area.sequence, area);
|
||||
execution.own(area); area.refresh(); execution.later(area.settings.duration(), execution::finish);
|
||||
execution.own(area);
|
||||
area.application = execution.effectApplied("rallying_cry_area", null,
|
||||
Map.of("radius", Double.toString(area.settings.radius()),
|
||||
"duration_seconds", seconds(area.settings.duration())));
|
||||
area.refresh(); execution.later(area.settings.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable speed(Player player, int amplifier) {
|
||||
var state = speeds.computeIfAbsent(player.getUniqueId(), ignored -> new SpeedState(player));
|
||||
var grant = new SpeedGrant(state, amplifier); state.grants.add(grant); state.refresh(); return grant;
|
||||
}
|
||||
private void breakArea(Area area) { area.close(); area.execution.finish(); }
|
||||
private void breakArea(Area area) {
|
||||
area.endReason = "eligibility_changed"; area.close();
|
||||
area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
private static boolean inside(Player source, Player target, double radius) {
|
||||
try { var a = source.getLocation(); var b = target.getLocation();
|
||||
return a.getWorld() == b.getWorld() && a.distanceSquared(b) <= radius * radius;
|
||||
@@ -220,7 +244,10 @@ public final class RallyingCry implements AbilityEffect, AutoCloseable {
|
||||
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; if (sweepTask != null) sweepTask.cancel();
|
||||
for (var area : List.copyOf(areas.values())) area.execution.finish(); areas.clear();
|
||||
for (var area : List.copyOf(areas.values())) {
|
||||
area.endReason = "shutdown"; area.execution.resolve("interrupted"); area.execution.finish();
|
||||
}
|
||||
areas.clear();
|
||||
for (var state : List.copyOf(speeds.values())) { state.grants.clear(); state.refresh(); }
|
||||
speeds.clear();
|
||||
}
|
||||
|
||||
@@ -53,23 +53,30 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
|
||||
private final class State implements AutoCloseable {
|
||||
final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, AutoCloseable> mitigation = new LinkedHashMap<>();
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> mitigation = new LinkedHashMap<>();
|
||||
Duration elapsed = Duration.ZERO; Duration nextPulse; final Duration scanPeriod;
|
||||
int pending; boolean expired; boolean closed;
|
||||
UUID areaApplication; int pulseOrdinal;
|
||||
String endReason = "interrupted"; int pending; boolean expired; boolean closed;
|
||||
State(AbilityExecution execution, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = source.getLocation().clone();
|
||||
this.captured = captured; this.nextPulse = captured.pulseInterval();
|
||||
long period = Math.max(50_000_000L, (captured.duration().toNanos() + 59) / 60);
|
||||
this.scanPeriod = Duration.ofNanos(period);
|
||||
}
|
||||
void start() { states.add(this); execution.own(this); reconcile(); visual(); schedule(); }
|
||||
void start() {
|
||||
states.add(this); execution.own(this);
|
||||
areaApplication = execution.effectApplied("sanctuary_area", null,
|
||||
Map.of("radius", captured.radius(), "vertical_range", captured.verticalRange(),
|
||||
"duration_seconds", seconds(captured.duration()), "area_location", locationData(origin)));
|
||||
reconcile(); visual(); schedule();
|
||||
}
|
||||
void schedule() {
|
||||
if (closed || expired) return;
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
execution.later(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod, this::step);
|
||||
}
|
||||
void step() {
|
||||
if (closed || !execution.stillCurrent()) { execution.finish(); return; }
|
||||
if (closed || !execution.stillCurrent()) { execution.resolve("interrupted"); execution.finish(); return; }
|
||||
Duration remaining = captured.duration().minus(elapsed);
|
||||
elapsed = elapsed.plus(remaining.compareTo(scanPeriod) < 0 ? remaining : scanPeriod);
|
||||
reconcile(); visual();
|
||||
@@ -77,20 +84,22 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
pulse(); nextPulse = nextPulse.plus(captured.pulseInterval());
|
||||
}
|
||||
if (elapsed.compareTo(captured.duration()) >= 0) {
|
||||
expired = true; closeMitigation(); if (pending == 0) execution.finish();
|
||||
expired = true; endReason = "expired"; closeMitigation();
|
||||
execution.resolve("expired", Map.of("pulse_count", pulseOrdinal));
|
||||
if (pending == 0) execution.finish();
|
||||
} else schedule();
|
||||
}
|
||||
void reconcile() {
|
||||
var eligible = recipients();
|
||||
for (var entry : List.copyOf(mitigation.entrySet())) {
|
||||
if (eligible.stream().noneMatch(player -> player.getUniqueId().equals(entry.getKey()))) {
|
||||
try { entry.getValue().close(); } catch (Exception ignored) { } mitigation.remove(entry.getKey());
|
||||
entry.getValue().close(); mitigation.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored ->
|
||||
combatMitigation.grant(execution, player, captured.damageReductionPercent(), () -> recipientCurrent(player)));
|
||||
for (var player : eligible) mitigation.computeIfAbsent(player.getUniqueId(), ignored -> new Benefit(this, player));
|
||||
}
|
||||
void pulse() {
|
||||
pulseOrdinal++;
|
||||
for (var player : recipients()) if (owner(player) == this) heal(player);
|
||||
}
|
||||
void heal(Player player) {
|
||||
@@ -141,11 +150,46 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
captured.radius() / 2, 0.1, captured.radius() / 2, 0); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
void closeMitigation() {
|
||||
for (var grant : List.copyOf(mitigation.values())) try { grant.close(); } catch (Exception ignored) { }
|
||||
for (var grant : List.copyOf(mitigation.values())) grant.close();
|
||||
mitigation.clear();
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; expired = true; states.remove(this); closeMitigation();
|
||||
if (areaApplication != null) execution.effectEnded(areaApplication, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final State state; final Player player; final AutoCloseable grant;
|
||||
UUID application; boolean closed;
|
||||
Benefit(State state, Player player) {
|
||||
this.state = state; this.player = player;
|
||||
grant = combatMitigation.grant(state.execution, player, state.captured.damageReductionPercent(),
|
||||
() -> !closed && state.recipientCurrent(player), (event, amount) -> {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var attacker = event.getDamageSource().getCausingEntity();
|
||||
if (attacker == null && event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
attacker = byEntity.getDamager();
|
||||
if (active != null && attacker != null && amount > 0)
|
||||
state.execution.acceptedPrevention(active, player, attacker,
|
||||
event.getDamage() + amount, amount, event.getFinalDamage(), application);
|
||||
});
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_reduction_percent", state.captured.damageReductionPercent().toPlainString());
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = state.execution.effectApplied("sanctuary_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Auditing cannot refuse a native mitigation grant. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true;
|
||||
try { grant.close(); } catch (Exception ignored) { }
|
||||
if (application != null) state.execution.effectEnded(application,
|
||||
state.closed || state.expired ? state.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,10 +253,15 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
double desired = Math.min(maximum, persisted.health().doubleValue()); double restored = Math.min(accepted, desired - current);
|
||||
if (!Double.isFinite(current) || !Double.isFinite(maximum) || !Double.isFinite(restored) || restored <= 0) return;
|
||||
target.player().setHealth(Math.min(maximum, current + restored));
|
||||
double after = target.player().getHealth();
|
||||
double actual = after - current;
|
||||
if (!Double.isFinite(actual) || actual <= 0) return;
|
||||
state.execution.acceptedHeal(target.active(), target.player(), persisted,
|
||||
accepted, actual, current, after, maximum);
|
||||
state.execution.report(AbilityReport.Type.HEALING, target.player(),
|
||||
BigDecimal.valueOf(restored).stripTrailingZeros());
|
||||
BigDecimal.valueOf(actual).stripTrailingZeros());
|
||||
if (target.player() != state.source) supports.accept(new MendingLight.Support(
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(restored).stripTrailingZeros()));
|
||||
state.execution.input().session().character(), target.active(), BigDecimal.valueOf(actual).stripTrailingZeros()));
|
||||
} finally { completePending(state); }
|
||||
}
|
||||
private static void completePending(State state) {
|
||||
@@ -240,6 +289,13 @@ public final class Sanctuary implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin, offset.multiply(1 / distance), distance, org.bukkit.FluidCollisionMode.NEVER, true) == null;
|
||||
} catch (RuntimeException invalid) { return false; }
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at = player.getLocation(); if (origin.getWorld() != at.getWorld()) return Double.POSITIVE_INFINITY;
|
||||
double dx = at.getX() - origin.getX(), dz = at.getZ() - origin.getZ(); return dx * dx + dz * dz;
|
||||
|
||||
@@ -55,13 +55,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
private final class Area implements AutoCloseable {
|
||||
final long sequence = ++sequences; final AbilityExecution execution; final Player source;
|
||||
final Location origin; final Settings captured; final Map<UUID, Benefit> recipients = new LinkedHashMap<>();
|
||||
boolean closed;
|
||||
UUID application; String endReason = "interrupted"; boolean closed;
|
||||
Area(AbilityExecution execution, Location origin, Settings captured) {
|
||||
this.execution = execution; this.source = execution.input().player(); this.origin = origin; this.captured = captured;
|
||||
}
|
||||
void refresh() {
|
||||
if (closed) return;
|
||||
if (!sourceCurrent()) { execution.finish(); return; }
|
||||
if (!sourceCurrent()) { endReason = "eligibility_changed"; execution.resolve("interrupted"); execution.finish(); return; }
|
||||
var eligible = candidates();
|
||||
for (var entry : List.copyOf(recipients.entrySet())) if (!eligible.containsKey(entry.getKey())) entry.getValue().close();
|
||||
for (var entry : eligible.entrySet()) recipients.computeIfAbsent(entry.getKey(), ignored -> new Benefit(this, entry.getValue()));
|
||||
@@ -104,20 +104,36 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; areas.remove(sequence, this);
|
||||
for (var benefit : List.copyOf(recipients.values())) benefit.close(); recipients.clear();
|
||||
if (application != null) execution.effectEnded(application, endReason);
|
||||
}
|
||||
}
|
||||
|
||||
private final class Benefit implements AutoCloseable {
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance; boolean closed;
|
||||
final Area area; final Player player; final AutoCloseable bonus; final AutoCloseable resistance;
|
||||
UUID application; boolean closed;
|
||||
Benefit(Area area, Player player) {
|
||||
this.area = area; this.player = player;
|
||||
BooleanSupplier current = () -> !closed && area.eligible(player);
|
||||
bonus = bonuses.grant(area.execution, player, area.captured.damageBonusPercent(), current);
|
||||
resistance = resistance(player, area.captured.knockbackResistancePercent(), current);
|
||||
try {
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var evidence = new LinkedHashMap<String, Object>();
|
||||
evidence.put("damage_bonus_percent", area.captured.damageBonusPercent().toPlainString());
|
||||
evidence.put("knockback_resistance_percent", area.captured.knockbackResistancePercent().toPlainString());
|
||||
evidence.put("area_location", locationData(area.origin));
|
||||
if (active != null) {
|
||||
evidence.put("recipient_character_id", active.character().toString());
|
||||
evidence.put("recipient_session_id", active.session().toString());
|
||||
}
|
||||
application = area.execution.effectApplied("war_banner_benefit", player, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native grant remains authoritative despite audit failure. */ }
|
||||
}
|
||||
@Override public void close() {
|
||||
if (closed) return; closed = true; area.recipients.remove(player.getUniqueId(), this);
|
||||
closeResource(bonus); closeResource(resistance);
|
||||
if (application != null) area.execution.effectEnded(application,
|
||||
area.closed ? area.endReason : "recipient_eligibility_changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +218,14 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return; var captured = from(execution.definition());
|
||||
var origin = placement(execution.input().player(), captured.placementRange());
|
||||
if (origin == null) { execution.finish(); return; }
|
||||
if (origin == null) { execution.resolve("miss", Map.of("reason", "placement_unavailable")); execution.finish(); return; }
|
||||
var area = new Area(execution, origin, captured); areas.put(area.sequence, area); execution.own(area);
|
||||
area.refresh(); execution.later(captured.duration(), execution::finish);
|
||||
area.application = execution.effectApplied("war_banner_area", null,
|
||||
Map.of("area_location", locationData(origin), "radius", captured.radius(),
|
||||
"vertical_range", captured.verticalRange(), "duration_seconds", seconds(captured.duration())));
|
||||
area.refresh(); execution.later(captured.duration(), () -> {
|
||||
area.endReason = "expired"; execution.resolve("expired"); execution.finish();
|
||||
});
|
||||
}
|
||||
|
||||
private AutoCloseable resistance(Player player, BigDecimal percent, BooleanSupplier current) {
|
||||
@@ -231,6 +252,13 @@ public final class WarBanner implements AbilityEffect, AutoCloseable {
|
||||
return world.rayTraceBlocks(origin,offset.multiply(1/distance),distance,FluidCollisionMode.NEVER,true)==null;
|
||||
} catch(RuntimeException invalid){return false;}
|
||||
}
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
var world = location == null ? null : location.getWorld();
|
||||
if (world == null || world.getUID() == null || world.getName() == null)
|
||||
return Map.of("location_status", "unavailable");
|
||||
return Map.of("world_id", world.getUID().toString(), "world_name", world.getName(),
|
||||
"x", location.getX(), "y", location.getY(), "z", location.getZ());
|
||||
}
|
||||
private static double distance(Location origin, Player player) {
|
||||
try { var at=player.getLocation();if(origin.getWorld()!=at.getWorld())return Double.POSITIVE_INFINITY;
|
||||
double dx=at.getX()-origin.getX(),dz=at.getZ()-origin.getZ();return dx*dx+dz*dz;
|
||||
|
||||
@@ -87,7 +87,7 @@ public final class AbilityCastEventBridge {
|
||||
data.put("application_id", application.toString());
|
||||
if (target != null) {
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", target.getType().name());
|
||||
data.put("target_type", entityType(target));
|
||||
var place = target.getLocation();
|
||||
if (place != null) data.put("target_location", locationData(place));
|
||||
}
|
||||
@@ -108,12 +108,12 @@ public final class AbilityCastEventBridge {
|
||||
data.put("requested_amount", requested);
|
||||
data.put("actual_amount", actual);
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", target.getType().name());
|
||||
data.put("target_type", entityType(target));
|
||||
var targetAtApplication = target.getLocation();
|
||||
if (targetAtApplication != null) data.put("target_location", locationData(targetAtApplication));
|
||||
if (target instanceof org.bukkit.entity.Player player) data.put("target_player_id", player.getUniqueId().toString());
|
||||
data.put("direct_entity_id", direct.getUniqueId().toString());
|
||||
data.put("direct_entity_type", direct.getType().name());
|
||||
data.put("direct_entity_type", entityType(direct));
|
||||
emit(cast, "combat.damage", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native damage remains authoritative even when auditing fails. */ }
|
||||
}
|
||||
@@ -165,6 +165,63 @@ public final class AbilityCastEventBridge {
|
||||
} catch (RuntimeException ignored) { /* Native cleanse remains authoritative. */ }
|
||||
}
|
||||
|
||||
public void prevented(Cast cast, org.bukkit.entity.Player ally, UUID aidedCharacter,
|
||||
UUID aidedSession, Entity attacker, double incoming, double actual,
|
||||
double remaining, UUID bondApplication) {
|
||||
if (cast == null || !Double.isFinite(actual) || actual <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
if (bondApplication != null) data.put("bond_application_id", bondApplication.toString());
|
||||
data.put("target_player_id", ally.getUniqueId().toString());
|
||||
data.put("target_character_id", aidedCharacter.toString());
|
||||
data.put("target_session_id", aidedSession.toString());
|
||||
data.put("target_location", locationData(ally.getLocation()));
|
||||
data.put("attacker_entity_id", attacker.getUniqueId().toString());
|
||||
data.put("attacker_entity_type", entityType(attacker));
|
||||
data.put("incoming_amount", decimal(incoming));
|
||||
data.put("actual_amount", decimal(actual));
|
||||
data.put("remaining_amount", decimal(remaining));
|
||||
emit(cast, "combat.prevented", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Prevention authority belongs to the native damage event. */ }
|
||||
}
|
||||
|
||||
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) {
|
||||
if (cast == null || !Double.isFinite(finalDamage) || finalDamage <= 0
|
||||
|| !Double.isFinite(bonus) || bonus <= 0) return;
|
||||
try {
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
var application = UUID.randomUUID();
|
||||
data.put("application_id", application.toString());
|
||||
data.put("causation_id", cast.actionId().toString());
|
||||
data.put("attacker_player_id", attacker.getUniqueId().toString());
|
||||
if (attacking != null) {
|
||||
data.put("attacker_character_id", attacking.character().toString());
|
||||
data.put("attacker_session_id", attacking.session().toString());
|
||||
}
|
||||
data.put("target_entity_id", target.getUniqueId().toString());
|
||||
data.put("target_type", entityType(target));
|
||||
var location = target.getLocation();
|
||||
if (location != null && location.getWorld() != null) data.put("target_location", locationData(location));
|
||||
data.put("base_amount", decimal(base));
|
||||
data.put("actual_amount", decimal(bonus));
|
||||
data.put("final_amount", decimal(finalDamage));
|
||||
data.put("winning_bonus_percent", percent.stripTrailingZeros().toPlainString());
|
||||
data.put("selection", "strongest_applicable_only");
|
||||
emit(cast, "combat.boosted", "accepted", data, application);
|
||||
} catch (RuntimeException ignored) { /* Native combat remains authoritative. */ }
|
||||
}
|
||||
|
||||
private static String entityType(Entity entity) {
|
||||
if (entity instanceof org.bukkit.entity.Player) return "PLAYER";
|
||||
var type = entity.getType();
|
||||
return type == null ? "unavailable" : type.name();
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
|
||||
if (cast == null) return;
|
||||
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package games.dmg.fantasystick.events;
|
||||
|
||||
import games.dmg.fantasystick.persistence.PlayerFeedbackPreferences;
|
||||
import games.dmg.fantasystick.session.EquipmentSessions;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Consumer;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Captured account preferences remain independent of gameplay and feedback delivery. */
|
||||
public final class PreferenceEventBridge {
|
||||
public record Action(UUID id, Instant time, EventInput.Context context, String key,
|
||||
boolean before, boolean savedBefore, Boolean requested, String actorKind) { }
|
||||
|
||||
private final Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink;
|
||||
private final Clock clock;
|
||||
private final EquipmentSessions sessions;
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Throwable> failures;
|
||||
|
||||
public PreferenceEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink, Clock clock,
|
||||
EquipmentSessions sessions, PlayerFeedbackPreferences preferences,
|
||||
Consumer<Throwable> failures) {
|
||||
this.sink = Objects.requireNonNull(sink);
|
||||
this.clock = Objects.requireNonNull(clock);
|
||||
this.sessions = Objects.requireNonNull(sessions);
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
this.failures = Objects.requireNonNull(failures);
|
||||
}
|
||||
|
||||
public Action begin(Player player, String key, boolean before, Boolean requested) {
|
||||
Objects.requireNonNull(player);
|
||||
var active = sessions.active(player.getUniqueId()).filter(sessions::current).orElse(null);
|
||||
var record = active == null ? null : sessions.currentRecord(active).orElse(null);
|
||||
var where = player.getLocation();
|
||||
EventInput.Location location = where == null || where.getWorld() == null ? null
|
||||
: new EventInput.Location(where.getWorld().getUID(), where.getWorld().getName(), where.getX(),
|
||||
where.getY(), where.getZ(), clock.instant(), "exact");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), new EventInput.Context(player.getUniqueId(),
|
||||
player.getName(), record == null ? null : record.id(), record == null ? null : record.name(),
|
||||
record == null ? null : active.session(), location), key, before,
|
||||
key.equals("player-aura-particles") ? preferences.savedPlayerAurasEnabled(player.getUniqueId())
|
||||
: key.equals("combat_feedback") ? preferences.savedEnabled(player.getUniqueId())
|
||||
: preferences.savedAllParticlesEnabled(player.getUniqueId()), requested, "player");
|
||||
emit("action.observed", action, "observed", null, null, before, false);
|
||||
return action;
|
||||
}
|
||||
|
||||
public void refuseNonPlayer(org.bukkit.command.CommandSender sender, String key) {
|
||||
var kind = sender instanceof org.bukkit.command.ConsoleCommandSender ? "console"
|
||||
: sender instanceof org.bukkit.command.BlockCommandSender ? "command_block" : "system";
|
||||
boolean initial = !key.equals("player-aura-particles");
|
||||
var action = new Action(UUID.randomUUID(), clock.instant(), null, key, initial, initial, null, kind);
|
||||
emit("action.observed", action, "observed", null, null, true, false);
|
||||
emit("action.resolved", action, "refused", "player_required", null, true, false);
|
||||
}
|
||||
|
||||
/** Written with the preference value in the same acknowledged file revision. */
|
||||
public PlayerFeedbackPreferences.EventIntent intent(Action action) {
|
||||
var fields = new LinkedHashMap<String,String>();
|
||||
fields.put("time", action.time().toString());
|
||||
fields.put("account", action.context().playerId().toString());
|
||||
fields.put("account_name", action.context().playerName());
|
||||
fields.put("key", action.key());
|
||||
fields.put("before", Boolean.toString(action.before()));
|
||||
fields.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
fields.put("requested", Boolean.toString(action.requested()));
|
||||
fields.put("all_enabled", Boolean.toString(preferences.allParticlesEnabled(action.context().playerId())));
|
||||
if (action.context().characterId() != null) {
|
||||
fields.put("character", action.context().characterId().toString());
|
||||
fields.put("character_name", action.context().characterName());
|
||||
fields.put("session", action.context().sessionId().toString());
|
||||
}
|
||||
var location = action.context().location();
|
||||
if (location != null) {
|
||||
fields.put("world", location.worldId().toString());
|
||||
fields.put("world_name", location.worldName());
|
||||
fields.put("x", Double.toString(location.x()));
|
||||
fields.put("y", Double.toString(location.y()));
|
||||
fields.put("z", Double.toString(location.z()));
|
||||
fields.put("location_time", location.capturedAt().toString());
|
||||
}
|
||||
return new PlayerFeedbackPreferences.EventIntent(action.id(), fields);
|
||||
}
|
||||
|
||||
public void replay(PlayerFeedbackPreferences.EventIntent intent) {
|
||||
try {
|
||||
var row = intent.captured();
|
||||
UUID account = UUID.fromString(row.get("account"));
|
||||
if (!"true".equals(row.get("published"))
|
||||
|| Boolean.parseBoolean(row.getOrDefault("saved_before", row.get("before")))
|
||||
== Boolean.parseBoolean(row.get("requested"))) return;
|
||||
UUID character = row.containsKey("character") ? UUID.fromString(row.get("character")) : null;
|
||||
EventInput.Location location = row.containsKey("world")
|
||||
? new EventInput.Location(UUID.fromString(row.get("world")), row.get("world_name"),
|
||||
Double.parseDouble(row.get("x")), Double.parseDouble(row.get("y")), Double.parseDouble(row.get("z")),
|
||||
Instant.parse(row.get("location_time")), "exact") : null;
|
||||
var context = new EventInput.Context(account, row.get("account_name"), character,
|
||||
row.get("character_name"), character == null ? null : UUID.fromString(row.get("session")), location);
|
||||
var action = new Action(intent.actionId(), Instant.parse(row.get("time")), context, row.get("key"),
|
||||
Boolean.parseBoolean(row.get("before")), Boolean.parseBoolean(
|
||||
row.getOrDefault("saved_before", row.get("before"))),
|
||||
Boolean.parseBoolean(row.get("requested")), "player");
|
||||
boolean after = Boolean.parseBoolean(row.get("requested"));
|
||||
boolean effective = row.get("key").equals("player-aura-particles")
|
||||
? after && Boolean.parseBoolean(row.get("all_enabled")) : after;
|
||||
emit("preference.changed", action, "accepted", "acknowledged", intent.sequence(), effective, true);
|
||||
} catch (RuntimeException ignored) { /* Invalid pending evidence cannot authorize a preference change. */ }
|
||||
}
|
||||
|
||||
public void read(Action action) {
|
||||
emit("action.resolved", action, "accepted", "read", null, action.before(), false);
|
||||
emit("preference.read", action, "accepted", "acknowledged", null, action.before(), false);
|
||||
}
|
||||
|
||||
public void saved(Action action, PlayerFeedbackPreferences.Update update, boolean published, boolean effective) {
|
||||
if (!published) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "superseded", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
if (update.intent() == null || action.savedBefore() == action.requested()) {
|
||||
if (update.intent() != null) preferences.acknowledgeEvent(action.id());
|
||||
emit("action.resolved", action, "refused", "unchanged", update.sequence(), effective, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
preferences.markPublished(action.id()).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
try { failures.accept(failure); } catch (RuntimeException ignoredFailure) { }
|
||||
}
|
||||
emit("action.resolved", action, "accepted", "acknowledged", update.sequence(), effective, false);
|
||||
emit("preference.changed", action, "accepted", "acknowledged", update.sequence(), effective, true);
|
||||
}
|
||||
|
||||
public void failed(Action action, String reason) {
|
||||
emit("action.resolved", action, "failed", reason, null, action.before(), false);
|
||||
}
|
||||
public void refused(Action action, String reason) {
|
||||
emit("action.resolved", action, "refused", reason, null, action.before(), false);
|
||||
}
|
||||
|
||||
private void emit(String type, Action action, String outcome, String reason, Long preferenceSequence,
|
||||
boolean effective, boolean acknowledgedIntent) {
|
||||
if (action == null) return;
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
data.put("action_kind", "preference_" + (action.requested() == null ? "read" : "change"));
|
||||
data.put("entry_point", action.key().equals("combat_feedback") ? "/fstick feedback"
|
||||
: "/fstick particles " + (action.key().equals("player-aura-particles") ? "players" : "all"));
|
||||
data.put("preference_key", action.key());
|
||||
if (action.context() == null) { data.put("actor_kind", action.actorKind()); data.put("location_status", "not_applicable"); }
|
||||
data.put("scope", "account");
|
||||
data.put("default_value", Boolean.toString(!action.key().equals("player-aura-particles")));
|
||||
data.put("before", Boolean.toString(action.before()));
|
||||
data.put("saved_before", Boolean.toString(action.savedBefore()));
|
||||
data.put("effective_before", Boolean.toString(action.before()));
|
||||
if (action.requested() != null) data.put("requested", Boolean.toString(action.requested()));
|
||||
if (action.context() != null) {
|
||||
boolean latestSaved = type.equals("preference.changed") ? action.requested()
|
||||
: preferenceSequence == null ? action.savedBefore()
|
||||
: action.key().equals("player-aura-particles")
|
||||
? preferences.savedPlayerAurasEnabled(action.context().playerId())
|
||||
: action.key().equals("combat_feedback")
|
||||
? preferences.savedEnabled(action.context().playerId())
|
||||
: preferences.savedAllParticlesEnabled(action.context().playerId());
|
||||
data.put("saved_value", Boolean.toString(latestSaved));
|
||||
data.put("effective_value", Boolean.toString(effective));
|
||||
data.put("effective_after", Boolean.toString(effective));
|
||||
}
|
||||
if (preferenceSequence != null) data.put("preference_sequence", Long.toString(preferenceSequence));
|
||||
data.put("outcome", outcome);
|
||||
if (reason != null) data.put(type.startsWith("preference.") ? "acknowledgment" : "reason", reason);
|
||||
if (type.equals("preference.changed")) {
|
||||
data.put("after", Boolean.toString(action.requested()));
|
||||
data.put("preference_revision", Long.toString(preferenceSequence));
|
||||
}
|
||||
try {
|
||||
var input = new EventInput(UUID.nameUUIDFromBytes(("fantasystick:preference:"+action.id()+":"+type)
|
||||
.getBytes(StandardCharsets.UTF_8)), action.id(), type.equals("preference.changed") ? action.time()
|
||||
: clock.instant(), type, action.context(), Map.copyOf(data));
|
||||
var result = sink.apply(input);
|
||||
if (acknowledgedIntent) result.whenComplete((delivery, failure) -> {
|
||||
if (failure == null && delivery != LocalEventLog.Delivery.REJECTED)
|
||||
preferences.acknowledgeEvent(action.id());
|
||||
});
|
||||
} catch (RuntimeException ignored) { /* Logging cannot affect the command or persistent state. */ }
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,11 @@ public final class ChainLightningSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public ChainLightning.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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,10 +71,11 @@ public final class FrostNovaSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public FrostNova.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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,10 +70,11 @@ public final class GuardianBondSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public GuardianBond.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -72,10 +72,11 @@ public final class MeteorSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Meteor.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -42,29 +42,81 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
private AccountPreference { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
public record EventIntent(UUID actionId, Map<String, String> captured, long sequence) {
|
||||
public EventIntent { Objects.requireNonNull(actionId); captured = Map.copyOf(captured); }
|
||||
public EventIntent(UUID actionId, Map<String, String> captured) { this(actionId, captured, 0); }
|
||||
}
|
||||
|
||||
public record Update(UUID account, Preference preference, boolean enabled, long sequence, EventIntent intent) {
|
||||
public Update { Objects.requireNonNull(account); Objects.requireNonNull(preference); }
|
||||
public Update(UUID account, Preference preference, boolean enabled, long sequence) {
|
||||
this(account, preference, enabled, sequence, null);
|
||||
}
|
||||
}
|
||||
|
||||
private final Path directory;
|
||||
private final java.util.concurrent.ExecutorService worker = Executors.newSingleThreadExecutor(
|
||||
task -> new Thread(task, "fantasy-player-feedback-preferences"));
|
||||
private final Map<AccountPreference, Boolean> effective = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Boolean> saved = new java.util.HashMap<>();
|
||||
private final Map<AccountPreference, Long> published = new java.util.HashMap<>();
|
||||
private long persistedSequence;
|
||||
private final Map<UUID, EventIntent> pendingEvents = new LinkedHashMap<>();
|
||||
private boolean closed;
|
||||
private int pending;
|
||||
private final CompletableFuture<Void> drained = new CompletableFuture<>();
|
||||
|
||||
public PlayerFeedbackPreferences(Path directory) throws IOException {
|
||||
this.directory = Objects.requireNonNull(directory).toAbsolutePath();
|
||||
effective.putAll(loadEnabled());
|
||||
var loaded = loadEnabled();
|
||||
effective.putAll(loaded);
|
||||
saved.putAll(loaded);
|
||||
var root = readRoot();
|
||||
var persisted = root.get("preference-sequence");
|
||||
if (persisted != null) {
|
||||
try { persistedSequence = Long.parseLong(String.valueOf(persisted)); }
|
||||
catch (NumberFormatException invalid) { throw new IOException("Invalid preference sequence", invalid); }
|
||||
if (persistedSequence < 0) throw new IOException("Invalid preference sequence");
|
||||
}
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
for (var entry : pending.entrySet()) {
|
||||
UUID id;
|
||||
try { id = UUID.fromString(entry.getKey()); }
|
||||
catch (IllegalArgumentException invalid) { throw new IOException("Invalid pending preference event identity", invalid); }
|
||||
var row = stringMap(entry.getValue(), "pending preference event");
|
||||
var captured = new LinkedHashMap<String, String>();
|
||||
for (var field : row.entrySet()) {
|
||||
if (!(field.getValue() instanceof String text)) throw new IOException("Invalid pending preference field");
|
||||
captured.put(field.getKey(), text);
|
||||
}
|
||||
long sequence;
|
||||
try { sequence = Long.parseLong(captured.remove("preference_sequence")); }
|
||||
catch (RuntimeException invalid) { throw new IOException("Invalid pending preference sequence", invalid); }
|
||||
if (sequence <= 0) throw new IOException("Invalid pending preference sequence");
|
||||
pendingEvents.put(id, new EventIntent(id, captured, sequence));
|
||||
persistedSequence = Math.max(persistedSequence, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing routine combat/effect feedback preference. */
|
||||
public synchronized boolean enabled(UUID account) { return value(account, Preference.COMBAT_FEEDBACK); }
|
||||
public synchronized boolean savedEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.COMBAT_FEEDBACK),
|
||||
Preference.COMBAT_FEEDBACK.defaultValue);
|
||||
}
|
||||
public synchronized boolean playerAurasEnabled(UUID account) { return value(account, Preference.PLAYER_AURAS); }
|
||||
public synchronized boolean allParticlesEnabled(UUID account) { return value(account, Preference.ALL_PARTICLES); }
|
||||
public synchronized boolean savedPlayerAurasEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.PLAYER_AURAS),
|
||||
Preference.PLAYER_AURAS.defaultValue);
|
||||
}
|
||||
public synchronized boolean savedAllParticlesEnabled(UUID account) {
|
||||
Objects.requireNonNull(account);
|
||||
return saved.getOrDefault(new AccountPreference(account, Preference.ALL_PARTICLES),
|
||||
Preference.ALL_PARTICLES.defaultValue);
|
||||
}
|
||||
public synchronized boolean auraParticlesEnabled(UUID account) {
|
||||
return value(account, Preference.ALL_PARTICLES) && value(account, Preference.PLAYER_AURAS);
|
||||
}
|
||||
@@ -78,16 +130,94 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.COMBAT_FEEDBACK, enabled, intent);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setPlayerAuras(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.PLAYER_AURAS, enabled, intent);
|
||||
}
|
||||
|
||||
/** Shared persisted precedence for the separately delivered all-particles command. */
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled);
|
||||
}
|
||||
|
||||
public CompletionStage<Update> setAllParticles(UUID account, boolean enabled, EventIntent intent) {
|
||||
return set(account, Preference.ALL_PARTICLES, enabled, intent);
|
||||
}
|
||||
|
||||
public synchronized java.util.List<EventIntent> pendingEvents() { return java.util.List.copyOf(pendingEvents.values()); }
|
||||
|
||||
/** Persist native publication proof independently of the event writer before a recovered success is claimed. */
|
||||
public CompletionStage<Void> markPublished(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preferences closed"));
|
||||
pending++;
|
||||
}
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
var root = readRoot();
|
||||
var all = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var found = stringMap(all.get(action.toString()), "pending preference event");
|
||||
if (found.isEmpty()) return; // Already accepted by the event writer.
|
||||
found.put("published", "true");
|
||||
all.put(action.toString(), found);
|
||||
root.put("pending-preference-events", all);
|
||||
writeRoot(root);
|
||||
synchronized (this) {
|
||||
var previous = pendingEvents.get(action);
|
||||
if (previous != null) {
|
||||
var snapshot = new LinkedHashMap<>(previous.captured());
|
||||
snapshot.put("published", "true");
|
||||
pendingEvents.put(action, new EventIntent(action, snapshot, previous.sequence()));
|
||||
}
|
||||
}
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
} finally { finished(); }
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
finished();
|
||||
return CompletableFuture.failedStage(rejected);
|
||||
}
|
||||
}
|
||||
|
||||
public CompletionStage<Void> acknowledgeEvent(UUID action) {
|
||||
Objects.requireNonNull(action);
|
||||
try {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (var channel = FileChannel.open(directory.resolve(".player-preferences.lock"),
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
var lock = channel.tryLock()) {
|
||||
if (lock == null) throw new IOException("Player preferences busy");
|
||||
Map<String,Object> root = readRoot();
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
if (pending.remove(action.toString()) == null) return;
|
||||
root.put("pending-preference-events", pending);
|
||||
writeRoot(root);
|
||||
synchronized (this) { pendingEvents.remove(action); }
|
||||
} catch (IOException | OverlappingFileLockException failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
}, worker).minimalCompletionStage();
|
||||
} catch (RejectedExecutionException closedWorker) { return CompletableFuture.failedStage(closedWorker); }
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled) {
|
||||
return set(account, preference, enabled, null);
|
||||
}
|
||||
|
||||
private CompletionStage<Update> set(UUID account, Preference preference, boolean enabled, EventIntent intent) {
|
||||
Objects.requireNonNull(account); Objects.requireNonNull(preference);
|
||||
synchronized (this) {
|
||||
if (closed) return CompletableFuture.failedStage(new IOException("Player preference store is closed"));
|
||||
@@ -96,8 +226,9 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
save(account, preference, enabled);
|
||||
return new Update(account, preference, enabled, ++persistedSequence);
|
||||
long sequence = ++persistedSequence;
|
||||
var accepted = save(account, preference, enabled, intent, sequence);
|
||||
return new Update(account, preference, enabled, sequence, accepted);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new java.util.concurrent.CompletionException(new IOException(
|
||||
"Player preference save refused or failed; effective setting unchanged", failure));
|
||||
@@ -142,7 +273,8 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
return result;
|
||||
}
|
||||
|
||||
private void save(UUID account, Preference preference, boolean enabled) throws IOException {
|
||||
private EventIntent save(UUID account, Preference preference, boolean enabled, EventIntent intent,
|
||||
long sequence) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Unsafe data directory");
|
||||
Path lockFile = directory.resolve(".player-preferences.lock");
|
||||
@@ -155,9 +287,19 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
Map<String, Object> root = readRoot();
|
||||
Map<String, Object> players = stringMap(root.get(PLAYERS), "player preferences");
|
||||
Map<String, Object> values = stringMap(players.get(account.toString()), "player preference entry");
|
||||
boolean before = values.get(preference.key) instanceof Boolean stored ? stored : preference.defaultValue;
|
||||
values.put(preference.key, enabled);
|
||||
players.put(account.toString(), values);
|
||||
root.put(PLAYERS, players);
|
||||
root.put("preference-sequence", Long.toString(sequence));
|
||||
EventIntent accepted = intent != null && before != enabled ? new EventIntent(intent.actionId(), intent.captured(), sequence) : null;
|
||||
if (accepted != null) {
|
||||
var pending = stringMap(root.get("pending-preference-events"), "pending preference events");
|
||||
var captured = new LinkedHashMap<String,Object>(accepted.captured());
|
||||
captured.put("preference_sequence", Long.toString(sequence));
|
||||
pending.put(accepted.actionId().toString(), captured);
|
||||
root.put("pending-preference-events", pending);
|
||||
}
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
|
||||
@@ -171,11 +313,32 @@ public final class PlayerFeedbackPreferences implements AutoCloseable {
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
synchronized (this) {
|
||||
saved.put(new AccountPreference(account, preference), enabled);
|
||||
if (accepted != null) pendingEvents.put(accepted.actionId(), accepted);
|
||||
}
|
||||
return accepted;
|
||||
} catch (OverlappingFileLockException busy) {
|
||||
throw new IOException("Player preferences busy", busy);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeRoot(Map<String,Object> root) throws IOException {
|
||||
byte[] bytes = yaml().dump(root).getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_BYTES) throw new IOException("Player preferences too large");
|
||||
Path temporary = Files.createTempFile(directory, ".player-preferences-", ".tmp");
|
||||
try {
|
||||
try (var output = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) {
|
||||
var buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) output.write(buffer);
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, directory.resolve(FILE), StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
DurableFiles.forceDirectory(directory);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private Map<String, Object> readRoot() throws IOException {
|
||||
Path file = directory.resolve(FILE);
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new LinkedHashMap<>();
|
||||
|
||||
@@ -71,10 +71,11 @@ public final class RallyingCrySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public RallyingCry.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -73,10 +73,11 @@ public final class SanctuarySettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public Sanctuary.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -75,10 +75,11 @@ public final class WarBannerSettings implements AutoCloseable {
|
||||
public String value(String key) { return effectiveValues.get(key); }
|
||||
public WarBanner.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1327,8 +1327,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void guardianBondConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = guardianBondSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + guardianBondSettings.value(key) + " (applies to future Guardian Bond casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "guardian_bond", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Guardian Bond casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.GuardianBondSettings.Update> operation;
|
||||
try { operation = guardianBondSettings.set(key, args[2]); }
|
||||
@@ -1340,8 +1343,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition()); guardianBondSettings.publish(saved);
|
||||
if (failure == null && guardianBondSettings.publish(saved)) {
|
||||
guardianBond.configure(saved.settings()); abilities.replace(guardianBond.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "guardian_bond", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1354,8 +1358,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void rallyingCryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = rallyingCrySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + rallyingCrySettings.value(key) + " (applies to future Rallying Cry casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "rallying_cry", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Rallying Cry casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.RallyingCrySettings.Update> operation;
|
||||
try { operation = rallyingCrySettings.set(key, args[2]); }
|
||||
@@ -1367,8 +1374,9 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition()); rallyingCrySettings.publish(saved);
|
||||
if (failure == null && rallyingCrySettings.publish(saved)) {
|
||||
rallyingCry.configure(saved.settings()); abilities.replace(rallyingCry.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "rallying_cry", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
@@ -1381,8 +1389,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void frostNovaConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostNovaSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostNovaSettings.value(key) + " (applies to future Frost Nova casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_nova", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Nova casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostNovaSettings.Update> operation;
|
||||
try { operation = frostNovaSettings.set(key, args[2]); }
|
||||
@@ -1394,12 +1405,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition()); frostNovaSettings.publish(saved);
|
||||
boolean published = failure == null && frostNovaSettings.publish(saved);
|
||||
if (published) {
|
||||
frostNova.configure(saved.settings()); abilities.replace(frostNova.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_nova", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Nova casts; active slows and deadlines are unchanged."
|
||||
: "Frost Nova configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1408,8 +1421,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void chainLightningConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = chainLightningSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + chainLightningSettings.value(key) + " (applies to future Chain Lightning casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "chain_lightning", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Chain Lightning casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ChainLightningSettings.Update> operation;
|
||||
try { operation = chainLightningSettings.set(key, args[2]); }
|
||||
@@ -1421,12 +1437,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition()); chainLightningSettings.publish(saved);
|
||||
boolean published = failure == null && chainLightningSettings.publish(saved);
|
||||
if (published) {
|
||||
chainLightning.configure(saved.settings()); abilities.replace(chainLightning.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "chain_lightning", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Chain Lightning casts; active chains and deadlines are unchanged."
|
||||
: "Chain Lightning configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1435,8 +1453,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void sanctuaryConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = sanctuarySettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + sanctuarySettings.value(key) + " (applies to future Sanctuary casts only)"); return;
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "sanctuary", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Sanctuary casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.SanctuarySettings.Update> operation;
|
||||
try { operation = sanctuarySettings.set(key, args[2]); }
|
||||
@@ -1448,12 +1469,14 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition()); sanctuarySettings.publish(saved);
|
||||
boolean published = failure == null && sanctuarySettings.publish(saved);
|
||||
if (published) {
|
||||
sanctuary.configure(saved.settings()); abilities.replace(sanctuary.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "sanctuary", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Sanctuary casts; active areas and deadlines are unchanged."
|
||||
: "Sanctuary configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1462,7 +1485,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void warBannerConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
if (args.length == 2) { sender.sendMessage(key + "=" + warBannerSettings.value(key) + " (applies to future War Banner casts only)"); return; }
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = warBannerSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "war_banner", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future War Banner casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.WarBannerSettings.Update> operation;
|
||||
try { operation = warBannerSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) { sender.sendMessage("War Banner setting is invalid; the effective value is unchanged."); return; }
|
||||
@@ -1470,9 +1498,13 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
if (failure != null) report(null, "config-" + key, failure); if (closed) return;
|
||||
try { nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) { warBanner.configure(saved.settings()); abilities.replace(warBanner.definition()); warBannerSettings.publish(saved); }
|
||||
boolean published = failure == null && warBannerSettings.publish(saved);
|
||||
if (published) {
|
||||
warBanner.configure(saved.settings()); abilities.replace(warBanner.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "war_banner", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION) || sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future War Banner casts; active areas and deadlines are unchanged."
|
||||
: "War Banner configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
@@ -1480,10 +1512,35 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
}
|
||||
|
||||
private void meteorConfiguration(CommandSender sender, String[] args) {
|
||||
String key=args[1];if(args.length==2){sender.sendMessage(key+"="+meteorSettings.value(key)+" (applies to future Meteor casts only)");return;}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = meteorSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "meteor", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Meteor casts only)"); return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.MeteorSettings.Update> operation;
|
||||
try{operation=meteorSettings.set(key,args[2]);}catch(IllegalArgumentException invalid){sender.sendMessage("Meteor 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){meteor.configure(saved.settings());abilities.replace(meteor.definition());meteorSettings.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 Meteor casts; active warnings and deadlines are unchanged.":"Meteor configuration save failed; the effective setting is unchanged.");});}catch(RuntimeException rejected){report(null,"config-response",rejected);}});
|
||||
try { operation = meteorSettings.set(key, args[2]); }
|
||||
catch (IllegalArgumentException invalid) {
|
||||
sender.sendMessage("Meteor 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;
|
||||
boolean published = failure == null && meteorSettings.publish(saved);
|
||||
if (published) {
|
||||
meteor.configure(saved.settings()); abilities.replace(meteor.definition());
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "meteor", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(published
|
||||
? "Saved " + key + "=" + saved.value() + " for future Meteor casts; active warnings and deadlines are unchanged."
|
||||
: "Meteor configuration save failed; the effective setting is unchanged.");
|
||||
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
}
|
||||
|
||||
private void hexConfiguration(CommandSender sender, String[] args) {
|
||||
|
||||
@@ -15,8 +15,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge bridge) {
|
||||
this.events = Objects.requireNonNull(bridge);
|
||||
}
|
||||
|
||||
public FantasyFeedbackCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -26,32 +31,51 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (events != null) try { events.refuseNonPlayer(sender, "combat_feedback"); }
|
||||
catch (RuntimeException ignored) { }
|
||||
sender.sendMessage("Fantasy feedback is a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
var action = observe(player, null);
|
||||
player.sendMessage("Fantasy feedback is " + state(preferences.enabled(player.getUniqueId())) + ".");
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) return false;
|
||||
if (args.length != 1 || !(args[0].equals("on") || args[0].equals("off"))) {
|
||||
var action = observe(player, null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
return false;
|
||||
}
|
||||
boolean enabled = args[0].equals("on");
|
||||
var account = player.getUniqueId();
|
||||
preferences.set(account, enabled).whenComplete((saved, failure) -> {
|
||||
var action = observe(player, enabled);
|
||||
var save = action == null ? preferences.set(account, enabled)
|
||||
: preferences.set(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
"Fantasy feedback preference could not be saved; it remains "
|
||||
+ state(preferences.enabled(account)) + ".");
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published, preferences.enabled(account));
|
||||
if (player.isOnline()) player.sendMessage("Fantasy feedback is "
|
||||
+ (published ? "now " : "already ") + state(preferences.enabled(account)) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -62,6 +86,13 @@ public final class FantasyFeedbackCommand implements TabExecutor, AutoCloseable
|
||||
return List.of("off", "on").stream().filter(value -> value.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(Player player, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, "combat_feedback", preferences.enabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Auditing cannot refuse a player preference.
|
||||
}
|
||||
|
||||
private static String state(boolean enabled) { return enabled ? "on" : "off"; }
|
||||
private void report(Throwable failure) { try { failures.accept(failure); } catch (RuntimeException ignored) { } }
|
||||
@Override public void close() { closed = true; }
|
||||
|
||||
@@ -16,8 +16,13 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
private final PlayerFeedbackPreferences preferences;
|
||||
private final Consumer<Runnable> nextTick;
|
||||
private final Consumer<Throwable> failures;
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge events;
|
||||
private boolean closed;
|
||||
|
||||
public void attachEvents(games.dmg.fantasystick.events.PreferenceEventBridge events) {
|
||||
this.events = Objects.requireNonNull(events);
|
||||
}
|
||||
|
||||
public FantasyParticlesCommand(PlayerFeedbackPreferences preferences, Consumer<Runnable> nextTick,
|
||||
Consumer<Throwable> failures) {
|
||||
this.preferences = Objects.requireNonNull(preferences);
|
||||
@@ -27,35 +32,60 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
|
||||
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players")) && events != null) {
|
||||
try { events.refuseNonPlayer(sender, key(args[0])); } catch (RuntimeException ignored) { }
|
||||
}
|
||||
sender.sendMessage("Fantasy particles are a player-account preference.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 1 && (args[0].equals("players") || args[0].equals("all"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
player.sendMessage(status(player.getUniqueId(), args[0]));
|
||||
if (action != null) events.read(action);
|
||||
return true;
|
||||
}
|
||||
if (args.length != 2 || !(args[0].equals("players") || args[0].equals("all"))
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) return false;
|
||||
|| !(args[1].equals("on") || args[1].equals("off"))) {
|
||||
if (args.length > 0 && (args[0].equals("all") || args[0].equals("players"))) {
|
||||
var action = observe(player, args[0], null);
|
||||
if (action != null) events.refused(action, "invalid_arguments");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
String route = args[0];
|
||||
boolean enabled = args[1].equals("on");
|
||||
UUID account = player.getUniqueId();
|
||||
var action = observe(player, route, enabled);
|
||||
var save = route.equals("players")
|
||||
? preferences.setPlayerAuras(account, enabled) : preferences.setAllParticles(account, enabled);
|
||||
? action == null ? preferences.setPlayerAuras(account, enabled)
|
||||
: preferences.setPlayerAuras(account, enabled, events.intent(action))
|
||||
: action == null ? preferences.setAllParticles(account, enabled)
|
||||
: preferences.setAllParticles(account, enabled, events.intent(action));
|
||||
save.whenComplete((saved, failure) -> {
|
||||
if (failure != null) report(failure);
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
if (action != null) events.failed(action, "closed");
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
if (action != null) events.failed(action, "save_failed");
|
||||
if (player.isOnline()) player.sendMessage(
|
||||
(route.equals("players") ? "Active-character aura" : "All-particles")
|
||||
+ " preference could not be saved; " + status(account, route));
|
||||
return;
|
||||
}
|
||||
boolean published = preferences.publish(saved);
|
||||
if (action != null) events.saved(action, saved, published,
|
||||
route.equals("players") ? preferences.auraParticlesEnabled(account)
|
||||
: preferences.allParticlesEnabled(account));
|
||||
if (player.isOnline()) player.sendMessage(acknowledgement(account, route, published));
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(rejected); }
|
||||
} catch (RuntimeException rejected) {
|
||||
if (action != null) events.failed(action, "schedule_failed");
|
||||
report(rejected);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -68,6 +98,18 @@ public final class FantasyParticlesCommand implements TabExecutor, AutoCloseable
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private games.dmg.fantasystick.events.PreferenceEventBridge.Action observe(
|
||||
Player player, String route, Boolean requested) {
|
||||
var bridge = events;
|
||||
if (bridge == null) return null;
|
||||
try { return bridge.begin(player, key(route), route.equals("players")
|
||||
? preferences.auraParticlesEnabled(player.getUniqueId())
|
||||
: preferences.allParticlesEnabled(player.getUniqueId()), requested); }
|
||||
catch (RuntimeException ignored) { return null; } // Capture failure cannot refuse gameplay.
|
||||
}
|
||||
|
||||
private static String key(String route) { return route.equals("players") ? "player-aura-particles" : "all-particles"; }
|
||||
|
||||
private String status(UUID account, String route) {
|
||||
if (route.equals("all"))
|
||||
return "All Fantasy Stick particles are " + state(preferences.allParticlesEnabled(account)) + ".";
|
||||
|
||||
@@ -67,6 +67,69 @@ class ChainLightningNativeTest {
|
||||
assertEquals(BigInteger.valueOf(39), stored.unspentPoints()); assertTrue(stored.learned().contains("chain_lightning"));
|
||||
assertEquals("chain_lightning", stored.loadout().getFirst()); assertNotNull(stored.cooldowns().get("chain_lightning"));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
|
||||
assertEquals(1, abilityEvents(records, type).size(), type);
|
||||
var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(2, hits.size(), "Exactly one positive accepted record per hop");
|
||||
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic native observer must not repeat feature-owned chain hits");
|
||||
assertEquals(List.of(1, 2), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals(2, hits.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(13, hits.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertEquals(9.75, hits.getLast().getAsJsonObject("data").get("actual_amount").getAsDouble(), 1.0e-9);
|
||||
assertTrue(hits.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")));
|
||||
assertEquals("hit", abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("outcome").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
|
||||
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("chain_lightning")).toList();
|
||||
}
|
||||
|
||||
@Test void cancelledMiddleHopContinuesWithoutClaimingItsNativeDamage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data); prepareCaster(data, caster, true);
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.activate(caster);
|
||||
var first = mock(LivingEntity.class); var cancelled = mock(LivingEntity.class); var third = mock(LivingEntity.class);
|
||||
locate(first, caster.world, UUID.randomUUID(), 10);
|
||||
locate(cancelled, caster.world, UUID.randomUUID(), 14);
|
||||
locate(third, caster.world, UUID.randomUUID(), 18);
|
||||
doReturn(new Location(caster.world, 0, 64, 0)).when(caster.player).getLocation();
|
||||
doReturn(new Location(caster.world, 0, 65, 0)).when(caster.player).getEyeLocation();
|
||||
doReturn(true).when(caster.player).hasLineOfSight(first);
|
||||
doReturn(new org.bukkit.util.RayTraceResult(new org.bukkit.util.Vector(10, 64, 0), first))
|
||||
.when(caster.world).rayTraceEntities(any(Location.class), any(org.bukkit.util.Vector.class), eq(16.0),
|
||||
org.mockito.ArgumentMatchers.<java.util.function.Predicate<org.bukkit.entity.Entity>>any());
|
||||
doReturn(List.of(cancelled)).when(first).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(List.of(third)).when(cancelled).getNearbyEntities(anyDouble(), anyDouble(), anyDouble());
|
||||
doReturn(true).when(first).hasLineOfSight(cancelled); doReturn(true).when(cancelled).hasLineOfSight(third);
|
||||
var applied = new LinkedHashMap<UUID, Double>();
|
||||
for (var target : List.of(first, cancelled, third)) nativeDamage(fixture, target, applied);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
|
||||
public void cancel(EntityDamageEvent event) { if (event.getEntity() == cancelled) event.setCancelled(true); }
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, caster); drain(fixture, 2);
|
||||
assertEquals(2, applied.size()); assertFalse(applied.containsKey(cancelled.getUniqueId()));
|
||||
}
|
||||
var records = events(data); var hits = abilityEvents(records, "combat.damage");
|
||||
assertEquals(List.of(1, 3), hits.stream().map(event -> event.getAsJsonObject("data").get("hop_ordinal").getAsInt()).toList());
|
||||
assertEquals("7.3125", hits.getLast().getAsJsonObject("data").get("defined_amount").getAsString());
|
||||
assertEquals(3, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("attempted_hops").getAsInt());
|
||||
assertEquals(2, abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data").get("accepted_hops").getAsInt());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -221,6 +284,18 @@ class ChainLightningNativeTest {
|
||||
assertEquals(0, new BigDecimal("7").compareTo(configured.parameters().get("jump-range")));
|
||||
assertEquals(new BigDecimal("1"), configured.parameters().get("additional-targets"));
|
||||
assertEquals(new BigDecimal("0.25"), configured.parameters().get("jump-multiplier"));
|
||||
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());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "chain-lightning-jump-range-blocks", "8"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("7", ((games.dmg.fantasystick.persistence.ChainLightningSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "chainLightningSettings"))
|
||||
.value("chain-lightning-jump-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "chain-lightning-jump-range-blocks", "7", "extra"});
|
||||
@@ -232,6 +307,11 @@ class ChainLightningNativeTest {
|
||||
assertEquals(new BigDecimal("0.25"), saved.parameters().get("jump-multiplier"));
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("chain-lightning-")).toList();
|
||||
assertEquals(7, config.size(), "One authorized read plus six acknowledged saved settings, no denied/invalid success");
|
||||
}
|
||||
|
||||
private static void nativeDamage(FantasyStickLifecycleNativeTest.Fixture fixture, LivingEntity target, Map<UUID, Double> damage) {
|
||||
|
||||
@@ -38,6 +38,11 @@ class FantasyFeedbackCommandNativeTest {
|
||||
assertEquals("Fantasy feedback is off.", player.messages.getLast());
|
||||
assertTrue(player.messageComponents.isEmpty(), "preference/status notices keep their existing presentation");
|
||||
}
|
||||
var records = feedbackRecords();
|
||||
assertEquals(2, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
}
|
||||
|
||||
@Test void consoleMalformedAndExtraArgumentsCannotChangeAnAccountPreference() throws Exception {
|
||||
@@ -60,6 +65,188 @@ class FantasyFeedbackCommandNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredFeedbackReadAndChangeReachAccountOwnedJsonl() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("Fantasy feedback is now off.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords();
|
||||
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("combat_feedback", read.get("preference_key").getAsString());
|
||||
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
|
||||
assertEquals("/fstick feedback", read.get("entry_point").getAsString());
|
||||
assertEquals("true", read.get("default_value").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
var change = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
|
||||
assertEquals("true", change.get("before").getAsString());
|
||||
assertEquals("false", change.get("after").getAsString());
|
||||
assertTrue(change.get("preference_sequence").getAsLong() > 0);
|
||||
assertEquals(actor.world.getUID().toString(), change.getAsJsonObject("location").get("world_id").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackRefusalsFailedAndSupersededWritesCannotClaimSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "on"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick", new String[]{"feedback", "OFF"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
|
||||
records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data").get("reason").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedFeedbackSaveIsReportedWithoutChangingEffectiveAccountState() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertTrue(feedbackRecords().stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void closedFeedbackCommandCannotPublishUnacknowledgedPreferenceSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.set(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
((games.dmg.fantasystick.session.FantasyFeedbackCommand)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackCommand")).close();
|
||||
fixture.tick();
|
||||
assertTrue(preferences.enabled(actor.core.id), "Closed callback leaves the current effective value unchanged");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = feedbackRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertTrue(records.stream().anyMatch(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("closed")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackOutboxRecoversOriginalAcceptedChangeAfterSinkOutage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(p -> "true".equals(p.captured().get("published"))));
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && feedbackRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void feedbackPreferenceIsAccountOwnedAcrossCharacterTransfer() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var recipient = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(owner.core.id, owner.player); fixture.players.put(recipient.core.id, recipient.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
fixture.activate(owner);
|
||||
var sheet = owner.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(owner.player);
|
||||
fixture.awaitCharacterAndReceipt(owner);
|
||||
recipient.core.inventory.setItem(8, recipient.core.inventory.getItemInOffHand());
|
||||
recipient.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(recipient);
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertEquals("Fantasy feedback is off.", owner.messages.getLast());
|
||||
assertTrue(root.onCommand(recipient.player, fixture.playerCommand, "fstick", new String[]{"feedback"}));
|
||||
assertEquals("Fantasy feedback is on.", recipient.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var reads = feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("preference_key").getAsString().equals("combat_feedback")).toList();
|
||||
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
|
||||
&& d.get("effective_value").getAsString().equals("false") && !d.has("character_id")));
|
||||
assertTrue(reads.stream().anyMatch(d -> d.get("player_id").getAsString().equals(recipient.core.id.toString())
|
||||
&& d.get("effective_value").getAsString().equals("true")
|
||||
&& d.get("character_id").getAsString().equals(owner.core.record.id().toString())));
|
||||
assertEquals(1, feedbackRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
}
|
||||
}
|
||||
|
||||
private List<com.google.gson.JsonObject> feedbackRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
@Test void sourceAndAffectedAccountPreferencesIndependentlyFilterPerTickMessages() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
|
||||
@@ -287,6 +287,503 @@ class FantasyParticlesNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAllParticlesReadAndSavedChangeProduceAccountEvents() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all"}));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && !preferences.pendingEvents().isEmpty(); attempt++)
|
||||
Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().isEmpty(), "First acknowledged event was not drained");
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals(actor.core.id.toString(), read.get("player_id").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data")).filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("true", changed.get("before").getAsString());
|
||||
assertEquals("false", changed.get("after").getAsString());
|
||||
assertTrue(changed.get("preference_sequence").getAsLong() > 0);
|
||||
assertEquals(read.get("player_id"), changed.get("player_id"));
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "on"}));
|
||||
for (int attempt = 0; attempt < 80 && !actor.messages.getLast().contains("now on"); attempt++) {
|
||||
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
}
|
||||
assertEquals("All Fantasy Stick particles are now on.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
var logical = changes.stream().collect(java.util.stream.Collectors.toMap(
|
||||
d -> d.get("action_id").getAsString(), d -> d, (first, duplicate) -> first,
|
||||
java.util.LinkedHashMap::new));
|
||||
assertEquals(2, logical.size(), "At-least-once export may duplicate a physical line, not a logical change");
|
||||
assertTrue(logical.values().stream().mapToLong(d -> d.get("preference_sequence").getAsLong()).max().orElseThrow()
|
||||
> changed.get("preference_sequence").getAsLong(),
|
||||
"The acknowledged preference revision must stay ordered across restart");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void refusedAndSupersededAllParticleWritesNeverClaimAnAcceptedChange() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "on"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertTrue(preferences.allParticlesEnabled(actor.core.id));
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "invalid"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var resolved = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("preference_key") && d.get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments"), resolved.stream()
|
||||
.map(d -> d.get("reason").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(3, resolved.size());
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
|
||||
"Superseded and unchanged saves cannot become successes during restart recovery");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void failedAllParticleSaveRemainsObservableWithoutChangingGameplay() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved")
|
||||
&& e.getAsJsonObject("data").has("reason")
|
||||
&& e.getAsJsonObject("data").get("reason").getAsString().equals("save_failed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void accountOptOutRetainsAuditAndCapturesCurrentCharacterWithoutBorrowingAnotherAccount() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var other = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
fixture.players.put(other.core.id, other.player);
|
||||
fixture.activate(actor);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
|
||||
var sourceSheet = actor.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(actor.player);
|
||||
fixture.awaitCharacterAndReceipt(actor);
|
||||
other.core.inventory.setItem(8, other.core.inventory.getItemInOffHand());
|
||||
other.core.inventory.setItemInOffHand(sourceSheet);
|
||||
fixture.activate(other);
|
||||
assertTrue(root.onCommand(other.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", other.messages.getLast());
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are off.", actor.messages.getLast());
|
||||
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(actor.player,
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "all", "of"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
var changed = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.getAsJsonObject("data")).toList();
|
||||
assertEquals(1, changed.size(), "Particle delivery and autocomplete do not create audit events");
|
||||
assertEquals(actor.core.id.toString(), changed.getFirst().get("player_id").getAsString());
|
||||
assertEquals(actor.core.record.id().toString(), changed.getFirst().get("character_id").getAsString());
|
||||
assertEquals(actor.world.getUID().toString(), changed.getFirst().getAsJsonObject("location")
|
||||
.get("world_id").getAsString());
|
||||
var read = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(other.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertFalse(read.has("character_id"), "An ordinary viewer cannot borrow a playing character identity");
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.anyMatch(d -> d.get("player_id").getAsString().equals(other.core.id.toString())
|
||||
&& d.has("character_id")
|
||||
&& d.get("character_id").getAsString().equals(actor.core.record.id().toString())
|
||||
&& d.get("effective_value").getAsString().equals("true")),
|
||||
"A transferred sheet carries its character, not the previous account's particle preference");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void nonPlayerAllParticleRequestsAreTruthfullyRefusedWithoutInventingPreferenceSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var console = org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class);
|
||||
var block = org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(console, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
assertTrue(root.onCommand(block, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().contains(".preference.")).count());
|
||||
var refused = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.has("reason") && d.get("reason").getAsString().equals("player_required"))
|
||||
.toList();
|
||||
assertEquals(java.util.Set.of("console", "command_block"), refused.stream()
|
||||
.map(d -> d.get("actor_kind").getAsString()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(2, refused.size());
|
||||
assertTrue(refused.stream().allMatch(d -> d.get("location_status").getAsString().equals("not_applicable")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedPreferenceOutlivesUnavailableEventSinkWithoutLosingItsOriginalEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var log = (games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog");
|
||||
log.close(); // Logging fails after gameplay has started; the saved preference remains authoritative.
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertEquals("All Fantasy Stick particles are now off.", actor.messages.getLast());
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(intent -> "true".equals(intent.captured().get("published"))); attempt++)
|
||||
Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(intent -> "true".equals(intent.captured().get("published"))),
|
||||
"Committed publication proof must be durable before orderly restart");
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); attempt++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var events = preferenceRecords();
|
||||
var changes = events.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.filter(e -> e.getAsJsonObject("data").get("preference_key").getAsString().equals("all-particles"))
|
||||
.toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count(),
|
||||
"An acknowledged account change must retain one recoverable event identity");
|
||||
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void readDuringDeferredPublicationDistinguishesSavedFromEffectivePreference() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setAllParticles(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS); // worker barrier; do not execute deferred main callback
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all"}));
|
||||
assertEquals("All Fantasy Stick particles are on.", actor.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
var read = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(actor.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("false", read.get("saved_value").getAsString());
|
||||
assertEquals("true", read.get("effective_value").getAsString());
|
||||
assertEquals(0, preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count(),
|
||||
"A queued native callback has not yet published a changed outcome");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void twoAcknowledgedChangesSurviveEventSinkFailureWithTheirOriginalIdentities() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
for (String value : List.of("off", "on")) {
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", value}));
|
||||
for (int attempt = 0; attempt < 80 && (actor.messages.isEmpty()
|
||||
|| !actor.messages.getLast().contains("now " + value)); attempt++) {
|
||||
var callback = fixture.tasks.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
if (callback != null) callback.run();
|
||||
}
|
||||
assertTrue(actor.messages.getLast().contains("now " + value));
|
||||
}
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int attempt = 0; attempt < 80 && preferences.pendingEvents().stream()
|
||||
.filter(intent -> "true".equals(intent.captured().get("published"))).count() < 2; attempt++)
|
||||
Thread.sleep(10);
|
||||
assertEquals(2, preferences.pendingEvents().stream()
|
||||
.filter(intent -> "true".equals(intent.captured().get("published"))).count());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int attempt = 0; attempt < 80 && preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.map(e -> e.get("id").getAsString()).distinct().count() < 2; attempt++)
|
||||
Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.collect(java.util.stream.Collectors.toMap(e -> e.get("id").getAsString(), e -> e,
|
||||
(first, duplicate) -> first, java.util.LinkedHashMap::new));
|
||||
assertEquals(2, changes.size(), "Both acknowledged changes need distinct stable recovered outcomes");
|
||||
assertEquals(java.util.Set.of("false", "true"), changes.values().stream()
|
||||
.map(e -> e.getAsJsonObject("data").get("after").getAsString())
|
||||
.collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void registeredAuraPreferenceRecordsDefaultOffAndSavedOptInUnderAllOff() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "all", "off"}));
|
||||
fixture.tick();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("effectively off"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords();
|
||||
var aura = records.stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
var read = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("false", read.get("default_value").getAsString());
|
||||
assertEquals("false", read.get("saved_value").getAsString());
|
||||
assertEquals("false", read.get("effective_value").getAsString());
|
||||
assertEquals("/fstick particles players", read.get("entry_point").getAsString());
|
||||
var change = aura.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(actor.core.id.toString(), change.get("player_id").getAsString());
|
||||
assertEquals("false", change.get("saved_before").getAsString());
|
||||
assertEquals("true", change.get("saved_value").getAsString());
|
||||
assertEquals("false", change.get("effective_value").getAsString());
|
||||
assertEquals("true", change.get("after").getAsString());
|
||||
assertTrue(change.get("preference_sequence").getAsLong() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraRefusalsAndSupersededWritesNeverClaimPreferenceSuccess() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
assertTrue(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "off"}));
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
preferences.setPlayerAuras(java.util.UUID.randomUUID(), true).toCompletableFuture()
|
||||
.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
var callbacks = new java.util.ArrayList<Runnable>();
|
||||
fixture.tasks.drainTo(callbacks);
|
||||
assertEquals(2, callbacks.size());
|
||||
java.util.Collections.reverse(callbacks); callbacks.forEach(Runnable::run);
|
||||
assertFalse(root.onCommand(actor.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "invalid"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.ConsoleCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
|
||||
assertTrue(root.onCommand(org.mockito.Mockito.mock(org.bukkit.command.BlockCommandSender.class),
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var reasons = records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.map(e -> e.getAsJsonObject("data").get("reason").getAsString()).toList();
|
||||
assertEquals(5, reasons.size());
|
||||
assertEquals(java.util.Set.of("superseded", "unchanged", "invalid_arguments", "player_required"),
|
||||
java.util.Set.copyOf(reasons));
|
||||
fixture.plugin.setEnabled(true);
|
||||
fixture.plugin.setEnabled(false);
|
||||
assertEquals(0, preferenceRecords().stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraSaveFailureLeavesDefaultOffAndReportsFailure() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
java.nio.file.Files.createDirectory(data.resolve("player-preferences.yml"));
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
assertTrue(actor.messages.getLast().contains("could not be saved"));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream().filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles")).toList();
|
||||
assertEquals(0, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
assertEquals("save_failed", records.stream().filter(e -> e.get("type").getAsString().endsWith("action.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data").get("reason").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void auraOptInSurvivesRestartAndCharacterTransferWithoutLeakingAccounts() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var owner = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var receiver = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(owner.core.id, owner.player);
|
||||
fixture.players.put(receiver.core.id, receiver.player);
|
||||
var root = fixture.playerCommand.getExecutor();
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
fixture.activate(owner);
|
||||
var sheet = owner.core.inventory.getItemInOffHand().clone();
|
||||
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
|
||||
sessions.exit(owner.player);
|
||||
fixture.awaitCharacterAndReceipt(owner);
|
||||
receiver.core.inventory.setItem(8, receiver.core.inventory.getItemInOffHand());
|
||||
receiver.core.inventory.setItemInOffHand(sheet);
|
||||
fixture.activate(receiver);
|
||||
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
assertTrue(root.onCommand(receiver.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are off.", receiver.messages.getLast());
|
||||
assertTrue(root.onCommand(owner.player, fixture.playerCommand, "fstick",
|
||||
new String[]{"particles", "players"}));
|
||||
assertEquals("Active-character aura particles are on.", owner.messages.getLast());
|
||||
assertEquals(List.of("off"), fixture.playerCommand.getTabCompleter().onTabComplete(owner.player,
|
||||
fixture.playerCommand, "fstick", new String[]{"particles", "players", "of"}));
|
||||
fixture.plugin.setEnabled(false);
|
||||
var records = preferenceRecords().stream()
|
||||
.filter(e -> e.getAsJsonObject("data").has("preference_key")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")).count());
|
||||
var receiverRead = records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.filter(d -> d.get("player_id").getAsString().equals(receiver.core.id.toString()))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("false", receiverRead.get("saved_value").getAsString());
|
||||
assertEquals(owner.core.record.id().toString(), receiverRead.get("character_id").getAsString());
|
||||
assertTrue(records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.read"))
|
||||
.map(e -> e.getAsJsonObject("data"))
|
||||
.anyMatch(d -> d.get("player_id").getAsString().equals(owner.core.id.toString())
|
||||
&& d.get("saved_value").getAsString().equals("true")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void acceptedAuraOptInRecoversSameEventAfterSinkOutage() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
fixture.players.put(actor.core.id, actor.player);
|
||||
((games.dmg.fantasystick.events.LocalEventLog)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "eventLog")).close();
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(actor.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"particles", "players", "on"}));
|
||||
fixture.tick();
|
||||
var preferences = (games.dmg.fantasystick.persistence.PlayerFeedbackPreferences)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "feedbackPreferences");
|
||||
for (int i = 0; i < 80 && preferences.pendingEvents().stream()
|
||||
.noneMatch(p -> "true".equals(p.captured().get("published"))); i++) Thread.sleep(10);
|
||||
assertTrue(preferences.pendingEvents().stream()
|
||||
.anyMatch(p -> "true".equals(p.captured().get("published"))));
|
||||
fixture.plugin.setEnabled(false);
|
||||
fixture.plugin.setEnabled(true);
|
||||
for (int i = 0; i < 80 && preferenceRecords().stream()
|
||||
.noneMatch(e -> e.get("type").getAsString().endsWith("preference.changed")); i++) Thread.sleep(10);
|
||||
fixture.plugin.setEnabled(false);
|
||||
var changes = preferenceRecords().stream()
|
||||
.filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("player-aura-particles"))
|
||||
.toList();
|
||||
assertEquals(1, changes.stream().map(e -> e.get("id").getAsString()).distinct().count());
|
||||
assertEquals("false", changes.getFirst().getAsJsonObject("data").get("saved_before").getAsString());
|
||||
assertEquals("true", changes.getFirst().getAsJsonObject("data").get("saved_value").getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.List<com.google.gson.JsonObject> preferenceRecords() throws Exception {
|
||||
var records = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(data.resolve("events"))) {
|
||||
for (var path : files.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) records.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static void optIn(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
fixture.particlesCommand.execute(actor.player, "fantasyparticles", new String[]{"players", "on"});
|
||||
|
||||
@@ -99,6 +99,45 @@ class FrostNovaNativeTest {
|
||||
assertEquals(60L, fixture.delayedTicks.getLast()); fixture.delayed.getLast().run();
|
||||
assertFalse(enemyEffects.containsKey(PotionEffectType.SLOWNESS)); assertFalse(opponentEffects.containsKey(PotionEffectType.SLOWNESS));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved"))
|
||||
assertEquals(1, abilityEvents(records, type).size(), type);
|
||||
assertEquals(2, abilityEvents(records, "combat.damage").size(), "One accepted record per eligible target");
|
||||
for (var type : List.of("effect.applied", "effect.ended"))
|
||||
assertEquals(2, abilityEvents(records, type).size(), "Only the two owned Slowness layers: " + type);
|
||||
var damageEvents = abilityEvents(records, "combat.damage");
|
||||
assertEquals(2, records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic native observer cannot duplicate either owned burst hit");
|
||||
assertEquals(2, damageEvents.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertTrue(damageEvents.stream().allMatch(event -> event.getAsJsonObject("data").has("target_location")
|
||||
&& event.getAsJsonObject("data").get("actual_amount").getAsDouble() == 8
|
||||
&& event.getAsJsonObject("data").get("damage_kind").getAsString().equals("owned_area_burst")));
|
||||
var applied = abilityEvents(records, "effect.applied");
|
||||
assertEquals(2, applied.stream().map(event -> event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertTrue(applied.stream().allMatch(event -> event.getAsJsonObject("data").get("amplifier").getAsInt() == 1));
|
||||
assertEquals(2, abilityEvents(records, "effect.ended").stream()
|
||||
.filter(event -> event.getAsJsonObject("data").get("reason").getAsString().equals("expired")).count());
|
||||
var outcome = abilityEvents(records, "ability.resolved").getFirst().getAsJsonObject("data");
|
||||
assertEquals("hit", outcome.get("outcome").getAsString());
|
||||
assertEquals(2, outcome.get("accepted_count").getAsInt());
|
||||
assertEquals(3, outcome.get("attempt_count").getAsInt());
|
||||
assertEquals(5, outcome.get("candidate_count").getAsInt(), "Duplicate nearby references are deduplicated");
|
||||
assertTrue(outcome.getAsJsonObject("burst_location").has("world_id"));
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events, String suffix) {
|
||||
return events.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_nova")).toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -274,6 +313,18 @@ class FrostNovaNativeTest {
|
||||
var enemyEffects = effects(enemy); var applied = new HashMap<UUID, Double>(); damage(fixture, enemy, applied);
|
||||
use(fixture, caster); drain(fixture, 2); assertEquals(12, applied.get(enemy.getUniqueId()), 1.0e-9);
|
||||
assertEquals(3, enemyEffects.get(PotionEffectType.SLOWNESS).getAmplifier()); assertEquals(120L, fixture.delayedTicks.getLast());
|
||||
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());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "frost-nova-radius-blocks", "11"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("10", ((games.dmg.fantasystick.persistence.FrostNovaSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "frostNovaSettings"))
|
||||
.value("frost-nova-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "frost-nova-radius-blocks", "10", "extra"});
|
||||
@@ -286,6 +337,12 @@ class FrostNovaNativeTest {
|
||||
assertEquals(java.time.Duration.ofSeconds(3), saved.cooldown());
|
||||
assertEquals(0, new BigDecimal("6").compareTo(saved.parameters().get("slowness-duration-seconds")));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("frost-nova-")).toList();
|
||||
assertEquals(6, config.size(), "One authorized read and five acknowledged saves, no denied/invalid successes");
|
||||
assertEquals("8", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("frost-nova-base-damage: -1\n", "frost-nova-cooldown-seconds: -1\n",
|
||||
"frost-nova-radius-blocks: 0\n", "frost-nova-slowness-amplifier: -1\n", "frost-nova-slowness-duration-seconds: 0\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
|
||||
@@ -68,6 +68,65 @@ class GuardianBondNativeTest {
|
||||
var expired = hit(enemy, ally.player, 10); fixture.dispatcher.accept(expired);
|
||||
assertEquals(10, expired.getDamage(), 1.0e-9);
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
|
||||
"effect.applied", "effect.ended", "combat.prevented", "combat.damage"))
|
||||
assertEquals(1, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(), type);
|
||||
var cast = records.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
|
||||
var prevented = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).findFirst().orElseThrow();
|
||||
assertEquals("3", prevented.getAsJsonObject("data").get("actual_amount").getAsString());
|
||||
assertEquals(cast.getAsJsonObject("data").get("action_id").getAsString(),
|
||||
prevented.getAsJsonObject("data").get("action_id").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@Test void laterCancelledOriginalHitCannotClaimAcceptedPrevention() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var guardian = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareGuardian(data, guardian, true);
|
||||
fixture.players.put(guardian.core.id, guardian.player);
|
||||
fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(guardian); fixture.activate(ally);
|
||||
party(fixture, guardian, ally); aim(guardian, ally, 8, true);
|
||||
nativeDamage(fixture, guardian);
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event) {
|
||||
if (event.getEntity() == ally.player) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
use(fixture, guardian); drain(fixture, 2);
|
||||
var enemy = mock(org.bukkit.entity.Zombie.class);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var original = hit(enemy, ally.player, 10); fixture.dispatcher.accept(original);
|
||||
assertTrue(original.isCancelled());
|
||||
assertEquals(7, guardian.player.getHealth(), 1.0e-9);
|
||||
}
|
||||
var observed = events(data);
|
||||
assertEquals(0, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.prevented")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count());
|
||||
assertEquals(1, observed.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).count(),
|
||||
"The guardian's separately accepted native damage remains truthful");
|
||||
}
|
||||
|
||||
@Test void lethalRedirectUsesNormalDefeatAndOnlyRemovesGuardianAvailableHealth() throws Exception {
|
||||
@@ -92,7 +151,10 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
};
|
||||
use(fixture, guardian); drain(fixture, 2); guardian.core.health = 1; ally.core.health = 9;
|
||||
var hit = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
var attacker = mock(org.bukkit.entity.Zombie.class);
|
||||
when(attacker.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
var hit = hit(attacker, ally.player, 10); fixture.dispatcher.accept(hit);
|
||||
assertEquals(List.of("guardian"), defeatOrder,
|
||||
"Redirected damage reaches normal guardian defeat before original damage resumes");
|
||||
assertEquals(9, hit.getDamage(), 1.0e-9, "Overkill cannot delete damage the guardian could not accept");
|
||||
@@ -107,6 +169,11 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
assertEquals(List.of("guardian", "ally"), defeatOrder, "Both characters may be defeated in the approved order");
|
||||
}
|
||||
var ownerDamage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("guardian_bond")).toList();
|
||||
assertEquals(1, ownerDamage.size(), "A lethal redirect still has one accepted guardian damage record");
|
||||
assertEquals(1.0, ownerDamage.getFirst().getAsJsonObject("data").get("actual_amount").getAsDouble());
|
||||
}
|
||||
|
||||
@Test void actualProtectionCreatesOnePersistedSupportShareForTheAidedDirectContributor() throws Exception {
|
||||
@@ -132,6 +199,25 @@ class GuardianBondNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void cancelledOriginalNativeHitCannotCreateEffectiveSupport() {
|
||||
var context = new Context(true);
|
||||
when(context.guardian.getHealth()).thenReturn(10.0);
|
||||
doAnswer(call -> {
|
||||
var redirected = hit(call.getArgument(1), context.guardian, (Double) call.getArgument(0));
|
||||
context.bond.acceptedRedirect(redirected);
|
||||
return null;
|
||||
}).when(context.guardian).damage(anyDouble(), any(Entity.class));
|
||||
context.activate();
|
||||
var cancelled = hit(context.enemy, context.ally, 10);
|
||||
context.bond.redirect(cancelled); cancelled.setCancelled(true);
|
||||
context.bond.acceptedPrevention(cancelled);
|
||||
assertTrue(context.supports.isEmpty(), "A later-vetoed original hit cannot create support credit");
|
||||
var accepted = hit(context.enemy, context.ally, 10);
|
||||
context.bond.redirect(accepted); context.bond.acceptedPrevention(accepted);
|
||||
assertEquals(1, context.supports.size());
|
||||
assertEquals(0, new BigDecimal("3").compareTo(context.supports.getFirst().amount()));
|
||||
}
|
||||
|
||||
@Test void conservedRoutingUsesPostRecipientAndPostGuardianMitigationCancellationAndLethalCaps() {
|
||||
var context = new Context(); var requested = new ArrayList<BigDecimal>();
|
||||
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
|
||||
@@ -268,6 +354,19 @@ class GuardianBondNativeTest {
|
||||
nativeDamage(fixture, guardian); use(fixture, guardian); drain(fixture, 2);
|
||||
var future = hit(mock(org.bukkit.entity.Zombie.class), ally.player, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(5, future.getDamage(), 1.0e-9); assertEquals(180L, fixture.delayedTicks.getLast());
|
||||
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());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "guardian-bond-range-blocks", "17"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("16", ((games.dmg.fantasystick.persistence.GuardianBondSettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "guardianBondSettings"))
|
||||
.value("guardian-bond-range-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "guardian-bond-range-blocks", "16", "extra"});
|
||||
@@ -281,6 +380,18 @@ class GuardianBondNativeTest {
|
||||
assertEquals(0, new BigDecimal("9").compareTo(afterRestart.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("50"), afterRestart.parameters().get("redirect-percent"));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("guardian-bond-")).toList();
|
||||
assertEquals(5, config.size());
|
||||
assertEquals("14", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
assertEquals("console", config.getFirst().getAsJsonObject("data").get("actor_kind").getAsString());
|
||||
var range = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().equals("guardian-bond-range-blocks"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("14", range.getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("16", range.getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("guardian-bond-cooldown-seconds: -1\n", "guardian-bond-range-blocks: 0\n",
|
||||
"guardian-bond-duration-seconds: 0\n", "guardian-bond-redirect-percent: 101\n")) {
|
||||
var directory = data.resolve(Integer.toHexString(invalid.hashCode())); java.nio.file.Files.createDirectories(directory);
|
||||
@@ -299,7 +410,8 @@ class GuardianBondNativeTest {
|
||||
GuardianBond.Damage damage = (target, attacker, amount) -> amount; final GuardianBond bond;
|
||||
final EquipmentSessions.Active guardianActive; final EquipmentSessions.Active allyActive;
|
||||
final PartyRoster.Party party;
|
||||
Context() {
|
||||
Context() { this(false); }
|
||||
Context(boolean nativeAdapter) {
|
||||
var guardianId = UUID.randomUUID(); var allyId = UUID.randomUUID();
|
||||
when(guardian.getUniqueId()).thenReturn(guardianId); when(ally.getUniqueId()).thenReturn(allyId);
|
||||
when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
@@ -314,8 +426,10 @@ class GuardianBondNativeTest {
|
||||
when(parties.partyOf(guardianId)).thenReturn(java.util.Optional.of(party)); when(parties.partyOf(allyId)).thenReturn(java.util.Optional.of(party));
|
||||
when(input.player()).thenReturn(guardian); when(input.target()).thenReturn(ally); when(input.session()).thenReturn(guardianActive);
|
||||
when(execution.input()).thenReturn(input); when(execution.stillCurrent()).thenReturn(true);
|
||||
bond = new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
bond = nativeAdapter
|
||||
? new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties, supports::add)
|
||||
: new GuardianBond(GuardianBond.Settings.defaults(), sessions, parties,
|
||||
(target, attacker, amount) -> damage.apply(target, attacker, amount), supports::add);
|
||||
when(execution.definition()).thenReturn(bond.definition());
|
||||
}
|
||||
void activate() { bond.activate(execution); }
|
||||
|
||||
@@ -105,6 +105,30 @@ class MendingLightNativeTest {
|
||||
assertEquals("10", healed.getAsJsonObject("data").get("health_after").getAsString());
|
||||
}
|
||||
|
||||
@Test void accountFeedbackOptOutCannotHideAnAcceptedNativeHealEvent() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
prepareCaster(data, caster, true); setHealth(data, ally, BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id, caster.player); fixture.players.put(ally.core.id, ally.player);
|
||||
fixture.activate(caster); fixture.activate(ally); party(fixture, caster, ally); aim(caster, ally, 8, true);
|
||||
assertTrue(fixture.playerCommand.getExecutor().onCommand(caster.player, fixture.playerCommand,
|
||||
"fstick", new String[]{"feedback", "off"}));
|
||||
fixture.tick();
|
||||
use(fixture, caster); drain(fixture, 3);
|
||||
assertFalse(caster.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
|
||||
assertTrue(ally.messages.contains("Mending Light healed " + ally.core.record.name() + " for 6 health."));
|
||||
fixture.plugin.setEnabled(false);
|
||||
}
|
||||
var records = events(data);
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("combat.healed")
|
||||
&& e.getAsJsonObject("data").has("ability_id")
|
||||
&& e.getAsJsonObject("data").get("ability_id").getAsString().equals("mending_light")).count());
|
||||
assertEquals(1, records.stream().filter(e -> e.get("type").getAsString().endsWith("preference.changed")
|
||||
&& e.getAsJsonObject("data").get("preference_key").getAsString().equals("combat_feedback")).count());
|
||||
}
|
||||
|
||||
@Test void actualHealingCreatesOnePersistedSupportShareForAnAllyDirectContributor() throws Exception {
|
||||
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
fixture.plugin.setEnabled(true);
|
||||
|
||||
@@ -29,12 +29,55 @@ class MeteorNativeTest {
|
||||
@BeforeAll static void bootstrap()throws Exception{NativeRuntime.bootstrap();}
|
||||
|
||||
@Test void characterBookPurchaseEquippedGroundWarningAndNativeImpact()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false);f.players.put(caster.core.id,caster.player);f.activate(caster);var placement=placement(caster,24,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,new UUID(0,2),2,65,0);
|
||||
var vetoed=mock(LivingEntity.class);locate(vetoed,caster.world,new UUID(0,3),3,65,0);
|
||||
doReturn(List.of(enemy,vetoed)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
class Veto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageEvent event){if(event.getEntity()==vetoed)event.setCancelled(true);}
|
||||
}
|
||||
f.dispatcher.register(new Veto(),f.plugin);
|
||||
doAnswer(call->{var source=(DamageSource)call.getArgument(1);
|
||||
f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,vetoed,EntityDamageEvent.DamageCause.MAGIC,
|
||||
source,(double)call.getArgument(0)));return null;
|
||||
}).when(vetoed).damage(anyDouble(),any(DamageSource.class));
|
||||
var damaged=new ArrayList<Double>();doAnswer(call->{double amount=call.getArgument(0);damaged.add(amount);var source=(DamageSource)call.getArgument(1);f.dispatcher.accept(new EntityDamageByEntityEvent(caster.player,enemy,EntityDamageEvent.DamageCause.MAGIC,source,amount));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("Meteor"));assertTrue(text.contains("INT26"));assertTrue(text.contains("WIS20"));assertTrue(text.contains("CON12"));assertTrue(text.contains("2-secondwarning"));PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","meteor"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(damaged.isEmpty());assertEquals(1L,f.delayedTicks.getLast());for(int i=0;i<39;i++)f.delayed.removeFirst().run();assertTrue(damaged.isEmpty(),"The 40-tick warning must remain escapable");f.delayed.removeFirst().run();assertEquals(List.of(22d),damaged);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Meteor warning or impact particles");assertTrue(viewer.particleCalls.stream().filter(call->call[0]==Particle.DUST&&call[2].equals(24)).count()>=40);assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.EXPLOSION_EMITTER&&call[2].equals(2)));assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.FLAME&&call[2].equals(48)));verify(caster.world,atLeastOnce()).playSound(any(Location.class),eq(Sound.ENTITY_GENERIC_EXPLODE),eq(1f),eq(.7f));verify(placement,never()).setType(any(Material.class));assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("meteor"));assertEquals(BigInteger.ZERO,storage(f).find(caster.core.record.id()).get().orElseThrow().unspentPoints());
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.damage"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
assertEquals(1,records.stream().filter(event->event.get("type").getAsString().endsWith("combat.damage")).count(),
|
||||
"The generic observer must not duplicate Meteor-owned native damage");
|
||||
var impact=abilityEvents(records,"combat.damage").getFirst().getAsJsonObject("data");
|
||||
assertEquals(22,impact.get("actual_amount").getAsDouble(),1e-9);
|
||||
assertTrue(impact.has("target_location"));
|
||||
var outcome=abilityEvents(records,"ability.resolved").getFirst().getAsJsonObject("data");
|
||||
assertEquals("hit",outcome.get("outcome").getAsString());
|
||||
assertEquals(1,outcome.get("accepted_count").getAsInt());
|
||||
assertEquals(2,outcome.get("attempted_count").getAsInt(),"Cancelled native impact has no damage success");
|
||||
assertTrue(outcome.getAsJsonObject("impact_location").has("world_id"));
|
||||
}
|
||||
|
||||
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}}
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("meteor")).toList();
|
||||
}
|
||||
|
||||
@Test void missingOrSideGroundRefusesBeforeCooldown()throws Exception{try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));placement(caster,24,BlockFace.UP);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("meteor"));}
|
||||
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only real ground selection commits a cooldown");
|
||||
assertTrue(abilityEvents(events(data),"combat.damage").isEmpty(),"Warning alone cannot claim a hit");
|
||||
}
|
||||
|
||||
@Test void impactRechecksGroundPartyModeGeometryObstructionCancellationAndSourceLifecycle(){
|
||||
var d=direct();var escaping=target(d.world,new UUID(0,1),2,65);var joined=mock(Player.class);locate(joined,d.world,new UUID(0,2),2,65,1);var ordinary=mock(Player.class);locate(ordinary,d.world,new UUID(0,3),2,65,2);var inactive=mock(Player.class);locate(inactive,d.world,new UUID(0,8),2,65,3);var inactiveSession=new EquipmentSessions.Active(inactive.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(inactive.getUniqueId())).thenReturn(Optional.of(inactiveSession));when(d.sessions.current(inactiveSession)).thenReturn(false);var blocked=target(d.world,new UUID(0,4),4.5,65);var cancelled=target(d.world,new UUID(0,5),2,65);var boundary=mock(LivingEntity.class);locate(boundary,d.world,new UUID(0,6),5.5,69,.5);var outside=mock(LivingEntity.class);locate(outside,d.world,new UUID(0,7),5.51,69,.5);doReturn(List.of(escaping,joined,ordinary,inactive,blocked,cancelled,boundary,outside,d.caster)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var joinedActive=new EquipmentSessions.Active(joined.getUniqueId(),UUID.randomUUID(),UUID.randomUUID());when(d.sessions.active(joined.getUniqueId())).thenReturn(Optional.of(joinedActive));when(d.sessions.current(joinedActive)).thenReturn(true);var party=new PartyRoster.Party(UUID.randomUUID(),d.caster.getUniqueId(),List.of(d.caster.getUniqueId(),joined.getUniqueId()));when(d.parties.partyOf(d.caster.getUniqueId())).thenReturn(Optional.of(party));when(d.parties.partyOf(joined.getUniqueId())).thenReturn(Optional.of(party));doReturn(mock(RayTraceResult.class)).when(d.world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),doubleThat(distance->distance>4&&distance<4.5),eq(FluidCollisionMode.NEVER),eq(true));when(escaping.getLocation()).thenReturn(new Location(d.world,6,65,.5));doAnswer(call->{var event=new EntityDamageEvent(cancelled,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0));event.setCancelled(true);d.meteor.acceptedDamage(event);return null;}).when(cancelled).damage(anyDouble(),any(DamageSource.class));doAnswer(call->{d.meteor.acceptedDamage(new EntityDamageEvent(boundary,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(boundary).damage(anyDouble(),any(DamageSource.class));d.meteor.activate(d.execution);run(d.scheduled,40);verify(escaping,never()).damage(anyDouble(),any(DamageSource.class));verify(joined,never()).damage(anyDouble(),any(DamageSource.class));verify(ordinary,never()).damage(anyDouble(),any(DamageSource.class));verify(inactive,never()).damage(anyDouble(),any(DamageSource.class));verify(blocked,never()).damage(anyDouble(),any(DamageSource.class));verify(cancelled).damage(anyDouble(),any(DamageSource.class));verify(boundary).damage(anyDouble(),any(DamageSource.class));verify(outside,never()).damage(anyDouble(),any(DamageSource.class));verify(d.execution,times(1)).report(eq(AbilityReport.Type.DAMAGE),eq(boundary),any());
|
||||
@@ -44,9 +87,27 @@ class MeteorNativeTest {
|
||||
|
||||
@Test void impactUsesExactCylinderDistanceUuidOrderAndSharedBoundsOnce(){var d=direct();var targets=new ArrayList<LivingEntity>();var order=new ArrayList<UUID>();for(int i=0;i<257;i++){var target=target(d.world,new UUID(0,i+1),i==256?0:Math.min(5,i/20d),65+(i%2==0?4:0));targets.add(target);doAnswer(call->{order.add(target.getUniqueId());d.meteor.acceptedDamage(new EntityDamageEvent(target,EntityDamageEvent.DamageCause.MAGIC,(DamageSource)call.getArgument(1),(double)call.getArgument(0)));return null;}).when(target).damage(anyDouble(),any(DamageSource.class));}doReturn(new ArrayList<Entity>(targets)).when(d.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());d.meteor.activate(d.execution);run(d.scheduled,40);assertEquals(64,order.size());assertFalse(order.contains(targets.get(256).getUniqueId()),"Candidates after the 256 inspection ceiling are ignored");assertEquals(new UUID(0,11),order.getFirst());verify(d.execution,times(64)).report(eq(AbilityReport.Type.DAMAGE),any(),any());assertEquals(BigInteger.valueOf(22),Meteor.damage(BigInteger.valueOf(26)));}
|
||||
|
||||
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
|
||||
@Test void configuredDefinitionAdminRestartAndFutureImpactCoverEverySetting()throws Exception{java.nio.file.Files.writeString(data.resolve("config.yml"),"meteor-base-damage: 16\nmeteor-cooldown-seconds: 3\nmeteor-target-range-blocks: 20\nmeteor-warning-seconds: 1\nmeteor-radius-blocks: 6\nmeteor-vertical-range-blocks: 3\n");try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var registry=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");var definition=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("16"),definition.parameters().get("base-damage"));assertEquals(java.time.Duration.ofSeconds(3),definition.cooldown());var sender=new EquipmentAdminCommandNativeTest.Sender();sender.allowed=false;assertTrue(f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).isEmpty());f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7"});assertTrue(sender.messages.getLast().contains("Permission denied"));sender.allowed=true;assertEquals(6,f.command.tabComplete(sender.port,"fantasystick",new String[]{"config","meteor-"}).size());assertEquals(List.of("5","6"),f.command.tabComplete(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,""}));
|
||||
f.command.execute(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS});
|
||||
assertTrue(sender.messages.getLast().contains("=6"));
|
||||
for(String[] update:List.of(new String[]{MeteorSettings.BASE_DAMAGE,"18"},new String[]{MeteorSettings.COOLDOWN,"4"},new String[]{MeteorSettings.TARGET_RANGE,"22"},new String[]{MeteorSettings.WARNING,"1.5"},new String[]{MeteorSettings.RADIUS,"7"},new String[]{MeteorSettings.VERTICAL,"5"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}var configured=registry.find("meteor").orElseThrow();assertEquals(new BigDecimal("18"),configured.parameters().get("base-damage"));assertEquals(0,new BigDecimal("1.5").compareTo(configured.parameters().get("warning-seconds")));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true);f.players.put(caster.core.id,caster.player);f.activate(caster);placement(caster,22,BlockFace.UP);var enemy=mock(LivingEntity.class);locate(enemy,caster.world,UUID.randomUUID(),2,65,0);doReturn(List.of(enemy)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());var damaged=new ArrayList<Double>();doAnswer(call->{damaged.add(call.getArgument(0));return null;}).when(enemy).damage(anyDouble(),any(DamageSource.class));use(f,caster);drain(f,2);for(int i=0;i<30;i++)f.delayed.removeFirst().run();assertEquals(List.of(26d),damaged);
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").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(sender.port,"fstickadmin",new String[]{"config",MeteorSettings.RADIUS,"8"});
|
||||
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("7",((MeteorSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"meteorSettings")).value(MeteorSettings.RADIUS));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",MeteorSettings.RADIUS,"7","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("18"),reloaded.find("meteor").orElseThrow().parameters().get("base-damage"));}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("meteor-")).toList();
|
||||
assertEquals(7,config.size(),"Authorized read and six acknowledged saved updates only");
|
||||
}
|
||||
|
||||
private record Direct(Meteor meteor,AbilityExecution execution,Player caster,World world,EquipmentSessions sessions,PartyRoster parties,ArrayDeque<Runnable> scheduled,java.util.concurrent.atomic.AtomicReference<Material> blockType){}
|
||||
private static Direct direct(){var sessions=mock(EquipmentSessions.class);var parties=mock(PartyRoster.class);var caster=mock(Player.class);var world=mock(World.class);UUID id=UUID.randomUUID();when(caster.getUniqueId()).thenReturn(id);when(caster.getWorld()).thenReturn(world);when(caster.getLocation()).thenReturn(new Location(world,0,65,0));when(caster.getBoundingBox()).thenReturn(new BoundingBox(0,65,0,1,67,1));var active=new EquipmentSessions.Active(id,UUID.randomUUID(),UUID.randomUUID());when(sessions.active(id)).thenReturn(Optional.of(active));when(sessions.current(active)).thenReturn(true);var type=new java.util.concurrent.atomic.AtomicReference<>(Material.STONE);var block=mock(Block.class);when(block.getType()).thenAnswer(call->type.get());when(block.getWorld()).thenReturn(world);when(block.getLocation()).thenReturn(new Location(world,0,64,0));var ray=mock(RayTraceResult.class);when(ray.getHitBlock()).thenReturn(block);when(ray.getHitBlockFace()).thenReturn(BlockFace.UP);when(caster.getEyeLocation()).thenReturn(new Location(world,0,65.62,0,0,45));doReturn(ray).when(world).rayTraceBlocks(any(Location.class),any(org.bukkit.util.Vector.class),eq(24d),eq(FluidCollisionMode.NEVER),eq(true));var execution=mock(AbilityExecution.class);var input=mock(ControlInput.class);when(input.player()).thenReturn(caster);when(input.session()).thenReturn(active);when(execution.input()).thenReturn(input);when(execution.stillCurrent()).thenReturn(true);doAnswer(call->{((LivingEntity)call.getArgument(0)).damage((Double)call.getArgument(1),(org.bukkit.damage.DamageSource)call.getArgument(2));return null;}).when(execution).damage(any(LivingEntity.class),anyDouble(),any(org.bukkit.damage.DamageSource.class));var character=mock(CharacterRecord.class);var stats=new EnumMap<Stat,BigInteger>(Stat.class);for(var stat:Stat.values())stats.put(stat,BigInteger.ZERO);stats.put(Stat.INT,BigInteger.valueOf(26));when(character.stats()).thenReturn(stats);when(execution.character()).thenReturn(character);var scheduled=new ArrayDeque<Runnable>();doAnswer(call->{scheduled.addLast(call.getArgument(1));return mock(AbilityScheduler.Task.class);}).when(execution).later(any(),any());var meteor=new Meteor(Meteor.Settings.defaults(),sessions,parties);when(execution.definition()).thenReturn(meteor.definition());return new Direct(meteor,execution,caster,world,sessions,parties,scheduled,type);}
|
||||
|
||||
@@ -70,6 +70,16 @@ class RallyingCryNativeTest {
|
||||
var hit = new EntityDamageByEntityEvent(ally.player, enemy, EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(hit); assertEquals(12, hit.getDamage(), 1.0e-9);
|
||||
assertEquals(1, caster.messages.stream().filter("Rallying Cry added 2 damage against Zombie."::equals).count());
|
||||
class Veto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void veto(EntityDamageByEntityEvent event) {
|
||||
if (event.getEntity() == enemy) event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(), fixture.plugin);
|
||||
var cancelled = new EntityDamageByEntityEvent(ally.player, enemy,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, 10);
|
||||
fixture.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
|
||||
fixture.dispatcher.accept(new EntityDeathEvent(enemy, source, new ArrayList<>(), 0)); drain(fixture, 4);
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5), storage(fixture).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
@@ -85,6 +95,31 @@ class RallyingCryNativeTest {
|
||||
PartyBookNativeTest.click(fixture, caster, button(caster, "[Learn]", "rallying_cry")); drain(fixture, 2);
|
||||
use(fixture, caster); assertTrue(caster.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
}
|
||||
var records = events(data);
|
||||
for (var type : List.of("ability.learned", "ability.assigned", "ability.cast", "ability.resolved",
|
||||
"combat.boosted"))
|
||||
assertEquals(type.equals("ability.learned") || type.equals("ability.assigned") ? 2 : 1,
|
||||
records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
|
||||
for (var type : List.of("effect.applied", "effect.ended"))
|
||||
assertEquals(3, records.stream().filter(event -> event.get("type").getAsString().endsWith(type)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).count(), type);
|
||||
var boosted = records.stream().filter(event -> event.get("type").getAsString().endsWith("combat.boosted")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("rallying_cry")).findFirst().orElseThrow();
|
||||
assertEquals("2", boosted.getAsJsonObject("data").get("actual_amount").getAsString());
|
||||
assertEquals("12", boosted.getAsJsonObject("data").get("final_amount").getAsString());
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root) throws Exception {
|
||||
var values = new ArrayList<com.google.gson.JsonObject>();
|
||||
try (var files = java.nio.file.Files.walk(root.resolve("events"))) {
|
||||
for (var file : files.filter(path -> path.toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(file))
|
||||
if (!line.isBlank()) values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@Test void dynamicSphereMembershipSessionAndSourceValidityRecheckOwnedBenefits() throws Exception {
|
||||
@@ -199,6 +234,19 @@ class RallyingCryNativeTest {
|
||||
var futureTarget = mock(LivingEntity.class); when(futureTarget.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var future = hit(ally.player, futureTarget, 10); fixture.dispatcher.accept(future);
|
||||
assertEquals(14, future.getDamage(), 1.0e-9);
|
||||
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());
|
||||
fixture.command.execute(sender.port, "fstickadmin",
|
||||
new String[]{"config", "rallying-cry-radius-blocks", "13"});
|
||||
var failed = fixture.tasks.poll(5, TimeUnit.SECONDS);
|
||||
assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals("12", ((games.dmg.fantasystick.persistence.RallyingCrySettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "rallyingCrySettings"))
|
||||
.value("rallying-cry-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-duration-seconds", "0"});
|
||||
assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
fixture.command.execute(sender.port, "fantasystick", new String[]{"config", "rallying-cry-radius-blocks", "12", "extra"});
|
||||
@@ -211,6 +259,17 @@ class RallyingCryNativeTest {
|
||||
assertEquals(0, new BigDecimal("9").compareTo(saved.parameters().get("duration-seconds")));
|
||||
assertEquals(new BigDecimal("2"), saved.parameters().get("speed-amplifier"));
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event -> event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("rallying-cry-")).toList();
|
||||
assertEquals(6, config.size());
|
||||
assertEquals("10", config.getFirst().getAsJsonObject("data").get("after").getAsString());
|
||||
var radius = config.stream().filter(event -> event.get("type").getAsString().endsWith("config.changed")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().equals("rallying-cry-radius-blocks"))
|
||||
.findFirst().orElseThrow();
|
||||
assertEquals("10", radius.getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("12", radius.getAsJsonObject("data").get("after").getAsString());
|
||||
for (String invalid : List.of("rallying-cry-cooldown-seconds: -1\n", "rallying-cry-radius-blocks: 0\n",
|
||||
"rallying-cry-duration-seconds: 0\n", "rallying-cry-speed-amplifier: -1\n",
|
||||
"rallying-cry-damage-bonus-percent: 101\n")) {
|
||||
|
||||
@@ -43,6 +43,19 @@ class SanctuaryNativeTest {
|
||||
var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);fixture.players.put(viewer.core.id,viewer.player);
|
||||
PartyBookNativeTest.disableAllParticles(fixture,caster);caster.particleCalls.clear();viewer.particleCalls.clear();
|
||||
use(fixture,caster);drain(fixture,2);assertEquals(2L,fixture.delayedTicks.getLast());
|
||||
var incomingAttacker=mock(org.bukkit.entity.LivingEntity.class);when(incomingAttacker.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var incomingSource=DamageSource.builder(org.bukkit.damage.DamageType.MOB_ATTACK)
|
||||
.withCausingEntity(incomingAttacker).withDirectEntity(incomingAttacker).build();
|
||||
var incoming=new EntityDamageByEntityEvent(incomingAttacker,ally.player,EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
|
||||
fixture.dispatcher.accept(incoming);assertEquals(8,incoming.getFinalDamage(),1.0e-9);
|
||||
class LateVeto implements org.bukkit.event.Listener {
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event){if(event.getDamager()==incomingAttacker)event.setCancelled(true);}
|
||||
}
|
||||
fixture.dispatcher.register(new LateVeto(),fixture.plugin);
|
||||
var vetoed=new EntityDamageByEntityEvent(incomingAttacker,ally.player,
|
||||
EntityDamageEvent.DamageCause.ENTITY_ATTACK,incomingSource,10);
|
||||
fixture.dispatcher.accept(vetoed);assertTrue(vetoed.isCancelled());
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);assertEquals(6,ally.core.health,0);
|
||||
assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no Sanctuary particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==org.bukkit.Particle.END_ROD&&call[2].equals(12)),"An enabled nearby viewer retains Sanctuary visuals");
|
||||
var enemy=mock(org.bukkit.entity.LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
@@ -54,6 +67,61 @@ class SanctuaryNativeTest {
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(caster.core.record.id()).get().orElseThrow().experience());
|
||||
assertEquals(BigInteger.valueOf(5),storage.find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.prevented"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
var heals=abilityEvents(records,"combat.healed");
|
||||
assertTrue(heals.size()>=1,"Actual acknowledged ally-health pulses are individually recorded");
|
||||
assertEquals(heals.size(),heals.stream().map(event->event.getAsJsonObject("data").get("application_id").getAsString()).distinct().count());
|
||||
assertEquals(3,abilityEvents(records,"effect.applied").size(),"One fixed area plus caster/ally benefits");
|
||||
assertEquals(3,abilityEvents(records,"effect.ended").size(),"No per-tick duplicate grants");
|
||||
var heal=heals.stream().map(event->event.getAsJsonObject("data"))
|
||||
.filter(value->value.get("health_before").getAsString().equals("4")).findFirst().orElseThrow();
|
||||
assertEquals("2",heal.get("actual_amount").getAsString());
|
||||
assertEquals("6",heal.get("health_after").getAsString());
|
||||
assertTrue(heal.has("target_location"));
|
||||
assertEquals("2",abilityEvents(records,"combat.prevented").getFirst().getAsJsonObject("data")
|
||||
.get("actual_amount").getAsString());
|
||||
}
|
||||
|
||||
@Test void cancelledNativeAllyHealCannotClaimSavedOrEffectiveHealth()throws Exception{
|
||||
UUID allyId;
|
||||
try(var fixture=new FantasyStickLifecycleNativeTest.Fixture(data)){
|
||||
fixture.plugin.setEnabled(true);
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var ally=new FantasyStickLifecycleNativeTest.Actor(data);allyId=ally.core.id;
|
||||
prepareCaster(data,caster,true);setHealth(data,ally,BigDecimal.valueOf(4));
|
||||
fixture.players.put(caster.core.id,caster.player);fixture.players.put(allyId,ally.player);
|
||||
fixture.activate(caster);fixture.activate(ally);party(fixture,caster,ally);
|
||||
caster.core.location=new Location(caster.world,0,64,0);ally.core.location=new Location(caster.world,2,64,0);
|
||||
doReturn(List.of(ally.player)).when(caster.world).getNearbyEntities(any(Location.class),anyDouble(),anyDouble(),anyDouble());
|
||||
class Veto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGH)
|
||||
public void cancel(EntityRegainHealthEvent event){if(event.getEntity()==ally.player)event.setCancelled(true);}
|
||||
}
|
||||
fixture.dispatcher.register(new Veto(),fixture.plugin);
|
||||
use(fixture,caster);drain(fixture,2);
|
||||
for(int i=0;i<10;i++)fixture.delayed.removeFirst().run();drain(fixture,4);
|
||||
assertEquals(4,ally.core.health,0);
|
||||
}
|
||||
assertTrue(abilityEvents(events(data),"combat.healed").stream()
|
||||
.noneMatch(event->event.getAsJsonObject("data").get("target_player_id").getAsString().equals(allyId.toString())),
|
||||
"Cancelled ally healing must not become a saved-health success or a JSONL healing claim");
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("sanctuary")).toList();
|
||||
}
|
||||
|
||||
@Test void fixedCylinderHasNoImmediatePulseThenSixOwnedHealingAndMitigationPulses() {
|
||||
@@ -145,6 +213,16 @@ class SanctuaryNativeTest {
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var configured=registry.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),configured.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),configured.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),configured.cooldown());
|
||||
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());
|
||||
plugin.command.execute(sender.port,"fstickadmin",new String[]{"config","sanctuary-radius-blocks","9"});
|
||||
var failed=plugin.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("8",((games.dmg.fantasystick.persistence.SanctuarySettings)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(plugin.plugin,"sanctuarySettings")).value("sanctuary-radius-blocks"));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","0"});assertTrue(sender.messages.getLast().contains("invalid"));
|
||||
plugin.command.execute(sender.port,"fantasystick",new String[]{"config","sanctuary-radius-blocks","8","extra"});assertTrue(sender.messages.getLast().contains("Usage"));
|
||||
plugin.plugin.setEnabled(false);plugin.plugin.setEnabled(true);
|
||||
@@ -152,6 +230,11 @@ class SanctuaryNativeTest {
|
||||
var saved=reloaded.find("sanctuary").orElseThrow();assertEquals(new BigDecimal("4"),saved.parameters().get("healing-per-pulse"));
|
||||
assertEquals(new BigDecimal("30"),saved.parameters().get("damage-reduction-percent"));assertEquals(java.time.Duration.ofSeconds(20),saved.cooldown());
|
||||
}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("sanctuary-")).toList();
|
||||
assertEquals(8,config.size(),"Authorized read and seven saved settings only");
|
||||
}
|
||||
|
||||
private static void use(FantasyStickLifecycleNativeTest.Fixture fixture,FantasyStickLifecycleNativeTest.Actor actor){actor.core.inventory.getInventory().setSelectedSlot(2);fixture.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(actor.player,org.bukkit.event.block.Action.RIGHT_CLICK_AIR,actor.core.inventory.getItem(2),null,org.bukkit.block.BlockFace.SELF,org.bukkit.inventory.EquipmentSlot.HAND));}
|
||||
|
||||
@@ -36,13 +36,45 @@ class WarBannerNativeTest {
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,false,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);resistance.modifiers().add(new AttributeModifier(new NamespacedKey("test","unrelated_knockback"),.2,AttributeModifier.Operation.ADD_NUMBER));
|
||||
caster.core.inventory.getInventory().setSelectedSlot(0);PartyBookNativeTest.open(f,caster);String text=PartyBookNativeTest.text(caster).replaceAll("\\s+","");assertTrue(text.contains("WarBanner"));assertTrue(text.contains("STR22"));assertTrue(text.contains("CHR22"));assertTrue(text.contains("CON12"));assertTrue(text.contains("25%"));assertTrue(text.contains("50%"));
|
||||
PartyBookNativeTest.click(f,caster,button(caster,"[Learn]","war_banner"));drain(f,2);var viewer=new FantasyStickLifecycleNativeTest.Actor(data);viewer.core.inventory.setItemInOffHand(null);viewer.core.location.setWorld(caster.world);f.players.put(viewer.core.id,viewer.player);PartyBookNativeTest.disableAllParticles(f,caster);caster.particleCalls.clear();viewer.particleCalls.clear();use(f,caster);drain(f,2);assertTrue(caster.particleCalls.isEmpty(),"The opted-out caster receives no War Banner particles");assertTrue(viewer.particleCalls.stream().anyMatch(call->call[0]==Particle.DUST&&call[2].equals(16)),"An enabled nearby viewer retains War Banner visuals");assertEquals(200L,f.delayedTicks.getLast());assertEquals(.7,resistance.value(),1e-9);assertNotNull(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().get("war_banner"));
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());when(enemy.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);when(enemy.getLocation()).thenReturn(new Location(caster.world,4,65,0));when(caster.world.getUID()).thenReturn(UUID.randomUUID());var source=DamageSource.builder(org.bukkit.damage.DamageType.PLAYER_ATTACK).withCausingEntity(ally.player).withDirectEntity(ally.player).build();var hit=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(hit);assertEquals(12.5,hit.getDamage(),1e-9);
|
||||
class LateVeto implements org.bukkit.event.Listener{
|
||||
@org.bukkit.event.EventHandler(priority=org.bukkit.event.EventPriority.HIGHEST)
|
||||
public void cancel(EntityDamageByEntityEvent event){if(event.getEntity()==enemy)event.setCancelled(true);}
|
||||
}
|
||||
f.dispatcher.register(new LateVeto(),f.plugin);
|
||||
var cancelled=hit(ally.player,enemy,10);f.dispatcher.accept(cancelled);assertTrue(cancelled.isCancelled());
|
||||
f.dispatcher.accept(new EntityDeathEvent(enemy,source,new ArrayList<>(),0));drain(f,4);assertEquals(BigInteger.valueOf(5),storage(f).find(caster.core.record.id()).get().orElseThrow().experience());assertEquals(BigInteger.valueOf(5),storage(f).find(ally.core.record.id()).get().orElseThrow().experience());
|
||||
f.delayed.getLast().run();assertEquals(.2,resistance.value(),1e-9,"Owned cleanup preserves unrelated knockback modifiers");var expired=new EntityDamageByEntityEvent(ally.player,enemy,EntityDamageEvent.DamageCause.ENTITY_ATTACK,source,10);f.dispatcher.accept(expired);assertEquals(10,expired.getDamage(),1e-9);
|
||||
}
|
||||
var records=events(data);
|
||||
for(var type:List.of("ability.learned","ability.assigned","ability.cast","ability.resolved","combat.boosted"))
|
||||
assertEquals(1,abilityEvents(records,type).size(),type);
|
||||
assertEquals(2,abilityEvents(records,"effect.applied").size(),"Fixed banner and the actually eligible ally benefit");
|
||||
assertEquals(2,abilityEvents(records,"effect.ended").size(),"Expiry closes only actually owned benefits");
|
||||
var boosted=abilityEvents(records,"combat.boosted").getFirst().getAsJsonObject("data");
|
||||
assertEquals("2.5",boosted.get("actual_amount").getAsString());
|
||||
assertEquals("12.5",boosted.get("final_amount").getAsString());
|
||||
assertTrue(boosted.has("target_location"));
|
||||
}
|
||||
|
||||
private static List<com.google.gson.JsonObject> events(Path root)throws Exception{
|
||||
var values=new ArrayList<com.google.gson.JsonObject>();
|
||||
try(var files=java.nio.file.Files.walk(root.resolve("events"))){
|
||||
for(var file:files.filter(path->path.toString().endsWith(".jsonl")).toList())
|
||||
for(var line:java.nio.file.Files.readAllLines(file))
|
||||
if(!line.isBlank())values.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
private static List<com.google.gson.JsonObject> abilityEvents(List<com.google.gson.JsonObject> events,String suffix){
|
||||
return events.stream().filter(event->event.get("type").getAsString().endsWith(suffix)
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("war_banner")).toList();
|
||||
}
|
||||
|
||||
@Test void invalidOrNonTopPlacementRefusesBeforeCooldown()throws Exception{
|
||||
try(var f=new FantasyStickLifecycleNativeTest.Fixture(data)){f.plugin.setEnabled(true);var caster=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.activate(caster);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster,12,BlockFace.NORTH);use(f,caster);drain(f,2);assertFalse(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));placement(caster);use(f,caster);drain(f,2);assertTrue(storage(f).find(caster.core.record.id()).get().orElseThrow().cooldowns().containsKey("war_banner"));}
|
||||
assertEquals(1,abilityEvents(events(data),"ability.cast").size(),"Only the actual solid-top placement commits a cast");
|
||||
}
|
||||
|
||||
@Test void fixedCylinderObstructionLifecycleOverlapAndRallyUseStrongestOnly()throws Exception{
|
||||
@@ -65,7 +97,21 @@ class WarBannerNativeTest {
|
||||
for(String[] update:List.of(new String[]{WarBannerSettings.COOLDOWN,"3"},new String[]{WarBannerSettings.PLACEMENT_RANGE,"11"},new String[]{WarBannerSettings.RADIUS,"9"},new String[]{WarBannerSettings.VERTICAL,"5"},new String[]{WarBannerSettings.DURATION,"9"},new String[]{WarBannerSettings.DAMAGE_BONUS,"35"},new String[]{WarBannerSettings.KNOCKBACK_RESISTANCE,"60"})){f.command.execute(sender.port,"fantasystick",new String[]{"config",update[0],update[1]});var response=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(response);response.run();}
|
||||
var saved=registry.find("war_banner").orElseThrow();assertEquals(java.time.Duration.ofSeconds(3),saved.cooldown());assertEquals(new BigDecimal("35"),saved.parameters().get("damage-bonus-percent"));assertEquals(new BigDecimal("60"),saved.parameters().get("knockback-resistance-percent"));
|
||||
var caster=new FantasyStickLifecycleNativeTest.Actor(data);var ally=new FantasyStickLifecycleNativeTest.Actor(data);prepare(data,caster,true,false);f.players.put(caster.core.id,caster.player);f.players.put(ally.core.id,ally.player);f.activate(caster);f.activate(ally);party(f,caster,ally);placement(caster,11);nearby(caster,List.of(ally.player));var resistance=attribute(ally.player);use(f,caster);drain(f,2);assertEquals(180L,f.delayedTicks.getLast());assertEquals(.6,resistance.value(),1e-9);var enemy=mock(LivingEntity.class);when(enemy.getUniqueId()).thenReturn(UUID.randomUUID());var hit=hit(ally.player,enemy,10);f.dispatcher.accept(hit);assertEquals(13.5,hit.getDamage(),1e-9);
|
||||
try(var channel=java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE,java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock=channel.lock()){
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(sender.port,"fstickadmin",new String[]{"config",WarBannerSettings.RADIUS,"10"});
|
||||
var failed=f.tasks.poll(5,TimeUnit.SECONDS);assertNotNull(failed);failed.run();
|
||||
}
|
||||
assertEquals("9",((WarBannerSettings)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"warBannerSettings")).value(WarBannerSettings.RADIUS));
|
||||
assertTrue(sender.messages.getLast().contains("save failed"));
|
||||
f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"0"});assertTrue(sender.messages.getLast().contains("invalid"));f.command.execute(sender.port,"fantasystick",new String[]{"config",WarBannerSettings.RADIUS,"9","extra"});assertTrue(sender.messages.getLast().contains("Usage"));f.plugin.setEnabled(false);f.plugin.setEnabled(true);var reloaded=(AbilityRegistry)FantasyStickLifecycleNativeTest.Fixture.field(f.plugin,"abilityRegistry");assertEquals(new BigDecimal("35"),reloaded.find("war_banner").orElseThrow().parameters().get("damage-bonus-percent"));}
|
||||
var config=events(data).stream().filter(event->event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))
|
||||
.filter(event->event.getAsJsonObject("data").has("key")
|
||||
&& event.getAsJsonObject("data").get("key").getAsString().startsWith("war-banner-")).toList();
|
||||
assertEquals(8,config.size(),"Authorized read and seven saved updates, without denied/invalid successes");
|
||||
}
|
||||
|
||||
private record AttributeFixture(Set<AttributeModifier> modifiers){double value(){return modifiers.stream().mapToDouble(AttributeModifier::getAmount).sum();}}
|
||||
|
||||
@@ -30,7 +30,8 @@ class ChainLightningSettingsTest {
|
||||
var cooldown = settings.set(ChainLightningSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().jumpRange(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRange), "Stale save callbacks cannot republish policy or claim configuration success");
|
||||
assertEquals(7, settings.settings().jumpRange(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
|
||||
@@ -21,7 +21,8 @@ class FrostNovaSettingsTest {
|
||||
var cooldown = settings.set(FrostNovaSettings.COOLDOWN, "24").toCompletableFuture();
|
||||
var savedRange = range.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(5, settings.settings().radius(), 0);
|
||||
settings.publish(savedCooldown); settings.publish(savedRange);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRange), "A late save callback cannot republish stale settings or claim an event");
|
||||
assertEquals(7, settings.settings().radius(), 0);
|
||||
assertEquals(java.time.Duration.ofSeconds(24), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
|
||||
@@ -12,7 +12,7 @@ class MeteorSettingsTest {
|
||||
@TempDir Path data;
|
||||
@Test void defaultsMatchApprovedPacket(){var s=MeteorSettings.parse(defaults());assertEquals(java.math.BigInteger.valueOf(14),s.baseDamage());assertEquals(Duration.ofSeconds(45),s.cooldown());assertEquals(24,s.targetRange());assertEquals(Duration.ofSeconds(2),s.warning());assertEquals(5,s.radius());assertEquals(4,s.verticalRange());}
|
||||
@Test void validatesEveryBound(){for(var values:List.of(changed(MeteorSettings.BASE_DAMAGE,"-1"),changed(MeteorSettings.COOLDOWN,"-1"),changed(MeteorSettings.TARGET_RANGE,"0"),changed(MeteorSettings.WARNING,"0"),changed(MeteorSettings.WARNING,"3.2"),changed(MeteorSettings.RADIUS,"0"),changed(MeteorSettings.VERTICAL,"-1"),changed(MeteorSettings.RADIUS,"NaN")))assertThrows(IllegalArgumentException.class,()->MeteorSettings.parse(values));}
|
||||
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));store.publish(update);assertEquals("6",store.value(MeteorSettings.RADIUS));}}
|
||||
@Test void persistsBeforePublicationAndPreservesUnrelatedYaml()throws Exception{Files.writeString(data.resolve("config.yml"),"unrelated: keep\n");try(var store=new MeteorSettings(data,defaults())){var update=store.set(MeteorSettings.RADIUS,"6.00").toCompletableFuture().get();assertEquals("5",store.value(MeteorSettings.RADIUS));assertEquals("6",update.value());assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));assertTrue(store.publish(update));assertEquals("6",store.value(MeteorSettings.RADIUS));var later=store.set(MeteorSettings.RADIUS,"7").toCompletableFuture().get();assertTrue(store.publish(later));assertFalse(store.publish(update),"A stale callback cannot claim publication twice");assertEquals("7",store.value(MeteorSettings.RADIUS));}}
|
||||
@Test void failedUnsafeAndClosedWritesRetainEffectiveState()throws Exception{var fail=new java.util.concurrent.atomic.AtomicBoolean(true);try(var store=new MeteorSettings(data,defaults(),(from,to)->{if(fail.getAndSet(false))throw new java.io.IOException("simulated");Files.move(from,to,StandardCopyOption.REPLACE_EXISTING);})) {assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("5",store.value(MeteorSettings.RADIUS));}if(!System.getProperty("os.name","").toLowerCase().contains("win")){var unsafe=data.resolve("unsafe");Files.createDirectories(unsafe);var outside=data.resolve("outside");Files.writeString(outside,"safe\n");Files.createSymbolicLink(unsafe.resolve("config.yml"),outside);try(var store=new MeteorSettings(unsafe,defaults())){assertThrows(java.util.concurrent.ExecutionException.class,()->store.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());assertEquals("safe\n",Files.readString(outside));}}var closed=new MeteorSettings(data.resolve("closed"),defaults());closed.close();assertThrows(java.util.concurrent.ExecutionException.class,()->closed.set(MeteorSettings.RADIUS,"6").toCompletableFuture().get());closed.drained().toCompletableFuture().get();}
|
||||
private static Map<String,String> defaults(){var result=new LinkedHashMap<String,String>();for(var key:MeteorSettings.keys())result.put(key,MeteorSettings.defaultValue(key));return result;}
|
||||
private static Map<String,String> changed(String key,String value){var result=new LinkedHashMap<>(defaults());result.put(key,value);return result;}
|
||||
|
||||
@@ -22,7 +22,9 @@ class SanctuarySettingsTest {
|
||||
var radius = settings.set(SanctuarySettings.RADIUS, "7").toCompletableFuture();
|
||||
var cooldown = settings.set(SanctuarySettings.COOLDOWN, "30").toCompletableFuture();
|
||||
var savedRadius = radius.get(5, TimeUnit.SECONDS); var savedCooldown = cooldown.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(6, settings.settings().radius(), 0); settings.publish(savedCooldown); settings.publish(savedRadius);
|
||||
assertEquals(6, settings.settings().radius(), 0);
|
||||
assertTrue(settings.publish(savedCooldown));
|
||||
assertFalse(settings.publish(savedRadius), "A stale save callback cannot republish policy or claim a config event");
|
||||
assertEquals(7, settings.settings().radius(), 0); assertEquals(java.time.Duration.ofSeconds(30), settings.settings().cooldown());
|
||||
var yaml = (Map<?, ?>) new org.yaml.snakeyaml.Yaml().load(Files.readString(data.resolve("config.yml")));
|
||||
assertEquals("retained", yaml.get("unrelated")); assertEquals("7", String.valueOf(yaml.get(SanctuarySettings.RADIUS)));
|
||||
|
||||
@@ -39,7 +39,11 @@ class WarBannerSettingsTest {
|
||||
var update=store.set(WarBannerSettings.RADIUS,"9.00").toCompletableFuture().get();
|
||||
assertEquals("8",store.value(WarBannerSettings.RADIUS));assertEquals("9",update.value());
|
||||
assertTrue(Files.readString(data.resolve("config.yml")).contains("unrelated: keep"));
|
||||
store.publish(update);assertEquals("9",store.value(WarBannerSettings.RADIUS));
|
||||
assertTrue(store.publish(update));assertEquals("9",store.value(WarBannerSettings.RADIUS));
|
||||
var later=store.set(WarBannerSettings.RADIUS,"10").toCompletableFuture().get();
|
||||
assertTrue(store.publish(later));
|
||||
assertFalse(store.publish(update),"A stale callback cannot replace current settings or claim a second success");
|
||||
assertEquals("10",store.value(WarBannerSettings.RADIUS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user