feat(stone-skin): audit owned damage mitigation
Release / release (push) Failing after 16m11s

This commit is contained in:
dmg committed 2026-09-25 23:13:37 -04:00
1 parent 4616ee80e3
commit ee7d9036a2
9 files changed
+262 -23

No files matched your search

@@ -763,6 +763,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("longbow");
abilityEngine.enableCastEventsFor("quickstep");
abilityEngine.enableCastEventsFor("second_wind");
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -444,6 +444,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
try { feedback.report(definition, input, type, target, report.amount()); }
catch (RuntimeException failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
}
@Override public java.time.Instant now() { return clock.instant(); }
@Override public UUID effectApplied(String effectId, Entity target, Map<String, Object> evidence) {
if (!stillCurrent()) throw new IllegalStateException("Ability execution ended");
Objects.requireNonNull(effectId); Objects.requireNonNull(evidence);
@@ -17,6 +17,8 @@ public interface AbilityExecution extends AutoCloseable {
ControlInput input();
games.dmg.fantasystick.persistence.CharacterRecord character();
boolean stillCurrent();
/** Native-thread clock shared with cast and effect audit timestamps. */
default java.time.Instant now() { return java.time.Instant.now(); }
List<Entity> eligible(Collection<? extends Entity> candidates, int maximum);
AbilityScheduler.Task later(Duration delay, Runnable action);
void own(AutoCloseable resource);
@@ -19,9 +19,9 @@ import org.bukkit.event.entity.EntityDamageEvent;
public final class CombatMitigation implements Listener, AutoCloseable {
private final class Grant implements AutoCloseable {
final AbilityExecution execution; final Player player; final BigDecimal percent; final BooleanSupplier current;
final java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation; boolean closed;
final java.util.function.Consumer<Accepted> acceptedObservation; boolean closed;
Grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
java.util.function.Consumer<Accepted> acceptedObservation) {
this.execution = execution; this.player = player; this.percent = percent; this.current = current;
this.acceptedObservation = acceptedObservation;
}
@@ -35,7 +35,8 @@ public final class CombatMitigation implements Listener, AutoCloseable {
if (owned != null) { owned.remove(this); if (owned.isEmpty()) grants.remove(player.getUniqueId()); }
}
}
private record Prevention(Grant grant, BigDecimal amount) { }
public record Accepted(EntityDamageByEntityEvent event, double incoming, double remaining, double prevented) { }
private record Prevention(Grant grant, double incoming, double reduced) { }
private final Map<UUID, List<Grant>> grants = new java.util.HashMap<>();
private final Map<EntityDamageEvent, Prevention> pending = new IdentityHashMap<>();
private boolean closed;
@@ -48,9 +49,16 @@ public final class CombatMitigation implements Listener, AutoCloseable {
return grant(execution, player, percent, current, null);
}
/** An owner may observe only the selected, positive native prevention after final cancellation. */
/** Legacy owner observation; keep existing prevention consumers unchanged. */
public AutoCloseable grant(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.BiConsumer<EntityDamageEvent, Double> acceptedObservation) {
java.util.function.BiConsumer<EntityDamageEvent, Double> observation) {
return grantAccepted(execution, player, percent, current,
observation == null ? null : accepted -> observation.accept(accepted.event(), accepted.prevented()));
}
/** An owner may observe the selected, positive native prevention and its original hit value. */
public AutoCloseable grantAccepted(AbilityExecution execution, Player player, BigDecimal percent, BooleanSupplier current,
java.util.function.Consumer<Accepted> acceptedObservation) {
Objects.requireNonNull(execution); Objects.requireNonNull(player); Objects.requireNonNull(percent); Objects.requireNonNull(current);
if (closed || percent.signum() < 0 || percent.compareTo(BigDecimal.valueOf(100)) > 0)
throw new IllegalArgumentException("Invalid combat mitigation grant");
@@ -75,8 +83,7 @@ public final class CombatMitigation implements Listener, AutoCloseable {
.multiply(BigDecimal.ONE.subtract(strongest.percent.movePointLeft(2))).doubleValue();
if (!Double.isFinite(reduced) || reduced < 0) return;
event.setDamage(reduced);
BigDecimal prevented = BigDecimal.valueOf(original - reduced);
if (prevented.signum() > 0) pending.put(event, new Prevention(strongest, prevented));
if (original > reduced) pending.put(event, new Prevention(strongest, original, reduced));
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -84,9 +91,15 @@ public final class CombatMitigation implements Listener, AutoCloseable {
var prevention = pending.remove(event);
if (prevention == null || event.isCancelled() || event.getFinalDamage() <= 0
|| !prevention.grant.valid()) return;
prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(), prevention.amount);
double remaining = event.getDamage();
double actual = Math.min(prevention.incoming - prevention.reduced, prevention.incoming - remaining);
if (!Double.isFinite(remaining) || !Double.isFinite(actual) || actual <= 0) return;
try { prevention.grant.execution.report(AbilityReport.Type.PREVENTION, event.getEntity(),
BigDecimal.valueOf(actual)); }
catch (RuntimeException ignored) { /* Feedback cannot interrupt an accepted native hit or its audit. */ }
if (prevention.grant.acceptedObservation != null) try {
prevention.grant.acceptedObservation.accept(event, prevention.amount.doubleValue());
prevention.grant.acceptedObservation.accept(new Accepted((EntityDamageByEntityEvent) event,
prevention.incoming, remaining, actual));
} catch (RuntimeException ignored) { /* Audit cannot alter the accepted native hit. */ }
}
@@ -62,11 +62,24 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition()); var player = execution.input().player();
var grant = mitigation.grant(execution, player, current.reductionPercent());
var grant = mitigation.grantAccepted(execution, player, current.reductionPercent(), () -> true,
accepted -> {
try {
execution.acceptedPrevention(execution.input().session(), player, accepted.event().getDamager(),
accepted.incoming(), accepted.prevented(), accepted.remaining(), null);
} catch (RuntimeException ignored) { /* Auditing cannot change an accepted native hit. */ }
});
var state = new State(execution, player, grant);
var previous = protections.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
execution.own(state); execution.later(current.duration(), execution::finish);
if (previous != null) { previous.execution.resolve("replaced"); previous.execution.finish(); }
execution.own(state);
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("reduction_percent", current.reductionPercent().stripTrailingZeros().toPlainString());
evidence.put("duration_seconds", BigDecimal.valueOf(current.duration().toNanos()).movePointLeft(9).stripTrailingZeros().toPlainString());
var activatedAt = execution.now();
if (activatedAt != null) evidence.put("effect_until", activatedAt.plus(current.duration()).toString());
execution.effectApplied("stone_skin", player, evidence);
execution.later(current.duration(), () -> { execution.resolve("expired"); execution.finish(); });
}
private static Settings from(AbilityDefinition definition) {
@@ -78,7 +91,9 @@ public final class StoneSkin implements AbilityEffect, AutoCloseable {
@Override public void close() {
if (closed) return; closed = true;
for (var state : List.copyOf(protections.values())) state.execution.finish();
for (var state : List.copyOf(protections.values())) {
state.execution.resolve("interrupted"); state.execution.finish();
}
protections.clear();
}
}
@@ -221,6 +221,10 @@ public final class AbilityCastEventBridge {
data.put("target_location", locationData(ally.getLocation()));
data.put("attacker_entity_id", attacker.getUniqueId().toString());
data.put("attacker_entity_type", entityType(attacker));
if (cast.abilityId().equals("stone_skin")) {
var attackLocation = attacker.getLocation();
if (attackLocation != null) data.put("attacker_location", locationData(attackLocation));
}
data.put("incoming_amount", decimal(incoming));
data.put("actual_amount", decimal(actual));
data.put("remaining_amount", decimal(remaining));
@@ -71,11 +71,12 @@ public final class StoneSkinSettings implements AutoCloseable {
public StoneSkin.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1677,8 +1677,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void stoneSkinConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = stoneSkinSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + stoneSkinSettings.value(key) + " (applies to future Stone Skin casts only)"); return;
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "stone_skin", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Stone Skin casts only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.StoneSkinSettings.Update> operation;
try { operation = stoneSkinSettings.set(key, args[2]); }
@@ -1688,14 +1691,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
stoneSkin.configure(saved.settings()); abilities.replace(stoneSkin.definition()); stoneSkinSettings.publish(saved);
String effectiveBefore = failure == null ? stoneSkinSettings.value(key) : null;
boolean applied = failure == null && stoneSkinSettings.publish(saved);
if (applied) {
stoneSkin.configure(stoneSkinSettings.settings()); abilities.replace(stoneSkin.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "stone_skin", key, effectiveBefore, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
: "Stone Skin configuration save failed; the effective setting is unchanged.");
sender.sendMessage(failure != null
? "Stone Skin configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Stone Skin casts; active effects and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + stoneSkinSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
@@ -24,10 +24,15 @@ class StoneSkinNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionEquippedUseReducesNativeEntityDamageForFourSeconds() throws Exception {
java.util.UUID expectedPlayer, expectedCharacter, expectedSession, expectedWorld, expectedAttacker = java.util.UUID.randomUUID();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
expectedPlayer = a.core.id; expectedCharacter = a.core.record.id(); expectedWorld = a.core.location.getWorld().getUID();
expectedSession = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
.active(expectedPlayer).orElseThrow().session();
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String text = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(text.contains("StoneSkin")); assertTrue(text.contains("CON14"));
@@ -39,6 +44,9 @@ class StoneSkinNativeTest {
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
var attacker = mock(org.bukkit.entity.Zombie.class);
when(attacker.getUniqueId()).thenReturn(expectedAttacker);
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(attacker.getLocation()).thenReturn(a.core.location.clone());
var hit = new EntityDamageByEntityEvent(attacker, a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(hit); assertEquals(6, hit.getDamage(), 1.0e-9);
@@ -59,9 +67,38 @@ class StoneSkinNativeTest {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); use(f, a);
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(2, count(own, "ability.learned"));
assertEquals(2, count(own, "ability.assigned"));
assertEquals(1, count(own, "ability.unlearned"));
assertEquals(1, count(own, "ability.cast"));
assertEquals(1, count(own, "effect.applied"));
assertEquals(1, count(own, "effect.ended"));
assertEquals(1, count(own, "combat.prevented"));
var cast = record(own, "ability.cast"); var applied = record(own, "effect.applied");
var prevented = record(own, "combat.prevented"); var ended = record(own, "effect.ended");
assertEquals(2, cast.get("slot").getAsInt());
assertTrue(cast.has("cooldown_until"));
assertEquals(expectedPlayer.toString(), prevented.get("target_player_id").getAsString());
assertEquals(expectedCharacter.toString(), prevented.get("target_character_id").getAsString());
assertEquals(expectedSession.toString(), prevented.get("target_session_id").getAsString());
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("target_location").get("world_id").getAsString());
assertEquals(expectedAttacker.toString(), prevented.get("attacker_entity_id").getAsString());
assertEquals(expectedWorld.toString(), prevented.getAsJsonObject("attacker_location").get("world_id").getAsString());
assertEquals("25", applied.get("reduction_percent").getAsString());
assertTrue(applied.has("effect_until"));
assertEquals("8", prevented.get("incoming_amount").getAsString());
assertEquals("6", prevented.get("remaining_amount").getAsString());
assertEquals("2", prevented.get("actual_amount").getAsString());
assertEquals(cast.get("action_id").getAsString(), prevented.get("action_id").getAsString());
assertEquals(applied.get("application_id").getAsString(), ended.get("application_id").getAsString());
assertEquals("expired", ended.get("reason").getAsString());
assertEquals("expired", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void environmentalAndCancelledDamageRemainUntouchedWhileProjectileCombatIsReduced() throws Exception {
var arrowId = java.util.UUID.randomUUID();
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
@@ -71,18 +108,26 @@ class StoneSkinNativeTest {
mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(fall); assertEquals(8, fall.getDamage(), 1.0e-9);
var arrow = mock(org.bukkit.entity.Arrow.class);
when(arrow.getUniqueId()).thenReturn(arrowId);
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
when(arrow.getLocation()).thenReturn(a.core.location.clone());
var projectile = new EntityDamageByEntityEvent(arrow, a.player,
EntityDamageEvent.DamageCause.PROJECTILE, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(projectile); assertEquals(6, projectile.getDamage(), 1.0e-9);
class Veto implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.LOWEST)
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void veto(EntityDamageByEntityEvent event) { event.setCancelled(true); }
}
f.dispatcher.register(new Veto(), f.plugin);
var cancelled = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), a.player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled()); assertEquals(8, cancelled.getDamage(), 1.0e-9);
f.dispatcher.accept(cancelled); assertTrue(cancelled.isCancelled());
assertEquals(6, cancelled.getDamage(), 1.0e-9, "Late cancellation wins even after reduction was proposed");
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(1, count(own, "combat.prevented"), "Fall and cancelled native damage cannot claim prevention");
assertEquals(arrowId.toString(), record(own, "combat.prevented").get("attacker_entity_id").getAsString());
}
@Test void replacementExpirySessionExitAndTransferRemoveOnlyOwnedReductionAndRetainDeadline() throws Exception {
@@ -109,6 +154,63 @@ class StoneSkinNativeTest {
f.players.put(b.core.id, b.player); f.activate(b);
assertEquals(deadline, storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("stone_skin"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(3, count(own, "effect.applied"));
assertEquals(3, count(own, "effect.ended"));
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("replaced")).count());
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("expired")).count());
}
@Test void laterNativeDamageOverrideCannotClaimAnUnusedReduction() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a);
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
class Override implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void restore(EntityDamageByEntityEvent event) { event.setDamage(8); }
}
f.dispatcher.register(new Override(), f.plugin);
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(1, count(own, "effect.applied"));
assertEquals(0, count(own, "combat.prevented"));
}
@Test void feedbackOffPreservesPreventionAuditAndRejectedEventStorePreservesNativeReduction() throws Exception {
for (boolean rejectStore : new boolean[]{false, true}) {
var directory = data.resolve(Boolean.toString(rejectStore));
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(directory); withStoneSkin(directory, a, true);
f.players.put(a.core.id, a.player); f.activate(a);
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"}));
f.tick();
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var attacker = mock(org.bukkit.entity.Zombie.class);
when(attacker.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(attacker.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(attacker.getLocation()).thenReturn(a.core.location.clone());
var hit = new EntityDamageByEntityEvent(attacker, a.player, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
mock(org.bukkit.damage.DamageSource.class), 8);
f.dispatcher.accept(hit);
assertEquals(6, hit.getDamage(), 1.0e-9);
assertFalse(a.messages.stream().anyMatch(message -> message.contains("prevented")));
f.plugin.setEnabled(false);
}
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(rejectStore ? 0 : 1, count(own, "combat.prevented"));
}
}
@Test void acknowledgedConfigurationAffectsFutureCastsWhileActiveProtectionKeepsSnapshot() throws Exception {
@@ -158,6 +260,44 @@ class StoneSkinNativeTest {
stone.close(); mitigation.close();
}
@Test void rejectedCooldownSaveCannotEmitAStoneEffectOrClaimPrevention() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStoneSkin(data, a, true);
f.players.put(a.core.id, a.player); f.activate(a);
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
use(f, a); var rejected = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
assertNotNull(rejected); rejected.run();
assertEquals(8, hit(f, a, 8).getDamage(), 1.0e-9);
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")).toList();
assertEquals(0, count(own, "ability.cast"));
assertEquals(0, count(own, "effect.applied"));
assertEquals(0, count(own, "combat.prevented"));
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void failingPreventionFeedbackCannotInterruptAcceptedNativeDamageObservation() {
var mitigation = new games.dmg.fantasystick.ability.CombatMitigation();
var player = mock(org.bukkit.entity.Player.class);
when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
var execution = mock(games.dmg.fantasystick.ability.AbilityExecution.class);
when(execution.stillCurrent()).thenReturn(true);
doThrow(new IllegalStateException("broken feedback")).when(execution).report(any(), any(), any());
var observed = new java.util.ArrayList<games.dmg.fantasystick.ability.CombatMitigation.Accepted>();
mitigation.grantAccepted(execution, player, new java.math.BigDecimal("25"), () -> true, observed::add);
var hit = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), player,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, mock(org.bukkit.damage.DamageSource.class), 8);
mitigation.reduce(hit);
assertDoesNotThrow(() -> mitigation.accepted(hit));
assertEquals(6, hit.getDamage(), 1.0e-9);
assertEquals(1, observed.size());
assertEquals(2, observed.getFirst().prevented(), 1.0e-9);
mitigation.close();
}
@Test void failedCooldownPersistenceCreatesNoReduction() {
var storage = mock(CharacterStorage.class);
var sessions = mock(games.dmg.fantasystick.session.EquipmentSessions.class);
@@ -192,6 +332,38 @@ class StoneSkinNativeTest {
engine.close(); stone.close(); mitigation.close(); parties.close();
}
@Test void authorizedStoneConfigurationReadAndAcknowledgedChangeReachJsonlOnlyAfterSave() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent"});
assertTrue(admin.messages.getLast().contains("stone-skin-damage-reduction-percent=25"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent", "40"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(0, new java.math.BigDecimal("40").compareTo(
registry.find("stone_skin").orElseThrow().parameters().get("damage-reduction-percent")));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent", "50"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "stone-skin-damage-reduction-percent"});
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("stone_skin")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size());
assertEquals("25", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("40", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void configuredDefinitionAdminRoutesAndStartupValidationCoverEverySetting() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"),
"stone-skin-cooldown-seconds: 2\nstone-skin-duration-seconds: 6\nstone-skin-damage-reduction-percent: 40\n");
@@ -237,6 +409,29 @@ class StoneSkinNativeTest {
}
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = java.nio.file.Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
return results;
}
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
}
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
.findFirst().orElseThrow().getAsJsonObject("data");
}
private static EntityDamageByEntityEvent hit(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor target, double amount) {
var event = new EntityDamageByEntityEvent(mock(org.bukkit.entity.Zombie.class), target.player,