feat(frost-shard): audit owned arrow and slow outcomes
Release / release (push) Successful in 17m59s
Release / release (push) Successful in 17m59s
This commit is contained in:
1 parent
db4c3b2842
commit
f6d95ca338
8 files changed
+531
-34
No files matched your search
@@ -741,6 +741,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
particlesCommand.attachEvents(preferenceEvents);
|
||||
feedbackCommand.attachEvents(preferenceEvents);
|
||||
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
@@ -757,6 +758,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.attachCastEvents(new games.dmg.fantasystick.events.AbilityCastEventBridge(eventLog::emit, Clock.systemUTC()));
|
||||
abilityEngine.enableCastEventsFor("fireball");
|
||||
abilityEngine.enableCastEventsFor("great_sword");
|
||||
abilityEngine.enableCastEventsFor("frost_shard");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
|
||||
@@ -458,9 +458,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
|
||||
castEvents.boosted(audit, attacking, attacker, target, base, bonus, finalDamage, percent);
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason);
|
||||
effectEnded(application, reason, Map.of());
|
||||
}
|
||||
@Override public void effectEnded(UUID application, String reason, Map<String, Object> evidence) {
|
||||
Objects.requireNonNull(application); Objects.requireNonNull(reason); Objects.requireNonNull(evidence);
|
||||
var effectId = observedEffects.remove(application);
|
||||
if (effectId != null && castEvents != null) castEvents.effectEnded(audit, application, effectId, reason);
|
||||
if (effectId != null && castEvents != null)
|
||||
castEvents.effectEnded(audit, application, effectId, reason, evidence);
|
||||
}
|
||||
@Override public void resolve(String result) { resolve(result, Map.of()); }
|
||||
@Override public void resolve(String result, Map<String, Object> evidence) {
|
||||
|
||||
@@ -46,6 +46,10 @@ public interface AbilityExecution extends AutoCloseable {
|
||||
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);
|
||||
/** End an observed effect with actual native cleanup/restoration evidence. */
|
||||
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
|
||||
effectEnded(applicationId, reason);
|
||||
}
|
||||
/** An owner may specify a truthful terminal outcome before finishing. */
|
||||
void resolve(String outcome);
|
||||
void resolve(String outcome, Map<String, Object> evidence);
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -58,7 +59,9 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
final BigInteger intelligence;
|
||||
final Settings settings;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow projectile, AbilityExecution execution, Location origin, BigInteger intelligence, Settings settings) {
|
||||
this.projectile = projectile; this.execution = execution; this.origin = origin;
|
||||
@@ -74,13 +77,33 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
private final LivingEntity target;
|
||||
private final PotionEffect previous;
|
||||
private final PotionEffect applied;
|
||||
private final AbilityExecution execution;
|
||||
private final long started = System.nanoTime();
|
||||
private UUID application;
|
||||
private String endReason = "interrupted";
|
||||
private boolean owned;
|
||||
private boolean closed;
|
||||
SlowLease(LivingEntity target, PotionEffect applied) {
|
||||
SlowLease(LivingEntity target, PotionEffect applied, AbilityExecution execution) {
|
||||
this.target = target; this.previous = target.getPotionEffect(PotionEffectType.SLOWNESS); this.applied = applied;
|
||||
this.execution = execution;
|
||||
}
|
||||
boolean apply() { owned = target.addPotionEffect(applied); return owned; }
|
||||
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());
|
||||
evidence.put("prior_layer", previous == null ? "none" : "replaced");
|
||||
if (previous != null) {
|
||||
evidence.put("prior_amplifier", previous.getAmplifier());
|
||||
evidence.put("prior_duration_ticks", previous.getDuration());
|
||||
}
|
||||
application = execution.effectApplied("frost_shard_slowness", target, evidence);
|
||||
} catch (RuntimeException ignored) { /* Native effect authority is independent of auditing. */ }
|
||||
return owned;
|
||||
}
|
||||
void expire() { endReason = "expired"; close(); }
|
||||
@Override public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -88,13 +111,24 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
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;
|
||||
if (current == null) {
|
||||
end(endReason.equals("expired") ? "expired" : "native_layer_missing", "none"); return;
|
||||
}
|
||||
if (current.getAmplifier() != applied.getAmplifier()
|
||||
|| current.getDuration() > expected + 2 || current.getDuration() < expected - 2) {
|
||||
end("superseded", "preserved_newer"); return;
|
||||
}
|
||||
target.removePotionEffect(PotionEffectType.SLOWNESS);
|
||||
String restored = "none";
|
||||
if (previous != null) {
|
||||
int remaining = (int) Math.max(0, (long) previous.getDuration() - elapsed);
|
||||
if (remaining > 0) target.addPotionEffect(previous.withDuration(remaining));
|
||||
if (remaining > 0)
|
||||
restored = target.addPotionEffect(previous.withDuration(remaining)) ? "prior" : "refused";
|
||||
}
|
||||
end(endReason, restored);
|
||||
}
|
||||
private void end(String reason, String restored) {
|
||||
if (application != null) execution.effectEnded(application, reason, Map.of("restored_layer", restored));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +164,18 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
AbilityDefinition.TargetPolicy.PROJECTILE_HOSTILE, this);
|
||||
}
|
||||
|
||||
private static Settings captured(AbilityDefinition definition) {
|
||||
var values = definition.parameters();
|
||||
return new Settings(values.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
values.get("range").doubleValue(), duration(values.get("lifetime")),
|
||||
values.get("projectile-speed").doubleValue(), values.get("slowness-amplifier").intValueExact(),
|
||||
duration(values.get("slowness-duration")));
|
||||
}
|
||||
|
||||
private static Duration duration(BigDecimal seconds) {
|
||||
return Duration.ofNanos(seconds.movePointRight(9).toBigIntegerExact().longValueExact());
|
||||
}
|
||||
|
||||
public static BigInteger damage(BigInteger intelligence) {
|
||||
return damage(BigInteger.valueOf(4), intelligence);
|
||||
}
|
||||
@@ -146,7 +192,7 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
|
||||
@Override public void activate(AbilityExecution execution) {
|
||||
if (closed || !execution.stillCurrent()) return;
|
||||
var current = settings;
|
||||
var current = captured(execution.definition());
|
||||
var player = execution.input().player();
|
||||
Location origin = player.getEyeLocation().clone();
|
||||
Vector direction = origin.getDirection();
|
||||
@@ -161,12 +207,15 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(current.range() / current.speedPerTick()));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(current.lifetime(), () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (projectiles.containsKey(projectile.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("projectile_id", projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an expired Arrow. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(current.lifetime(), expire);
|
||||
particles.world(Particle.SNOWFLAKE, origin, 8, 0.15, 0.15, 0.15, 0.01);
|
||||
}
|
||||
|
||||
@@ -183,25 +232,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
if (state == null) return;
|
||||
boolean retainExecution = false;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target) || !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot retain an invalid hit. */ }
|
||||
return;
|
||||
}
|
||||
var current = state.settings;
|
||||
BigInteger amount = damage(current.baseDamage(), state.intelligence);
|
||||
double nativeDamage = amount.bitLength() > 1023 ? Double.MAX_VALUE : amount.doubleValue();
|
||||
var source = DamageSource.builder(DamageType.FREEZE).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.projectile).withDamageLocation(state.projectile.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Capturing evidence cannot prevent native Slowness. */ }
|
||||
if (state.accepted && state.execution.stillCurrent()) {
|
||||
int ticks = Math.toIntExact(Math.max(1,
|
||||
(current.slownessDuration().toNanos() + 49_999_999L) / 50_000_000L));
|
||||
var lease = new SlowLease(target, new PotionEffect(PotionEffectType.SLOWNESS, ticks,
|
||||
current.slownessAmplifier(), false, true, true));
|
||||
current.slownessAmplifier(), false, true, true), state.execution);
|
||||
if (lease.apply()) {
|
||||
state.execution.own(lease);
|
||||
state.execution.later(current.slownessDuration(), state.execution::finish);
|
||||
state.execution.later(current.slownessDuration(), () -> {
|
||||
lease.expire(); state.execution.finish();
|
||||
});
|
||||
retainExecution = true;
|
||||
}
|
||||
}
|
||||
@@ -217,18 +279,38 @@ public final class FrostShard implements AbilityEffect, Listener, AutoCloseable
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.projectile && (!(event instanceof org.bukkit.event.entity.EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.projectile)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.projectile,
|
||||
Map.of("projectile_id", state.projectile.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.projectile.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* An audit failure cannot remove an accepted combat contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.projectile || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.projectile;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static String level(int amplifier) {
|
||||
return switch (amplifier + 1) {
|
||||
case 1 -> "I"; case 2 -> "II"; case 3 -> "III"; case 4 -> "IV"; case 5 -> "V";
|
||||
|
||||
@@ -18,7 +18,13 @@ import org.bukkit.entity.Entity;
|
||||
/** Shared cast and explicit effect-observation boundary; never infers effects from feedback. */
|
||||
public final class AbilityCastEventBridge {
|
||||
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, UUID executionId) {
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, UUID.randomUUID());
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, 1.0f);
|
||||
@@ -229,9 +235,19 @@ public final class AbilityCastEventBridge {
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason) {
|
||||
effectEnded(cast, application, effectId, reason, Map.of());
|
||||
}
|
||||
|
||||
public void effectEnded(Cast cast, UUID application, String effectId, String reason,
|
||||
Map<String, Object> evidence) {
|
||||
if (cast == null) return;
|
||||
emit(cast, "effect.ended", "ended", Map.of("application_id", application.toString(),
|
||||
"effect_id", effectId, "reason", reason), application);
|
||||
try {
|
||||
var details = new LinkedHashMap<String, Object>(evidence);
|
||||
details.put("application_id", application.toString());
|
||||
details.put("effect_id", effectId);
|
||||
details.put("reason", reason);
|
||||
emit(cast, "effect.ended", "ended", details, application);
|
||||
} catch (RuntimeException ignored) { /* Native cleanup is not conditional on audit evidence. */ }
|
||||
}
|
||||
|
||||
private void emit(Cast cast, String type, String outcome, Map<String, Object> extra) {
|
||||
@@ -244,6 +260,7 @@ public final class AbilityCastEventBridge {
|
||||
data.put("action_kind", "ability_cast");
|
||||
data.put("ability_id", cast.abilityId());
|
||||
data.put("outcome", outcome);
|
||||
if (cast.abilityId().equals("frost_shard")) data.put("execution_id", cast.executionId().toString());
|
||||
if (cast.targetId() != null) {
|
||||
data.put("target_entity_id", cast.targetId().toString());
|
||||
data.put("target_type", cast.targetType());
|
||||
|
||||
@@ -76,11 +76,12 @@ public final class FrostShardSettings implements AutoCloseable {
|
||||
public FrostShard.Settings settings() { return parse(effectiveValues); }
|
||||
|
||||
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
|
||||
public synchronized void publish(Update update) {
|
||||
public synchronized boolean publish(Update update) {
|
||||
Objects.requireNonNull(update);
|
||||
if (update.sequence() <= publishedSequence) return;
|
||||
if (update.sequence() <= publishedSequence) return false;
|
||||
parse(update.values());
|
||||
effectiveValues = update.values(); publishedSequence = update.sequence();
|
||||
return true;
|
||||
}
|
||||
|
||||
public CompletionStage<Update> set(String key, String value) {
|
||||
|
||||
@@ -1065,8 +1065,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
configurationUsage(sender); return;
|
||||
}
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = frostSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + frostSettings.value(key) + " (applies to future Frost Shard casts only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "frost_shard", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Frost Shard casts only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.FrostShardSettings.Update> operation;
|
||||
@@ -1080,16 +1083,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
frostShard.configure(saved.settings());
|
||||
boolean applied = failure == null && frostSettings.publish(saved);
|
||||
if (applied) {
|
||||
frostShard.configure(frostSettings.settings());
|
||||
abilities.replace(frostShard.definition());
|
||||
frostSettings.publish(saved);
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "frost_shard", key, before, saved.value());
|
||||
}
|
||||
if (!sender.hasPermission(PERMISSION)
|
||||
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
|
||||
sender.sendMessage(failure == null
|
||||
? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Frost Shard configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Frost Shard configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Frost Shard casts; active effects and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + frostSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
|
||||
@@ -104,6 +104,228 @@ class FrostShardNativeTest {
|
||||
assertEquals(1, damages.size());
|
||||
verify(projectile, atLeastOnce()).remove();
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
for (var suffix : java.util.List.of("ability.learned", "ability.assigned", "ability.cast",
|
||||
"ability.resolved", "combat.damage", "effect.applied", "effect.ended"))
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count(), suffix);
|
||||
var castEvent = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("12", castEvent.get("captured_int").getAsString());
|
||||
assertEquals("3.0", castEvent.getAsJsonObject("captured_settings").get("projectile-speed").getAsString());
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(castEvent.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertEquals(castEvent.get("execution_id").getAsString(), damage.get("execution_id").getAsString());
|
||||
var resolution = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals(resolution.get("projectile_id").getAsString(), damage.get("projectile_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(5, damage.get("requested_amount").getAsDouble());
|
||||
assertEquals(5, damage.get("actual_amount").getAsDouble());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.has("direct_entity_id"));
|
||||
}
|
||||
|
||||
@Test void staleFlightIsInterruptedRatherThanFalselyReportedAsAnOrdinaryMiss() throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(),
|
||||
mock(games.dmg.fantasystick.presentation.ParticleDelivery.class));
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(execution.stillCurrent()).thenReturn(true, false);
|
||||
when(execution.definition()).thenReturn(frost.definition());
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
|
||||
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
frost.activate(execution);
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow));
|
||||
verify(execution).resolve(eq("interrupted"), anyMap());
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void pendingCastUsesItsCapturedDefinitionEvenIfConfigurationChangesBeforeLaunch() throws Exception {
|
||||
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
var particles = mock(games.dmg.fantasystick.presentation.ParticleDelivery.class);
|
||||
var frost = new games.dmg.fantasystick.ability.FrostShard(
|
||||
games.dmg.fantasystick.ability.FrostShard.Settings.defaults(), particles);
|
||||
var captured = frost.definition();
|
||||
frost.configure(new games.dmg.fantasystick.ability.FrostShard.Settings(BigInteger.valueOf(9),
|
||||
java.time.Duration.ofSeconds(5), 32, java.time.Duration.ofSeconds(5), 4, 0,
|
||||
java.time.Duration.ofSeconds(2)));
|
||||
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
|
||||
var input = mock(games.dmg.fantasystick.session.ControlInput.class);
|
||||
when(execution.stillCurrent()).thenReturn(true);
|
||||
when(execution.definition()).thenReturn(captured);
|
||||
when(execution.input()).thenReturn(input);
|
||||
when(execution.character()).thenReturn(actor.core.record);
|
||||
when(input.player()).thenReturn(actor.player);
|
||||
var eye = new org.bukkit.Location(actor.world, 0, 65.6, 0);
|
||||
eye.setDirection(new org.bukkit.util.Vector(1, 0, 0));
|
||||
doReturn(eye).when(actor.player).getEyeLocation();
|
||||
var arrow = projectile(actor, new org.bukkit.Location(actor.world, 2, 65.6, 0));
|
||||
doReturn(arrow).when(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(execution.eligible(any(), eq(1))).thenReturn(java.util.List.of(target));
|
||||
frost.activate(execution);
|
||||
var velocity = org.mockito.ArgumentCaptor.forClass(org.bukkit.util.Vector.class);
|
||||
verify(actor.player).launchProjectile(eq(org.bukkit.entity.Arrow.class), velocity.capture());
|
||||
assertEquals(3.0, velocity.getValue().length(), 0.0001, "Pending cast retains the captured default speed");
|
||||
frost.hit(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var amount = org.mockito.ArgumentCaptor.forClass(double.class);
|
||||
verify(execution).damage(eq(target), amount.capture(), any(org.bukkit.damage.DamageSource.class));
|
||||
assertEquals(games.dmg.fantasystick.ability.FrostShard.damage(actor.core.record.stats()
|
||||
.get(games.dmg.fantasystick.progression.Stat.INT)).doubleValue(), amount.getValue());
|
||||
frost.close();
|
||||
}
|
||||
|
||||
@Test void registeredFrostShardConfigReadAndAcknowledgedSaveReachJsonlWithoutFailedWrite() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("frost-shard-base-damage=4"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "9"});
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
|
||||
var lock = channel.lock()) {
|
||||
assertTrue(lock.isValid());
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage", "11"});
|
||||
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
|
||||
}
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("frost_shard").orElseThrow()
|
||||
.parameters().get("base-damage"));
|
||||
admin.allowed = false;
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "frost-shard-base-damage"});
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied and failed attempts cannot fabricate acknowledged changes");
|
||||
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acceptedSlowExportsItsOwnedApplicationAndTheActuallyRestoredPriorLayer() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var prior = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 100, 1);
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<>(prior);
|
||||
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
|
||||
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
|
||||
doAnswer(call -> { current.set(null); return null; }).when(target).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
assertNotNull(current.get()); assertEquals(40, current.get().getDuration());
|
||||
f.delayed.getLast().run();
|
||||
assertNotNull(current.get()); assertEquals(1, current.get().getAmplifier());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var effects = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")
|
||||
&& (event.get("type").getAsString().endsWith("effect.applied")
|
||||
|| event.get("type").getAsString().endsWith("effect.ended"))).toList();
|
||||
assertEquals(2, effects.size());
|
||||
var applied = effects.getFirst().getAsJsonObject("data");
|
||||
var ended = effects.getLast().getAsJsonObject("data");
|
||||
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
|
||||
assertEquals("replaced", applied.get("prior_layer").getAsString());
|
||||
assertEquals(1, applied.get("prior_amplifier").getAsInt());
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("prior", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void newerNativeSlowLayerSurvivesOwnedCleanupAndIsNotReportedAsRestoredPrior() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
var current = new java.util.concurrent.atomic.AtomicReference<org.bukkit.potion.PotionEffect>();
|
||||
when(target.getPotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS)).thenAnswer(ignored -> current.get());
|
||||
doAnswer(call -> { current.set(call.getArgument(0)); return true; }).when(target).addPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
var newer = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS, 200, 3);
|
||||
current.set(newer);
|
||||
f.delayed.getLast().run();
|
||||
assertSame(newer, current.get());
|
||||
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("superseded", ended.get("reason").getAsString());
|
||||
assertEquals("preserved_newer", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void naturallyExpiredOwnedSlowDoesNotClaimSupersessionOrInventRestoration() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
when(target.addPotionEffect(any())).thenReturn(true);
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0))); return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
f.delayed.getLast().run();
|
||||
verify(target, never()).removePotionEffect(org.bukkit.potion.PotionEffectType.SLOWNESS);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var ended = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("effect.ended")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("expired", ended.get("reason").getAsString());
|
||||
assertEquals("none", ended.get("restored_layer").getAsString());
|
||||
}
|
||||
|
||||
@Test void partyMixedModeAndCancelledDamageApplyNeitherDamageNorSlow() throws Exception {
|
||||
@@ -138,6 +360,77 @@ class FrostShardNativeTest {
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(third, target));
|
||||
verify(target, never()).addPotionEffect(any());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
assertEquals(3, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
}
|
||||
|
||||
@Test void acceptedFantasyPlayerFreezeHasOneOwnerNotAnIncomingNativeDuplicate() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.location.setWorld(a.world); b.core.location.setX(2); b.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
f.dispatcher.accept(new org.bukkit.event.entity.EntityDamageEvent(b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.FALL, 1));
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
prepareLaunches(f, a, arrow);
|
||||
doReturn(true).when(b.player).addPotionEffect(any());
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, b.player,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source,
|
||||
(Double) call.getArgument(0));
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, times(1)).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("target_player_id")
|
||||
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
|
||||
assertEquals(2, damage.size(), "One unrelated fall and one Freeze; no duplicate incoming Freeze");
|
||||
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeDamageIsNotAnAcceptedHitOrAFalseCancellation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
var target = mock(org.bukkit.entity.LivingEntity.class);
|
||||
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new org.bukkit.Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
|
||||
var hit = new org.bukkit.event.entity.EntityDamageByEntityEvent(arrow, target,
|
||||
org.bukkit.event.entity.EntityDamageEvent.DamageCause.PROJECTILE, source, 0);
|
||||
f.dispatcher.accept(hit);
|
||||
assertFalse(hit.isCancelled(), "Zero-damage native event was not itself cancelled");
|
||||
return null;
|
||||
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
|
||||
cast(f, a); f.dispatcher.accept(new org.bukkit.event.entity.ProjectileHitEvent(arrow, target));
|
||||
verify(target, never()).addPotionEffect(any());
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedProjectilesWithoutLateDamage() throws Exception {
|
||||
@@ -174,6 +467,14 @@ class FrostShardNativeTest {
|
||||
((games.dmg.fantasystick.ability.AbilityEngine) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityEngine")).sweep();
|
||||
verify(exiting, atLeastOnce()).remove();
|
||||
}
|
||||
var outcomes = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(4, outcomes.size(), outcomes.toString());
|
||||
assertEquals(2, outcomes.stream().filter("miss"::equals).count(),
|
||||
"The outside Arrow and non-accepted boundary damage must not claim hits: " + outcomes);
|
||||
assertTrue(outcomes.containsAll(java.util.List.of("expired", "interrupted")), outcomes.toString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOwnedProjectile() throws Exception {
|
||||
@@ -212,6 +513,73 @@ class FrostShardNativeTest {
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("frost_shard"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void restartedDifferentHolderCannotRelaunchTheSameCommittedFrostDeadline() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "frost-shard-cooldown-seconds: 60\n");
|
||||
org.bukkit.inventory.ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = projectile(a, new org.bukkit.Location(a.world, 2, 65.6, 0)); prepareLaunches(f, a, arrow);
|
||||
cast(f, a);
|
||||
verify(a.player, times(1)).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
cast(f, b);
|
||||
verify(b.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void refusedCooldownWriteExportsFailureWithoutProjectileOrEffect() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withFrostShard(data, a, 12, 10);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var storage = (games.dmg.fantasystick.persistence.CharacterStorage)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
a.core.inventory.getInventory().setSelectedSlot(2);
|
||||
f.dispatcher.accept(new org.bukkit.event.player.PlayerInteractEvent(a.player,
|
||||
org.bukkit.event.block.Action.RIGHT_CLICK_AIR, a.core.inventory.getItem(2), null,
|
||||
org.bukkit.block.BlockFace.SELF, org.bukkit.inventory.EquipmentSlot.HAND));
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).launchProjectile(eq(org.bukkit.entity.Arrow.class), any(org.bukkit.util.Vector.class));
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("frost_shard")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
|| event.get("type").getAsString().endsWith("combat.damage")
|
||||
|| event.get("type").getAsString().endsWith("effect.applied")).count());
|
||||
}
|
||||
|
||||
@Test void failedCooldownPersistenceLaunchesNoProjectileOrSlow() {
|
||||
@@ -470,6 +838,20 @@ class FrostShardNativeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
|
||||
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
|
||||
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
|
||||
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
|
||||
try (var segments = java.nio.file.Files.list(day)) {
|
||||
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
|
||||
for (var line : java.nio.file.Files.readAllLines(path))
|
||||
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
|
||||
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);
|
||||
|
||||
Reference in new issue
Block a user