This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user