feat(combat): audit accepted basic attacks
Release / release (push) Failing after 7m39s

This commit is contained in:
dmg committed 2026-09-25 15:26:45 -04:00
1 parent c7fce1a53d
commit bb8d1857b4
4 files changed
+535 -11

No files matched your search

@@ -731,6 +731,9 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(chainLightning::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(meteor::ownsAcceptedDamage);
eventCapture.excludeOwnedDamage(localBasicAttack::ownsAcceptedDamage);
localBasicAttack.attachEvents(new games.dmg.fantasystick.events.BasicAttackEventBridge(
eventLog::emit, Clock.systemUTC()));
downedEvents = new games.dmg.fantasystick.events.DownedEventBridge(eventLog::emit, Clock.systemUTC());
rareEvents = new games.dmg.fantasystick.events.RareEventBridge(eventLog::emit, Clock.systemUTC());rareShop.attachEvents(rareEvents);
bookEvents = new games.dmg.fantasystick.events.BookEventBridge(eventLog::emit, Clock.systemUTC());books.attachEvents(bookEvents);
@@ -28,6 +28,7 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
private final CombatFeedback feedback;
private final games.dmg.fantasystick.training.TrainingDamage trainingDamage;
private final Map<UUID, Instant> readyAt = new HashMap<>();
private games.dmg.fantasystick.events.BasicAttackEventBridge events;
private Pending pending;
private boolean closed;
@@ -36,9 +37,18 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
final LivingEntity target;
final Instant attackedAt;
final boolean knockback;
final games.dmg.fantasystick.events.BasicAttackEventBridge.Action action;
final double requested;
boolean accepted;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback) {
boolean nativeSeen;
boolean nativePriorCancelled;
boolean nativeCancelled;
Double nativeFinal;
EntityDamageByEntityEvent acceptedEvent;
Pending(ControlInput input, LivingEntity target, Instant attackedAt, boolean knockback,
double requested, games.dmg.fantasystick.events.BasicAttackEventBridge.Action action) {
this.input = input; this.target = target; this.attackedAt = attackedAt; this.knockback = knockback;
this.requested = requested; this.action = action;
}
}
@@ -62,26 +72,38 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
this.trainingDamage = Objects.requireNonNull(trainingDamage);
}
public void attachEvents(games.dmg.fantasystick.events.BasicAttackEventBridge bridge) {
if (closed || events != null) throw new IllegalStateException("Basic attack events already attached");
events = Objects.requireNonNull(bridge);
}
@Override public void accept(ControlInput input) {
if (closed || pending != null || input.kind() != ControlInput.Kind.BASIC_ATTACK
|| input.gesture() != ControlInput.Gesture.ATTACK
|| !(input.target() instanceof LivingEntity target) || !input.stillCurrent()) return;
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) return;
if (input.kind() != ControlInput.Kind.BASIC_ATTACK || input.gesture() != ControlInput.Gesture.ATTACK) return;
Instant now = clock.instant();
Instant ready = readyAt.get(input.session().character());
if (ready != null && now.isBefore(ready)) return;
if (closed || pending != null || !(input.target() instanceof LivingEntity) || !input.stillCurrent()) {
refuse(input, null, now, ready, closed ? "closed" : pending != null ? "attack_in_progress"
: !(input.target() instanceof LivingEntity) ? "ineligible_target" : "stale_input");
return;
}
var target = (LivingEntity) input.target();
var record = sessions.currentRecord(input.session()).orElse(null);
if (record == null) { refuse(input, null, now, ready, "missing_character"); return; }
if (ready != null && now.isBefore(ready)) { refuse(input, record, now, ready, "cadence_not_ready"); return; }
BigInteger damage = damage(record.stats().get(Stat.STR));
double nativeDamage = damage.bitLength() > 1023 ? Double.MAX_VALUE : damage.doubleValue();
var action = events == null ? null : events.observed(input, record, target, now, ready, nativeDamage);
var attempt = new Pending(input, target, now,
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID));
record.passives().contains(games.dmg.fantasystick.passive.StickKnockback.ID), nativeDamage, action);
pending = attempt;
boolean completed = false;
try {
var measured = trainingDamage.apply(input.player(), target, () -> target.damage(nativeDamage, input.player()));
measured.ifPresent(amount -> {
attempt.accepted = true;
if (feedback != null) feedback.report("Basic Attack", input.player(), AbilityReport.Type.DAMAGE, target, amount);
});
completed = true;
} finally {
if (pending == attempt) pending = null;
if (attempt.accepted) {
@@ -91,9 +113,24 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
games.dmg.fantasystick.passive.StickKnockback.apply(input.player(), target);
}
}
if (events != null) events.resolved(action, attempt.accepted ? "accepted" : !completed ? "failed"
: attempt.nativeCancelled ? "cancelled" : "refused",
attempt.accepted ? attempt.nativeSeen ? "native_positive_hit" : "training_measurement"
: !completed ? "native_damage_failed" : attempt.nativePriorCancelled ? "prior_cancelled"
: attempt.nativeCancelled ? "native_cancelled" : attempt.nativeSeen
? "zero_or_invalid_damage" : "native_hit_unverified",
attempt.nativePriorCancelled, attempt.nativeCancelled, attempt.nativeFinal,
attempt.accepted ? readyAt.get(input.session().character()) : null);
}
}
private void refuse(ControlInput input, games.dmg.fantasystick.persistence.CharacterRecord record,
Instant now, Instant ready, String reason) {
if (events == null) return;
var action = events.observed(input, record, input.target(), now, ready, null);
events.resolved(action, "refused", reason, false, false, null, null);
}
static BigInteger damage(BigInteger strength) {
Objects.requireNonNull(strength);
if (strength.signum() < 0) throw new IllegalArgumentException("Strength must not be negative");
@@ -101,13 +138,35 @@ public final class BasicAttack implements Consumer<ControlInput>, Listener, Auto
}
/** MONITOR observes the synchronous native result; cancelled/zero damage never spends cadence. */
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.LOWEST)
public void observedNativeDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (attempt != null && event.getDamager() == attempt.input.player() && event.getEntity() == attempt.target)
attempt.nativePriorCancelled = event.isCancelled();
}
public boolean ownsAcceptedDamage(org.bukkit.event.entity.EntityDamageEvent event) {
var attempt = pending;
if (closed || attempt == null || !(event instanceof EntityDamageByEntityEvent by)) return false;
if (attempt.accepted) return attempt.acceptedEvent == event;
return by.getDamager() == attempt.input.player() && by.getEntity() == attempt.target
&& !event.isCancelled() && Double.isFinite(event.getFinalDamage()) && event.getFinalDamage() > 0
&& attempt.input.stillCurrent() && sessions.current(attempt.input.session());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
public void acceptedDamage(EntityDamageByEntityEvent event) {
var attempt = pending;
if (closed || attempt == null || attempt.accepted || event.isCancelled() || event.getFinalDamage() <= 0
|| !Double.isFinite(event.getFinalDamage()) || event.getDamager() != attempt.input.player() || event.getEntity() != attempt.target
if (closed || attempt == null || attempt.accepted || event.getDamager() != attempt.input.player()
|| event.getEntity() != attempt.target) return;
attempt.nativeSeen = true;
attempt.nativeCancelled = event.isCancelled();
if (event.isCancelled() || event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())
|| !attempt.input.stillCurrent() || !sessions.current(attempt.input.session())) return;
attempt.accepted = true;
attempt.acceptedEvent = event;
attempt.nativeFinal = event.getFinalDamage();
if (events != null) events.acceptedDamage(attempt.action, event, attempt.attackedAt.plus(CADENCE));
if (feedback != null) feedback.report("Basic Attack", attempt.input.player(), AbilityReport.Type.DAMAGE,
event.getEntity(), java.math.BigDecimal.valueOf(event.getFinalDamage()).stripTrailingZeros());
}
@@ -0,0 +1,117 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.persistence.CharacterRecord;
import games.dmg.fantasystick.session.ControlInput;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import org.bukkit.Location;
import org.bukkit.entity.Entity;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
/** Immutable server-thread snapshots for the owned bound-stick input and its native hit. */
public final class BasicAttackEventBridge {
public record Action(UUID id, Instant observedAt, EventInput.Context actor, Map<String, Object> data) { }
private final Consumer<EventInput> sink;
private final Clock clock;
public BasicAttackEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink);
this.clock = Objects.requireNonNull(clock);
}
public Action observed(ControlInput input, CharacterRecord record, Entity target, Instant at,
Instant ready, Double requested) {
try {
var player = input.player();
var context = new EventInput.Context(player.getUniqueId(), player.getName(),
record == null ? null : record.id(), record == null ? null : record.name(),
record == null ? null : input.session().session(), location(player.getLocation()));
var data = new LinkedHashMap<String, Object>();
data.put("action_kind", "basic_attack");
data.put("entry_point", "bound_slot_2_attack");
data.put("slot_index", input.slot());
data.put("control_kind", input.kind().name().toLowerCase(java.util.Locale.ROOT));
data.put("source_player_id", player.getUniqueId().toString());
data.put("source_character_id", input.session().character().toString());
data.put("source_session_id", input.session().session().toString());
if (record != null) data.put("strength", record.stats().get(games.dmg.fantasystick.progression.Stat.STR).toString());
else data.put("strength_status", "unavailable");
data.put("ready_deadline", (ready == null ? at : ready).toString());
if (ready == null) data.put("ready_deadline_status", "ready_at_observation_no_prior_hit");
if (requested != null) data.put("requested_amount", Double.toString(requested));
else data.put("requested_amount_status", "not_computed");
data.put("prior_cancelled", false);
if (target != null && target.getUniqueId() != null) data.put("target_entity_id", target.getUniqueId().toString());
if (target instanceof org.bukkit.entity.Player) data.put("target_entity_type", "player");
else if (target != null && target.getType() != null)
data.put("target_entity_type", target.getType().name().toLowerCase(java.util.Locale.ROOT));
var sourceLocation = location(player.getLocation());
var targetLocation = target == null ? null : location(target.getLocation());
if (sourceLocation != null) data.put("source_location", coordinates(sourceLocation));
else data.put("source_location_status", "unavailable");
if (targetLocation != null) data.put("target_location", coordinates(targetLocation));
else data.put("target_location_status", "unavailable");
var action = new Action(UUID.randomUUID(), at, context, Map.copyOf(data));
var values = new LinkedHashMap<String, Object>(data);
values.put("outcome", "observed");
emit(action, "action.observed", "observed", at, values);
return action;
} catch (RuntimeException ignored) { return null; }
}
public void resolved(Action action, String outcome, String reason, boolean nativePriorCancelled,
boolean finalCancelled, Double finalAmount, Instant nextReady) {
if (action == null) return;
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", outcome);
values.put("reason", reason);
values.put("native_prior_cancelled", nativePriorCancelled);
values.put("final_cancelled", finalCancelled);
if (finalAmount != null) values.put("final_amount", Double.toString(finalAmount));
else values.put("final_amount_status", "unavailable");
if (nextReady != null) values.put("next_ready_deadline", nextReady.toString());
emit(action, "action.resolved", "resolved", clock.instant(), values);
}
public void acceptedDamage(Action action, EntityDamageByEntityEvent event, Instant nextReady) {
if (action == null) return;
try {
var values = new LinkedHashMap<String, Object>(action.data());
values.put("outcome", "accepted");
values.put("reason", "native_positive_hit");
values.put("final_cancelled", false);
values.put("final_amount", Double.toString(event.getFinalDamage()));
values.put("next_ready_deadline", nextReady.toString());
var target = event.getEntity();
if (target instanceof org.bukkit.entity.Player player) values.put("target_player_id", player.getUniqueId().toString());
var captured = location(target.getLocation());
if (captured != null) values.put("target_location", coordinates(captured));
else values.put("target_location_status", "unavailable");
emit(action, "combat.damage", "damage", clock.instant(), values);
} catch (RuntimeException ignored) { }
}
private EventInput.Location location(Location nativeLocation) {
return nativeLocation == null || nativeLocation.getWorld() == null ? null
: new EventInput.Location(nativeLocation.getWorld().getUID(), nativeLocation.getWorld().getName(),
nativeLocation.getX(), nativeLocation.getY(), nativeLocation.getZ(), clock.instant(), "exact");
}
private Map<String, Object> coordinates(EventInput.Location value) {
return Map.of("world_id", value.worldId().toString(), "world_name", value.worldName(), "x", value.x(),
"y", value.y(), "z", value.z(), "captured_at", value.capturedAt().toString());
}
private void emit(Action action, String type, String stage, Instant time, Map<String, Object> values) {
try {
sink.accept(new EventInput(UUID.nameUUIDFromBytes(("fantasystick:basic-attack:" + action.id() + ':'
+ stage).getBytes(StandardCharsets.UTF_8)), action.id(), time, type, action.actor(), values));
} catch (RuntimeException ignored) { }
}
}
@@ -0,0 +1,345 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Registered bound slot-2 input -> native damage -> local JSONL, not a direct bridge test. */
class BasicAttackEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void acceptedNativeBasicHitHasOneCorrelatedDamageRecordAndRealFinalAmount() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
doAnswer(call -> {
var damageSource = mock(org.bukkit.damage.DamageSource.class);
when(damageSource.getCausingEntity()).thenReturn(source.player);
when(damageSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, damageSource,
(Double) call.getArgument(0));
event.setDamage(0.75);
fixture.dispatcher.accept(event);
assertFalse(event.isCancelled());
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
var attack = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(attack);
assertTrue(attack.isCancelled(), "Bound stick may not continue to vanilla damage");
for (int i = 0; i < 250 && records().stream().noneMatch(value -> is(value, "combat.damage")); i++)
Thread.sleep(20);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(1, observed.size()); assertEquals(1, resolved.size()); assertEquals(1, damage.size());
var start = observed.getFirst().getAsJsonObject("data");
var result = resolved.getFirst().getAsJsonObject("data");
var hit = damage.getFirst().getAsJsonObject("data");
assertEquals("basic_attack", start.get("action_kind").getAsString());
assertEquals(start.get("action_id").getAsString(), result.get("action_id").getAsString());
assertEquals(start.get("action_id").getAsString(), hit.get("action_id").getAsString());
assertEquals("accepted", result.get("outcome").getAsString());
assertEquals("1.0", hit.get("requested_amount").getAsString());
assertEquals("0.75", hit.get("final_amount").getAsString());
assertEquals(source.core.id.toString(), hit.get("source_player_id").getAsString());
assertEquals(target.core.id.toString(), hit.get("target_player_id").getAsString());
assertTrue(start.has("source_location")); assertTrue(hit.has("target_location"));
assertTrue(start.has("ready_deadline"));
}
}
@Test void partyVetoZeroDamageAndExactCadenceDoNotMintExtraSuccess() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
strength(source, 12);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var basic = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var clock = new BasicAttackNativeTest.MutableClock(java.time.Instant.now());
var field = games.dmg.fantasystick.combat.BasicAttack.class.getDeclaredField("clock");
field.setAccessible(true); field.set(basic, clock);
var parties = PartyLifecycleNativeTest.parties(fixture);
var group = parties.create(source.core.id).orElseThrow();
var invitation = parties.invite(source.core.id, group.id(), target.core.id).orElseThrow();
assertTrue(parties.accept(target.core.id, invitation.id()));
var requests = new ArrayList<Double>();
var zero = new java.util.concurrent.atomic.AtomicBoolean(false);
doAnswer(call -> {
double requested = call.getArgument(0);
requests.add(requested);
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var nativeHit = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, target.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource, requested);
if (zero.get()) nativeHit.setDamage(0);
fixture.dispatcher.accept(nativeHit);
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target); // Party guard rejects this native attempt.
assertTrue(parties.leave(target.core.id, group.id()));
zero.set(true); attack(fixture, source, target); // Zero final amount, no deadline.
zero.set(false); attack(fixture, source, target); // STR 12: base 1 + modifier 1.
clock.advance(java.time.Duration.ofMillis(999)); attack(fixture, source, target);
assertEquals(3, requests.size(), "Early input cannot trigger a fourth native hit");
clock.advance(java.time.Duration.ofMillis(1)); attack(fixture, source, target);
assertEquals(List.of(2.0, 2.0, 2.0, 2.0), requests);
fixture.plugin.setEnabled(false);
var entries = records();
var observed = actions(entries, "action.observed");
var resolved = actions(entries, "action.resolved");
var damage = entries.stream().filter(value -> is(value, "combat.damage")).toList();
assertEquals(5, observed.size()); assertEquals(5, resolved.size()); assertEquals(2, damage.size());
assertEquals(List.of("cancelled", "refused", "accepted", "refused", "accepted"),
resolved.stream().map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals(List.of("native_cancelled", "zero_or_invalid_damage", "native_positive_hit",
"cadence_not_ready", "native_positive_hit"), resolved.stream()
.map(value -> value.getAsJsonObject("data").get("reason").getAsString()).toList());
assertTrue(damage.stream().allMatch(value -> value.getAsJsonObject("data").get("strength").getAsString().equals("12")
&& value.getAsJsonObject("data").get("requested_amount").getAsString().equals("2.0")));
assertEquals(2, damage.stream().map(value -> value.getAsJsonObject("data").get("action_id").getAsString())
.distinct().count());
}
}
@Test void optionalStickKnockbackDoesNotCreateAnotherDamageOutcome() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture);
strength(source, java.math.BigInteger.valueOf(12), List.of("stick_knockback", "", ""));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var target = mock(org.bukkit.entity.LivingEntity.class);
when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
when(target.getPersistentDataContainer()).thenReturn(new org.bukkit.craftbukkit.persistence.CraftPersistentDataContainer(
new org.bukkit.craftbukkit.persistence.CraftPersistentDataTypeRegistry()));
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target).damage(anyDouble(), same(source.player));
fixture.dispatcher.accept(new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target, true));
verify(target).knockback(eq(1.0), anyDouble(), anyDouble());
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(1, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(1, actions(entries, "action.resolved").size());
}
}
@Test void staleInputAfterExitAndRestartCannotReplayPreviousAcceptedHit() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var target = actor(fixture);
fixture.activate(source); fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
var sessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
var old = sessions.active(source.core.id).orElseThrow();
var requests = new java.util.concurrent.atomic.AtomicInteger();
doAnswer(call -> {
requests.incrementAndGet();
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, (Double) call.getArgument(0)));
return null;
}).when(target.player).damage(anyDouble(), same(source.player));
attack(fixture, source, target);
var sheet = source.core.inventory.getItemInOffHand().clone();
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
fixture.awaitCharacterAndReceipt(source);
fixture.plugin.setEnabled(false); fixture.awaitWorkers();
fixture.plugin.setEnabled(true);
var resumed = (games.dmg.fantasystick.combat.BasicAttack)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "basicAttack");
var stale = new games.dmg.fantasystick.session.ControlInput(source.player, old, 1, "",
games.dmg.fantasystick.session.ControlInput.Kind.BASIC_ATTACK,
games.dmg.fantasystick.session.ControlInput.Gesture.ATTACK, target.player, 0, 1,
() -> false);
resumed.accept(stale); resumed.accept(stale);
assertEquals(1, requests.get(), "Stale/duplicate notifications cannot replay native damage");
source.core.inventory.setItemInOffHand(sheet); fixture.activate(source);
var recoveredSessions = (games.dmg.fantasystick.session.EquipmentSessions)
FantasyStickLifecycleNativeTest.Fixture.field(fixture.plugin, "sessions");
if (recoveredSessions.active(target.core.id).isEmpty()) fixture.activate(target);
source.core.inventory.getInventory().setSelectedSlot(1);
attack(fixture, source, target);
assertEquals(2, requests.get(), "Fresh session gets a fresh action after restart");
fixture.plugin.setEnabled(false);
var entries = records();
assertEquals(2, entries.stream().filter(value -> is(value, "combat.damage")).count());
assertEquals(2, actions(entries, "action.resolved").stream()
.filter(value -> value.getAsJsonObject("data").get("reason").getAsString().equals("stale_input")).count());
assertEquals(4, actions(entries, "action.observed").size());
}
}
@Test void mixedModeAndPriorNativeVetoNeverConsumeCadenceOrPublishDamage() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var source = actor(fixture); var ordinary = actor(fixture);
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
var priorVeto = new java.util.concurrent.atomic.AtomicBoolean(false);
var nativeHits = new ArrayList<org.bukkit.event.entity.EntityDamageByEntityEvent>();
doAnswer(call -> {
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
var event = new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player, ordinary.player,
org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK, nativeSource,
(Double) call.getArgument(0));
if (priorVeto.get()) event.setCancelled(true);
nativeHits.add(event);
fixture.dispatcher.accept(event);
return null;
}).when(ordinary.player).damage(anyDouble(), same(source.player));
attack(fixture, source, ordinary); // Fantasy against ordinary: native mixed-mode veto.
assertTrue(nativeHits.getFirst().isCancelled());
fixture.activate(ordinary);
priorVeto.set(true); attack(fixture, source, ordinary);
assertTrue(nativeHits.getLast().isCancelled(), "Pre-existing native veto is never uncancelled");
priorVeto.set(false); attack(fixture, source, ordinary);
assertFalse(nativeHits.getLast().isCancelled(), "Neither refusal spent the first accepted cadence");
assertEquals(3, nativeHits.size());
fixture.plugin.setEnabled(false);
var results = actions(records(), "action.resolved");
assertEquals(List.of("cancelled", "cancelled", "accepted"), results.stream()
.map(value -> value.getAsJsonObject("data").get("outcome").getAsString()).toList());
assertEquals("prior_cancelled", results.get(1).getAsJsonObject("data").get("reason").getAsString());
assertTrue(results.get(1).getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertFalse(results.getFirst().getAsJsonObject("data").get("native_prior_cancelled").getAsBoolean());
assertEquals(1, records().stream().filter(value -> is(value, "combat.damage")).count());
assertTrue(results.stream().allMatch(value -> value.getAsJsonObject("data").get("source_location") != null
&& value.getAsJsonObject("data").get("target_location") != null));
}
}
@Test void strengthBoundariesReachOneOwnedNativeDamageRecordEach() throws Exception {
try (var fixture = new FantasyStickLifecycleNativeTest.Fixture(data)) {
fixture.plugin.setEnabled(true);
var target = actor(fixture); fixture.activate(target);
int[] strengths = {0, 9, 10, 11, 12, 13, 14, 15};
double[] expected = {1, 1, 1, 1, 2, 2, 3, 3};
for (int i = 0; i < strengths.length + 1; i++) {
var source = actor(fixture);
strength(source, i < strengths.length ? java.math.BigInteger.valueOf(strengths[i])
: java.math.BigInteger.TEN.pow(1000));
fixture.activate(source);
source.core.inventory.getInventory().setSelectedSlot(1);
double requested = i < strengths.length ? expected[i] : Double.MAX_VALUE;
doAnswer(call -> {
assertEquals(requested, (Double) call.getArgument(0));
var nativeSource = mock(org.bukkit.damage.DamageSource.class);
when(nativeSource.getCausingEntity()).thenReturn(source.player);
when(nativeSource.getDirectEntity()).thenReturn(source.player);
fixture.dispatcher.accept(new org.bukkit.event.entity.EntityDamageByEntityEvent(source.player,
target.player, org.bukkit.event.entity.EntityDamageEvent.DamageCause.ENTITY_ATTACK,
nativeSource, requested));
return null;
}).when(target.player).damage(eq(requested), same(source.player));
attack(fixture, source, target);
source.core.inventory.setItemInOffHand(null); fixture.changed(source);
fixture.awaitCharacterAndReceipt(source);
}
fixture.plugin.setEnabled(false);
var entries = records();
var hits = entries.stream().filter(value -> is(value, "combat.damage")).map(value ->
value.getAsJsonObject("data")).toList();
assertEquals(9, hits.size());
assertEquals(List.of("1.0", "1.0", "1.0", "1.0", "2.0", "2.0", "3.0", "3.0",
Double.toString(Double.MAX_VALUE)), hits.stream()
.map(value -> value.get("requested_amount").getAsString()).toList());
assertEquals(9, hits.stream().map(value -> value.get("action_id").getAsString()).distinct().count());
assertTrue(hits.stream().allMatch(value -> value.get("final_amount").getAsString()
.equals(value.get("requested_amount").getAsString())));
}
}
private static void attack(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor source,
FantasyStickLifecycleNativeTest.Actor target) {
var event = new io.papermc.paper.event.player.PrePlayerAttackEntityEvent(source.player, target.player, true);
fixture.dispatcher.accept(event);
assertTrue(event.isCancelled());
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, int value) throws Exception {
strength(actor, java.math.BigInteger.valueOf(value));
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value) throws Exception {
strength(actor, value, null);
}
private void strength(FantasyStickLifecycleNativeTest.Actor actor, java.math.BigInteger value,
List<String> passives) throws Exception {
var repository = new games.dmg.fantasystick.persistence.YamlCharacterRepository(data);
var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new java.util.EnumMap<>(old.stats());
stats.put(games.dmg.fantasystick.progression.Stat.STR, value);
repository.replace(new games.dmg.fantasystick.persistence.CharacterRecord(old.id(), old.name(),
old.revision().add(java.math.BigInteger.ONE), stats, old.level(), old.experience(), old.unspentPoints(),
old.health(), old.learned(), old.loadout(), passives == null ? old.passives() : passives,
old.cooldowns(), old.restrictedKills(),
false, null, null, old.coins(), old.coinsInitialized(), old.rewardReceipts(), old.pvpCoinEscrow(), old.gear()),
old.revision());
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture fixture) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
fixture.players.put(actor.core.id, actor.player);
return actor;
}
private static boolean is(JsonObject event, String suffix) {
return event.get("type").getAsString().endsWith(suffix);
}
private static List<JsonObject> actions(List<JsonObject> entries, String suffix) {
return entries.stream().filter(value -> is(value, suffix)
&& value.getAsJsonObject("data").has("action_kind")
&& value.getAsJsonObject("data").get("action_kind").getAsString().equals("basic_attack")).toList();
}
private List<JsonObject> records() throws Exception {
var result = new ArrayList<JsonObject>();
var directory = data.resolve("events");
if (!Files.exists(directory)) return result;
try (var paths = Files.walk(directory)) {
for (var path : paths.filter(p -> p.getFileName().toString().endsWith(".jsonl")).toList()) {
var content = Files.readString(path);
int end = content.lastIndexOf('\n');
if (end < 0) continue;
for (var line : content.substring(0, end).lines().toList())
if (!line.isBlank()) result.add(JsonParser.parseString(line).getAsJsonObject());
}
}
return result;
}
}