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