feat(shield-bash): audit accepted strike and knockback
Release / release (push) Successful in 16m56s

This commit is contained in:
dmg committed 2026-09-25 23:32:35 -04:00
1 parent ee7d9036a2
commit fc49ee1cfb
6 files changed
+351 -40

No files matched your search

@@ -744,6 +744,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(shieldBash::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
@@ -764,6 +765,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("quickstep");
abilityEngine.enableCastEventsFor("second_wind");
abilityEngine.enableCastEventsFor("stone_skin");
abilityEngine.enableCastEventsFor("shield_bash");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -72,7 +72,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep") || definition.id().equals("second_wind"))
|| definition.id().equals("quickstep") || definition.id().equals("second_wind")
|| definition.id().equals("shield_bash"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
@@ -19,6 +19,7 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.util.Vector;
@@ -39,9 +40,12 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
}
}
private static final class Attempt {
final AbilityExecution execution; final LivingEntity target; final Settings settings; boolean accepted;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings) {
final AbilityExecution execution; final LivingEntity target; final Settings settings;
final double requested; final double healthBefore;
double finalDamage; boolean accepted; boolean cancelled;
Attempt(AbilityExecution execution, LivingEntity target, Settings settings, double requested, double healthBefore) {
this.execution = execution; this.target = target; this.settings = settings;
this.requested = requested; this.healthBefore = healthBefore;
}
}
@@ -85,42 +89,109 @@ public final class ShieldBash implements AbilityEffect, Listener, AutoCloseable
@Override public void activate(AbilityExecution execution) {
if (closed || !execution.stillCurrent() || !(execution.input().target() instanceof LivingEntity target)) return;
var current = from(execution.definition()); var player = execution.input().player();
if (!inRange(player, target, current.range())) { execution.finish(); return; }
if (!inRange(player, target, current.range())) {
execution.resolve("miss", Map.of("reason", "target_out_of_range_or_obstructed"));
execution.finish(); return;
}
var exact = damage(current.baseDamage(), execution.character().stats().get(Stat.STR));
double amount = exact.bitLength() > 1023 ? Double.MAX_VALUE : exact.doubleValue();
if (!Double.isFinite(amount) || amount <= 0) { execution.finish(); return; }
if (!Double.isFinite(amount) || amount <= 0) {
execution.resolve("miss", Map.of("reason", "damage_nonpositive")); execution.finish(); return;
}
var source = DamageSource.builder(DamageType.PLAYER_ATTACK).withCausingEntity(player).withDirectEntity(player).build();
var attempt = new Attempt(execution, target, current); damaging = attempt;
try { execution.damage(target, amount, source); }
finally { if (damaging == attempt) damaging = null; execution.finish(); }
double beforeHealth;
try { beforeHealth = target.getHealth(); }
catch (RuntimeException unavailable) { beforeHealth = Double.NaN; }
var attempt = new Attempt(execution, target, current, amount, beforeHealth); damaging = attempt;
boolean nativeFailed = false;
try {
execution.damage(target, amount, source);
if (attempt.accepted) publishAccepted(attempt);
}
catch (RuntimeException failed) {
nativeFailed = true;
if (!attempt.accepted) execution.resolve("failed", Map.of("reason", "native_damage_failed"));
throw failed;
} finally {
if (damaging == attempt) damaging = null;
if (!attempt.accepted && !nativeFailed)
execution.resolve(attempt.cancelled ? "cancelled" : "miss",
Map.of("reason", attempt.cancelled ? "native_damage_cancelled" : "native_damage_not_accepted"));
execution.finish();
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
/** Yield generic incoming-player capture to the owning accepted Shield Bash strike. */
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
return event instanceof EntityDamageByEntityEvent by && matches(damaging, by);
}
private boolean matches(Attempt attempt, EntityDamageByEntityEvent event) {
if (closed || attempt == null || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target) return false;
var source = event.getDamageSource();
return (source.getCausingEntity() == null || source.getCausingEntity() == attempt.execution.input().player())
&& (source.getDirectEntity() == null || source.getDirectEntity() == attempt.execution.input().player());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = damaging;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.execution.input().player()
|| event.getEntity() != attempt.target || !attempt.execution.stillCurrent()) return;
if (!matches(attempt, event) || attempt.accepted || !attempt.execution.stillCurrent()) return;
if (event.isCancelled()) { attempt.cancelled = true; return; }
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
attempt.accepted = true;
attempt.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(),
BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
knockback(attempt);
attempt.finalDamage = event.getFinalDamage();
}
private void knockback(Attempt attempt) {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return;
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return;
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) return;
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
private void publishAccepted(Attempt attempt) {
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("stage", "native_damage_accepted");
try {
double afterHealth = attempt.target.getHealth();
if (Double.isFinite(attempt.healthBefore) && attempt.healthBefore > 0
&& Double.isFinite(afterHealth) && afterHealth >= 0) {
evidence.put("target_health_before", BigDecimal.valueOf(attempt.healthBefore).stripTrailingZeros().toPlainString());
evidence.put("target_health_after", BigDecimal.valueOf(afterHealth).stripTrailingZeros().toPlainString());
}
} catch (RuntimeException ignored) { /* Target health may be unavailable after a native hit. */ }
try { attempt.execution.acceptedDamage(attempt.target, attempt.requested, attempt.finalDamage,
attempt.execution.input().player(), evidence); }
catch (RuntimeException ignored) { /* File auditing cannot interrupt accepted native damage. */ }
try { attempt.execution.report(AbilityReport.Type.DAMAGE, attempt.target,
BigDecimal.valueOf(attempt.finalDamage).stripTrailingZeros()); }
catch (RuntimeException ignored) { /* Feedback cannot alter accepted damage or knockback. */ }
String knockbackStatus = knockback(attempt);
attempt.execution.resolve("hit", Map.of("reason", "native_damage_accepted",
"knockback_status", knockbackStatus));
}
private String knockback(Attempt attempt) {
if (!attempt.execution.stillCurrent()) return "session_changed_after_damage";
try {
var player = attempt.execution.input().player(); var target = attempt.target;
Vector away = target.getLocation().toVector().subtract(player.getLocation().toVector()); away.setY(0);
if (!finite(away) || away.lengthSquared() < 1.0e-12) return "direction_unavailable";
Vector current = target.getVelocity();
Vector proposed = current.clone().add(away.normalize().multiply(attempt.settings.horizontalKnockback()));
proposed.setY(Math.max(current.getY(), attempt.settings.minimumVerticalKnockback()));
if (!finite(proposed)) return "velocity_invalid";
if (target instanceof Player targetPlayer) {
var event = new PlayerVelocityEvent(targetPlayer, proposed.clone()); velocityEvents.accept(event);
if (event.isCancelled()) return "native_velocity_cancelled";
if (!finite(event.getVelocity())) return "velocity_invalid";
proposed = event.getVelocity().clone();
}
target.setVelocity(proposed);
try { attempt.execution.effectApplied("shield_bash_knockback", target,
Map.of("knockback_horizontal", decimal(attempt.settings.horizontalKnockback()),
"accepted_velocity_x", decimal(proposed.getX()),
"accepted_velocity_y", decimal(proposed.getY()),
"accepted_velocity_z", decimal(proposed.getZ()),
"status", "native_velocity_accepted")); }
catch (RuntimeException ignored) { /* Accepted movement does not depend on auditing. */ }
return "native_velocity_accepted";
} catch (RuntimeException invalid) { return "native_velocity_failed"; }
}
private static boolean inRange(Player player, LivingEntity target, double range) {
@@ -74,11 +74,12 @@ public final class ShieldBashSettings implements AutoCloseable {
public ShieldBash.Settings settings() { return parse(effectiveValues); }
/** Publish only after the caller returns the durable acknowledgment to the server thread. */
public synchronized void publish(Update update) {
public synchronized boolean publish(Update update) {
Objects.requireNonNull(update);
if (update.sequence() <= publishedSequence) return;
if (update.sequence() <= publishedSequence) return false;
parse(update.values());
effectiveValues = update.values(); publishedSequence = update.sequence();
return true;
}
public CompletionStage<Update> set(String key, String value) {
@@ -1654,7 +1654,12 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void shieldBashConfiguration(CommandSender sender, String[] args) {
String key = args[1];
if (args.length == 2) { sender.sendMessage(key + "=" + shieldBashSettings.value(key) + " (applies to future Shield Bash strikes only)"); return; }
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = shieldBashSettings.value(key);
if (args.length == 2) {
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "shield_bash", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Shield Bash strikes only)"); return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.ShieldBashSettings.Update> operation;
try { operation = shieldBashSettings.set(key, args[2]); }
catch (IllegalArgumentException invalid) { sender.sendMessage("Shield Bash setting is invalid; the effective value is unchanged."); return; }
@@ -1663,14 +1668,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
if (closed) return;
try { nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
shieldBash.configure(saved.settings()); abilities.replace(shieldBash.definition()); shieldBashSettings.publish(saved);
String effectiveBefore = failure == null ? shieldBashSettings.value(key) : null;
boolean applied = failure == null && shieldBashSettings.publish(saved);
if (applied) {
shieldBash.configure(shieldBashSettings.settings()); abilities.replace(shieldBash.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "shield_bash", key, effectiveBefore, saved.value());
}
if (!sender.hasPermission(PERMISSION)
|| sender instanceof org.bukkit.entity.Player player && !player.isOnline()) return;
sender.sendMessage(failure == null
? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
: "Shield Bash configuration save failed; the effective setting is unchanged.");
sender.sendMessage(failure != null
? "Shield Bash configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Shield Bash strikes; active work and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + shieldBashSettings.value(key) + ".");
}); } catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
}
@@ -25,10 +25,15 @@ class ShieldBashNativeTest {
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void bookAcquisitionAndEquippedAttackReplaceVanillaDamageThenKnockBack() throws Exception {
java.util.UUID playerId, characterId, sessionId, targetId, worldId;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withShieldBash(data, a, false);
f.players.put(a.core.id, a.player); f.activate(a);
playerId = a.core.id; characterId = a.core.record.id();
sessionId = ((games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions"))
.active(playerId).orElseThrow().session();
a.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(f, a);
String book = PartyBookNativeTest.text(a).replaceAll("\\s+", "");
assertTrue(book.contains("ShieldBash")); assertTrue(book.contains("STR12")); assertTrue(book.contains("CON12"));
@@ -46,18 +51,23 @@ class ShieldBashNativeTest {
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
targetId = java.util.UUID.randomUUID(); when(target.getUniqueId()).thenReturn(targetId);
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
worldId = java.util.UUID.randomUUID(); when(a.world.getUID()).thenReturn(worldId);
var origin = new Location(a.world, 0, 64, 0); var destination = new Location(a.world, 2, 64, 0);
doReturn(origin).when(a.player).getLocation(); when(target.getLocation()).thenReturn(destination);
doReturn(true).when(a.player).hasLineOfSight(target); when(target.getVelocity()).thenReturn(new Vector(0.1, 0.2, 0));
var targetHealth = new java.util.concurrent.atomic.AtomicReference<>(20.0);
when(target.getHealth()).thenAnswer(ignored -> targetHealth.get());
var applied = new java.util.concurrent.atomic.AtomicReference<Vector>();
doAnswer(call -> { applied.set(call.getArgument(0)); return null; }).when(target).setVelocity(any(Vector.class));
var damages = new java.util.ArrayList<EntityDamageByEntityEvent>();
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event); return null;
source, (Double) call.getArgument(0)); damages.add(event); f.dispatcher.accept(event);
if (!event.isCancelled()) targetHealth.updateAndGet(health -> Math.max(0, health - event.getFinalDamage()));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true);
@@ -73,6 +83,30 @@ class ShieldBashNativeTest {
PartyBookNativeTest.drain(f); assertEquals(1, damages.size());
assertTrue(a.messages.stream().anyMatch(message -> message.contains("ready in")));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(1, count(own, "ability.learned"));
assertEquals(1, count(own, "ability.assigned"));
assertEquals(1, count(own, "ability.cast"));
assertEquals(1, count(own, "combat.damage"));
assertEquals(1, count(own, "effect.applied"));
var cast = record(own, "ability.cast"); var damage = record(own, "combat.damage");
var knockback = record(own, "effect.applied"); var resolution = record(own, "ability.resolved");
assertEquals(2, cast.get("slot").getAsInt()); assertTrue(cast.has("cooldown_until"));
assertEquals(playerId.toString(), damage.get("player_id").getAsString());
assertEquals(characterId.toString(), damage.get("character_id").getAsString());
assertEquals(sessionId.toString(), damage.get("session_id").getAsString());
assertEquals(targetId.toString(), damage.get("target_entity_id").getAsString());
assertEquals(worldId.toString(), damage.getAsJsonObject("target_location").get("world_id").getAsString());
assertEquals(5, damage.get("requested_amount").getAsDouble(), 1.0e-9);
assertEquals(5, damage.get("actual_amount").getAsDouble(), 1.0e-9);
assertEquals("20", damage.get("target_health_before").getAsString());
assertEquals("15", damage.get("target_health_after").getAsString());
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
assertEquals(cast.get("action_id").getAsString(), knockback.get("action_id").getAsString());
assertEquals("shield_bash_knockback", knockback.get("effect_id").getAsString());
assertEquals("hit", resolution.get("outcome").getAsString());
assertEquals("native_velocity_accepted", resolution.get("knockback_status").getAsString());
}
@Test void outOfRangeOrObstructedTargetRefusesBeforeCooldownWhileStillContainingShield() throws Exception {
@@ -93,6 +127,11 @@ class ShieldBashNativeTest {
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("shield_bash"));
}
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(0, count(own, "ability.cast"));
assertEquals(0, count(own, "combat.damage"));
assertEquals("refused", record(own, "ability.resolved").get("outcome").getAsString());
}
}
@@ -132,6 +171,40 @@ class ShieldBashNativeTest {
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("shield_bash"));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(1, count(own, "ability.cast"));
assertEquals(0, count(own, "combat.damage"));
assertEquals(0, count(own, "effect.applied"));
assertEquals("cancelled", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void zeroFinalNativeDamageNeverClaimsAHeldShieldHitOrKnockback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(target);
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
var event = new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)); event.setDamage(0); f.dispatcher.accept(event); return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(target, never()).setVelocity(any(Vector.class));
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(1, count(own, "ability.cast"));
assertEquals(0, count(own, "combat.damage"));
assertEquals(0, count(own, "effect.applied"));
assertEquals("miss", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void cancelledPlayerVelocityPreservesMovementAfterAcceptedDamage() throws Exception {
@@ -157,6 +230,81 @@ class ShieldBashNativeTest {
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, b.player, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); verify(b.player, never()).setVelocity(any(Vector.class));
}
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).toList();
assertEquals(1, damage.size(), "Owned accepted strike must not also be generic incoming player damage");
assertEquals("shield_bash", damage.getFirst().getAsJsonObject("data").get("ability_id").getAsString());
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(0, count(own, "effect.applied"), "Cancelled velocity is not an applied knockback");
assertEquals("native_velocity_cancelled", record(own, "ability.resolved").get("knockback_status").getAsString());
}
@Test void disabledRoutineFeedbackKeepsAuditAndRejectedWriterCannotChangeAcceptedStrike() throws Exception {
for (boolean rejectStore : new boolean[]{false, true}) {
var directory = data.resolve(Boolean.toString(rejectStore));
try (var f = new FantasyStickLifecycleNativeTest.Fixture(directory)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(directory);
withShieldBash(directory, a, true); f.players.put(a.core.id, a.player); f.activate(a);
assertTrue(f.feedbackCommand.execute(a.player, "fantasyfeedback", new String[]{"off"})); f.tick();
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(target);
when(target.getVelocity()).thenReturn(new Vector());
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target,
EntityDamageEvent.DamageCause.ENTITY_ATTACK, source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
if (rejectStore) ((games.dmg.fantasystick.events.LocalEventLog)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(target).damage(eq(5.0), any(org.bukkit.damage.DamageSource.class));
verify(target).setVelocity(any(Vector.class));
assertFalse(a.messages.stream().anyMatch(message -> message.contains("Shield Bash hit")));
f.plugin.setEnabled(false);
}
var own = events(directory).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(rejectStore ? 0 : 1, count(own, "combat.damage"));
}
}
@Test void failedNativeVelocityPublicationKeepsAcceptedDamageButNeverClaimsKnockback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
when(a.world.getUID()).thenReturn(java.util.UUID.randomUUID());
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(target);
when(target.getVelocity()).thenReturn(new Vector());
doThrow(new IllegalStateException("simulated native velocity refusal")).when(target).setVelocity(any(Vector.class));
doAnswer(call -> {
var source = (org.bukkit.damage.DamageSource) call.getArgument(1);
f.dispatcher.accept(new EntityDamageByEntityEvent(a.player, target, EntityDamageEvent.DamageCause.ENTITY_ATTACK,
source, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(1, count(own, "combat.damage"));
assertEquals(0, count(own, "effect.applied"));
assertEquals("hit", record(own, "ability.resolved").get("outcome").getAsString());
assertEquals("native_velocity_failed", record(own, "ability.resolved").get("knockback_status").getAsString());
}
@Test void unlearnRelearnAndCharacterTransferRetainCooldown() throws Exception {
@@ -247,6 +395,38 @@ class ShieldBashNativeTest {
}
}
@Test void authorizedShieldConfigurationReadAndAcknowledgedChangeReachExportedJsonlOnlyAfterSave() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var admin = new EquipmentAdminCommandNativeTest.Sender(); admin.allowed = true;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage"});
assertTrue(admin.messages.getLast().contains("shield-bash-base-damage=4"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage", "7"});
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
assertEquals(new java.math.BigDecimal("7"), registry.find("shield_bash").orElseThrow()
.parameters().get("base-damage"));
try (var channel = java.nio.channels.FileChannel.open(data.resolve(".config.lock"),
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.WRITE);
var lock = channel.lock()) {
assertTrue(lock.isValid());
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage", "8"});
var failed = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(failed); failed.run();
}
admin.allowed = false;
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "shield-bash-base-damage"});
f.plugin.setEnabled(false);
}
var config = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size());
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void retainedSixSecondCooldownIsHonoredWithoutRewritingConfiguration() throws Exception {
String retained = "shield-bash-cooldown-seconds: 6\n";
java.nio.file.Files.writeString(data.resolve("config.yml"), retained);
@@ -270,6 +450,32 @@ class ShieldBashNativeTest {
games.dmg.fantasystick.ability.ShieldBash.damage(huge));
}
@Test void failedCooldownSaveNeverPublishesShieldDamageOrKnockback() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var a = new FantasyStickLifecycleNativeTest.Actor(data);
withShieldBash(data, a, true); f.players.put(a.core.id, a.player); f.activate(a);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doReturn(new Location(a.world, 0, 64, 0)).when(a.player).getLocation();
when(target.getLocation()).thenReturn(new Location(a.world, 2, 64, 0));
doReturn(true).when(a.player).hasLineOfSight(target);
((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).close();
a.core.inventory.getInventory().setSelectedSlot(2);
f.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(a.player, target, true));
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response); response.run();
verify(target, never()).damage(anyDouble(), any(org.bukkit.damage.DamageSource.class));
verify(target, never()).setVelocity(any(Vector.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("shield_bash")).toList();
assertEquals(0, count(own, "ability.cast"));
assertEquals(0, count(own, "combat.damage"));
assertEquals(0, count(own, "effect.applied"));
assertEquals("failed", record(own, "ability.resolved").get("outcome").getAsString());
}
@Test void failedCooldownPersistenceDealsNoDamageOrKnockback() {
var storage=mock(CharacterStorage.class);var sessions=mock(games.dmg.fantasystick.session.EquipmentSessions.class);
var parties=new games.dmg.fantasystick.party.PartyRoster(java.time.Clock.systemUTC(),ignored->true);
@@ -361,6 +567,27 @@ class ShieldBashNativeTest {
assertEquals(expected, value.get());
}
private static java.util.List<com.google.gson.JsonObject> events(Path data) throws Exception {
var results = new java.util.ArrayList<com.google.gson.JsonObject>();
try (var days = java.nio.file.Files.list(data.resolve("events"))) {
for (var day : days.filter(path -> path.getFileName().toString().matches("\\d{4}-\\d{2}-\\d{2}")).toList()) {
try (var segments = java.nio.file.Files.list(day)) {
for (var path : segments.filter(file -> file.getFileName().toString().endsWith(".jsonl")).toList())
for (var line : java.nio.file.Files.readAllLines(path))
if (!line.isBlank()) results.add(com.google.gson.JsonParser.parseString(line).getAsJsonObject());
}
}
}
return results;
}
private static long count(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix)).count();
}
private static com.google.gson.JsonObject record(java.util.List<com.google.gson.JsonObject> own, String suffix) {
return own.stream().filter(event -> event.get("type").getAsString().endsWith(suffix))
.findFirst().orElseThrow().getAsJsonObject("data");
}
private static void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);