This commit is contained in:
1 parent
f6d95ca338
commit
fc90af3a62
7 files changed
+326
-29
No files matched your search
@@ -742,6 +742,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
feedbackCommand.attachEvents(preferenceEvents);
|
||||
eventCapture.excludeOwnedDamage(fireball::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(frostShard::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(longbow::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(greatSword::ownsAcceptedDamage);
|
||||
eventCapture.excludeOwnedDamage(guardianBond::ownsAcceptedRedirect);
|
||||
eventCapture.excludeOwnedDamage(frostNova::ownsAcceptedDamage);
|
||||
@@ -759,6 +760,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
|
||||
abilityEngine.enableCastEventsFor("fireball");
|
||||
abilityEngine.enableCastEventsFor("great_sword");
|
||||
abilityEngine.enableCastEventsFor("frost_shard");
|
||||
abilityEngine.enableCastEventsFor("longbow");
|
||||
abilityEngine.enableCastEventsFor("mending_light");
|
||||
abilityEngine.enableCastEventsFor("purify");
|
||||
abilityEngine.enableCastEventsFor("guardian_bond");
|
||||
|
||||
@@ -70,7 +70,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")
|
||||
if (castEvents == null || record == null
|
||||
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow"))
|
||||
|| !eventOwnedAbilities.contains(definition.id())) return;
|
||||
resolve(castEvents.begin(input, record, definition), "refused", reason);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
final Settings settings;
|
||||
final BigDecimal damage;
|
||||
LivingEntity target;
|
||||
double requested;
|
||||
boolean accepted;
|
||||
boolean nativeCancelled;
|
||||
boolean finished;
|
||||
State(Arrow arrow, AbilityExecution execution, Location origin, Settings settings, BigDecimal damage) {
|
||||
this.arrow = arrow; this.execution = execution; this.origin = origin; this.settings = settings; this.damage = damage;
|
||||
@@ -141,12 +143,15 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
}
|
||||
execution.own(state);
|
||||
long rangeTicks = Math.max(1, (long) Math.ceil(shot.range() / speed));
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
execution.later(shot.lifetime(), () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) execution.finish();
|
||||
});
|
||||
Runnable expire = () -> {
|
||||
if (arrows.containsKey(arrow.getUniqueId())) {
|
||||
try { execution.resolve("expired", Map.of("arrow_id", arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot retain expired native arrows. */ }
|
||||
execution.finish();
|
||||
}
|
||||
};
|
||||
execution.later(Duration.ofMillis(Math.multiplyExact(rangeTicks, 50)), expire);
|
||||
execution.later(shot.lifetime(), expire);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
@@ -161,18 +166,32 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
var state = arrows.remove(event.getEntity().getUniqueId());
|
||||
if (state == null) return;
|
||||
try {
|
||||
if (closed || event.isCancelled() || !state.execution.stillCurrent()
|
||||
|| !(event.getHitEntity() instanceof LivingEntity target)
|
||||
boolean currentSession = !closed && state.execution.stillCurrent();
|
||||
if (!currentSession || event.isCancelled() || !(event.getHitEntity() instanceof LivingEntity target)
|
||||
|| !inRange(state, event.getEntity().getLocation())
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) return;
|
||||
|| state.execution.eligible(List.of(target), 1).isEmpty()) {
|
||||
try { state.execution.resolve(!currentSession ? "interrupted" : event.isCancelled() ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
double nativeDamage = state.damage.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0
|
||||
? Double.MAX_VALUE : state.damage.doubleValue();
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) return;
|
||||
if (!Double.isFinite(nativeDamage) || nativeDamage <= 0) {
|
||||
try { state.execution.resolve("miss", Map.of("arrow_id", state.arrow.getUniqueId().toString())); }
|
||||
catch (RuntimeException ignored) { /* Auditing cannot prevent native cleanup. */ }
|
||||
return;
|
||||
}
|
||||
var source = DamageSource.builder(DamageType.ARROW).withCausingEntity(state.execution.input().player())
|
||||
.withDirectEntity(state.arrow).withDamageLocation(state.arrow.getLocation()).build();
|
||||
state.target = target; damaging = state;
|
||||
state.target = target; state.requested = nativeDamage; damaging = state;
|
||||
try { state.execution.damage(target, nativeDamage, source); }
|
||||
finally { if (damaging == state) damaging = null; }
|
||||
try { state.execution.resolve(state.accepted ? "hit" : state.nativeCancelled ? "cancelled" : "miss",
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"target_entity_id", target.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change native damage. */ }
|
||||
} finally {
|
||||
state.close(); state.execution.finish();
|
||||
}
|
||||
@@ -184,18 +203,38 @@ public final class Longbow implements AbilityEffect, Listener, AutoCloseable {
|
||||
&& current.distanceSquared(state.origin) <= state.settings.range() * state.settings.range();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void acceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target || event.getFinalDamage() <= 0
|
||||
|| !Double.isFinite(event.getFinalDamage())) return;
|
||||
if (state == null || state.accepted || event.getEntity() != state.target) return;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
if (direct != state.arrow && (!(event instanceof EntityDamageByEntityEvent byEntity)
|
||||
|| byEntity.getDamager() != state.arrow)) return;
|
||||
if (event.isCancelled()) { state.nativeCancelled = true; return; }
|
||||
if (event.getFinalDamage() <= 0 || !Double.isFinite(event.getFinalDamage())) return;
|
||||
state.accepted = true;
|
||||
try { state.execution.acceptedDamage(state.target, state.requested, event.getFinalDamage(), state.arrow,
|
||||
Map.of("arrow_id", state.arrow.getUniqueId().toString(),
|
||||
"impact_location", locationData(state.arrow.getLocation()))); }
|
||||
catch (RuntimeException ignored) { /* Audit capture cannot change an accepted contribution. */ }
|
||||
state.execution.report(AbilityReport.Type.DAMAGE, event.getEntity(), BigDecimal.valueOf(event.getFinalDamage()));
|
||||
}
|
||||
|
||||
public boolean ownsAcceptedDamage(EntityDamageEvent event) {
|
||||
var state = damaging;
|
||||
if (state == null || event.getEntity() != state.target) return false;
|
||||
var direct = event.getDamageSource().getDirectEntity();
|
||||
return direct == state.arrow || event instanceof EntityDamageByEntityEvent by
|
||||
&& by.getDamager() == state.arrow;
|
||||
}
|
||||
|
||||
private static Map<String, Object> locationData(Location location) {
|
||||
if (location == null || location.getWorld() == null) return Map.of("status", "unavailable");
|
||||
return Map.of("world_id", location.getWorld().getUID().toString(),
|
||||
"world_name", location.getWorld().getName(), "x", location.getX(), "y", location.getY(),
|
||||
"z", location.getZ(), "captured_at", java.time.Instant.now().toString());
|
||||
}
|
||||
|
||||
private static Settings from(AbilityDefinition definition) {
|
||||
var p = definition.parameters();
|
||||
return new Settings(p.get("base-damage").toBigIntegerExact(), definition.cooldown(),
|
||||
|
||||
@@ -19,11 +19,16 @@ import org.bukkit.entity.Entity;
|
||||
public final class AbilityCastEventBridge {
|
||||
public record Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, UUID executionId) {
|
||||
float attackCharge, int heldTicks, UUID executionId) {
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt,
|
||||
float attackCharge, int heldTicks) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, heldTicks, UUID.randomUUID());
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
|
||||
attackCharge, UUID.randomUUID());
|
||||
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt, attackCharge, 0);
|
||||
}
|
||||
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
|
||||
String targetType, Map<String, Object> targetLocation, Instant requestedAt) {
|
||||
@@ -50,7 +55,7 @@ public final class AbilityCastEventBridge {
|
||||
definition.id(), target == null ? null : target.getUniqueId(),
|
||||
target == null ? null : target instanceof org.bukkit.entity.Player ? "PLAYER" : target.getType().name(),
|
||||
targetAtRequest == null ? Map.of() : locationData(targetAtRequest),
|
||||
clock.instant(), input.attackStrength());
|
||||
clock.instant(), input.attackStrength(), input.heldTicks());
|
||||
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
|
||||
return cast;
|
||||
} catch (RuntimeException ignored) {
|
||||
@@ -71,6 +76,11 @@ public final class AbilityCastEventBridge {
|
||||
data.put("captured_settings", settings);
|
||||
data.put("settings_scope", "definition_at_request");
|
||||
if (cast.abilityId().equals("great_sword")) data.put("captured_charge", Float.toString(cast.attackCharge()));
|
||||
if (cast.abilityId().equals("longbow")) {
|
||||
data.put("draw_ticks", cast.heldTicks());
|
||||
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
|
||||
definition.parameters().get("full-draw-ticks").intValueExact()).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
for (Stat stat : definition.requirements().keySet())
|
||||
data.put("captured_" + stat.name().toLowerCase(java.util.Locale.ROOT), saved.stats().get(stat).toString());
|
||||
emit(cast, "ability.cast", "accepted", data);
|
||||
@@ -260,7 +270,8 @@ public final class AbilityCastEventBridge {
|
||||
data.put("action_kind", "ability_cast");
|
||||
data.put("ability_id", cast.abilityId());
|
||||
data.put("outcome", outcome);
|
||||
if (cast.abilityId().equals("frost_shard")) data.put("execution_id", cast.executionId().toString());
|
||||
if (cast.abilityId().equals("frost_shard") || cast.abilityId().equals("longbow"))
|
||||
data.put("execution_id", cast.executionId().toString());
|
||||
if (cast.targetId() != null) {
|
||||
data.put("target_entity_id", cast.targetId().toString());
|
||||
data.put("target_type", cast.targetType());
|
||||
|
||||
@@ -77,11 +77,12 @@ public final class LongbowSettings implements AutoCloseable {
|
||||
public Longbow.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) {
|
||||
|
||||
@@ -1287,8 +1287,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
|
||||
private void longbowConfiguration(CommandSender sender, String[] args) {
|
||||
String key = args[1];
|
||||
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
|
||||
String before = longbowSettings.value(key);
|
||||
if (args.length == 2) {
|
||||
sender.sendMessage(key + "=" + longbowSettings.value(key) + " (applies to future Longbow shots only)");
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "longbow", key, before);
|
||||
sender.sendMessage(key + "=" + before + " (applies to future Longbow shots only)");
|
||||
return;
|
||||
}
|
||||
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.LongbowSettings.Update> operation;
|
||||
@@ -1302,16 +1305,18 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
|
||||
try {
|
||||
nextTick.accept(() -> {
|
||||
if (closed) return;
|
||||
if (failure == null) {
|
||||
longbow.configure(saved.settings());
|
||||
boolean applied = failure == null && longbowSettings.publish(saved);
|
||||
if (applied) {
|
||||
longbow.configure(longbowSettings.settings());
|
||||
abilities.replace(longbow.definition());
|
||||
longbowSettings.publish(saved);
|
||||
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "longbow", key, before, 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 Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Longbow configuration save failed; the effective setting is unchanged.");
|
||||
sender.sendMessage(failure != null
|
||||
? "Longbow configuration save failed; the effective setting is unchanged."
|
||||
: applied ? "Saved " + key + "=" + saved.value() + " for future Longbow shots; active shots and deadlines are unchanged."
|
||||
: "Saved request was superseded; effective " + key + "=" + longbowSettings.value(key) + ".");
|
||||
});
|
||||
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
|
||||
});
|
||||
|
||||
@@ -83,6 +83,30 @@ class LongbowNativeTest {
|
||||
assertEquals(BigInteger.TEN, saved.experience()); assertNotNull(saved.cooldowns().get("longbow"));
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target)); assertEquals(1, damages.size());
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.learned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.assigned")).count());
|
||||
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var result = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.findFirst().orElseThrow().getAsJsonObject("data");
|
||||
assertEquals("hit", result.get("outcome").getAsString());
|
||||
assertEquals("20", cast.get("draw_ticks").getAsString());
|
||||
assertEquals("1", cast.get("draw_power").getAsString());
|
||||
assertEquals("14", cast.get("captured_dex").getAsString());
|
||||
assertTrue(cast.has("cooldown_until"));
|
||||
assertTrue(cast.has("after_revision"));
|
||||
assertTrue(cast.getAsJsonObject("location").has("world_id"));
|
||||
assertEquals(cast.get("execution_id").getAsString(), result.get("execution_id").getAsString());
|
||||
assertEquals(result.get("arrow_id").getAsString(), damage.get("arrow_id").getAsString());
|
||||
assertEquals(cast.get("action_id").getAsString(), damage.get("action_id").getAsString());
|
||||
assertTrue(damage.getAsJsonObject("target_location").has("world_id"));
|
||||
assertTrue(damage.getAsJsonObject("impact_location").has("world_id"));
|
||||
assertEquals(7.0, damage.get("actual_amount").getAsDouble());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void partialDrawUsesReviewedCurveWhileTooShortDrawRefusesBeforeCooldown() throws Exception {
|
||||
@@ -111,6 +135,16 @@ class LongbowNativeTest {
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, amount[0], 1.0e-9);
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
&& event.getAsJsonObject("data").get("draw_ticks").getAsInt() == 10).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
|
||||
var damage = own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage"))
|
||||
.map(event -> event.getAsJsonObject("data")).toList();
|
||||
assertEquals(1, damage.size(), "Exactly one accepted partial-draw damage record");
|
||||
assertEquals(7.0 * (0.25 + 1.0) / 3.0, damage.getFirst().get("requested_amount").getAsDouble(), 1.0e-9);
|
||||
}
|
||||
|
||||
@Test void nativeDamagePartyTargetsAndCancelledCustomDamageProduceNoAcceptedHit() throws Exception {
|
||||
@@ -151,6 +185,98 @@ class LongbowNativeTest {
|
||||
assertEquals(BigInteger.ZERO, storage.find(c.core.record.id()).get().orElseThrow().experience());
|
||||
assertNotNull(storage.find(c.core.record.id()).get().orElseThrow().cooldowns().get("longbow"));
|
||||
}
|
||||
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).toList();
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(2, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count(),
|
||||
"Native party protection and cancelled custom damage each resolve without an accepted hit");
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void mixedModeNativeProtectionCancelsArrowHitWithoutClaimingDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
ordinary.core.location.setWorld(a.world); ordinary.core.location.setX(4); ordinary.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(ordinary.core.id, ordinary.player);
|
||||
f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, ordinary.player));
|
||||
verify(ordinary.player, never()).damage(anyDouble(), any(DamageSource.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("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void acceptedFantasyPlayerArrowDamageIsNotDuplicatedAsGenericIncomingDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.location.setWorld(a.world); b.core.location.setX(6); b.core.location.setY(65.6);
|
||||
f.players.put(a.core.id, a.player); f.players.put(b.core.id, b.player);
|
||||
f.activate(a); f.activate(b);
|
||||
doReturn(org.bukkit.entity.EntityType.PLAYER).when(b.player).getType();
|
||||
f.dispatcher.accept(new EntityDamageEvent(b.player, EntityDamageEvent.DamageCause.FALL, 1));
|
||||
var arrow = arrow(a, new Location(a.world, 6, 65.6, 0));
|
||||
when(arrow.getType()).thenReturn(org.bukkit.entity.EntityType.ARROW);
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var hit = new EntityDamageByEntityEvent(arrow, b.player, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, (Double) call.getArgument(0));
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(b.player).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, b.player));
|
||||
verify(b.player, times(1)).damage(anyDouble(), any(DamageSource.class));
|
||||
f.plugin.setEnabled(false);
|
||||
var damage = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")
|
||||
&& event.getAsJsonObject("data").has("target_player_id")
|
||||
&& event.getAsJsonObject("data").get("target_player_id").getAsString().equals(b.core.id.toString())).toList();
|
||||
assertEquals(2, damage.size(), "One unrelated fall and one accepted Longbow shot");
|
||||
assertEquals(1, damage.stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow")).count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void zeroFinalNativeArrowDamageIsAReportedMissNotAnAcceptedHitOrCancellation() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
when(target.getType()).thenReturn(org.bukkit.entity.EntityType.ZOMBIE);
|
||||
when(target.getLocation()).thenReturn(new Location(a.world, 4, 65.6, 0));
|
||||
doAnswer(call -> {
|
||||
var source = call.getArgument(1, DamageSource.class);
|
||||
var hit = new EntityDamageByEntityEvent(arrow, target, EntityDamageEvent.DamageCause.PROJECTILE,
|
||||
source, 0);
|
||||
f.dispatcher.accept(hit); assertFalse(hit.isCancelled()); return null;
|
||||
}).when(target).damage(anyDouble(), any(DamageSource.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
f.dispatcher.accept(new ProjectileHitEvent(arrow, target));
|
||||
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("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("miss")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void rangeLifetimeAndSessionEndRemoveOwnedArrowsWithoutLateDamage() throws Exception {
|
||||
@@ -180,6 +306,12 @@ class LongbowNativeTest {
|
||||
verify(arrow, atLeastOnce()).remove();
|
||||
}
|
||||
}
|
||||
var results = events(data).stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").has("ability_id")
|
||||
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("longbow"))
|
||||
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
|
||||
assertEquals(3, results.size(), results.toString());
|
||||
assertTrue(results.containsAll(java.util.List.of("miss", "expired", "interrupted")), results.toString());
|
||||
}
|
||||
|
||||
@Test void unlearnRelearnAndTransferRetainRecoveryAndRemoveTheOwnedArrow() throws Exception {
|
||||
@@ -212,6 +344,66 @@ class LongbowNativeTest {
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.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("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.unlearned")).count());
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void restartAndNewHolderCannotRelaunchACommittedLongbowRecovery() throws Exception {
|
||||
java.nio.file.Files.writeString(data.resolve("config.yml"), "longbow-recovery-seconds: 60\n");
|
||||
ItemStack sheet;
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var arrow = arrow(a, new Location(a.world, 2, 65.6, 0));
|
||||
doAnswer(call -> { f.dispatcher.accept(new ProjectileLaunchEvent(arrow)); return arrow; })
|
||||
.when(a.player).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
drawAndRelease(f, a, 20); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
|
||||
verify(a.player, times(1)).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
sheet = a.core.inventory.getItemInOffHand().clone();
|
||||
a.core.inventory.setItemInOffHand(null); f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a);
|
||||
f.plugin.setEnabled(false);
|
||||
}
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var b = new FantasyStickLifecycleNativeTest.Actor(data);
|
||||
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b);
|
||||
drawAndRelease(f, b, 20);
|
||||
verify(b.player, never()).launchProjectile(eq(Arrow.class), any(Vector.class));
|
||||
assertTrue(b.messages.stream().anyMatch(message -> message.contains("ready in")));
|
||||
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("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void failedNativeRecoverySaveReportsFailureWithoutArrowOrAcceptedDamage() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
var a = new FantasyStickLifecycleNativeTest.Actor(data); withLongbow(data, a, 14);
|
||||
f.players.put(a.core.id, a.player); f.activate(a);
|
||||
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
|
||||
storage.close();
|
||||
drawAndRelease(f, a, 20);
|
||||
for (int tries = 0; tries < 4 && a.messages.stream().noneMatch(message -> message.contains("could not be saved")); tries++) {
|
||||
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
assertNotNull(response); response.run();
|
||||
}
|
||||
assertTrue(a.messages.stream().anyMatch(message -> message.contains("could not be saved")));
|
||||
verify(a.player, never()).launchProjectile(eq(Arrow.class), 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("longbow")).toList();
|
||||
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
|
||||
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("failed")).count());
|
||||
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")
|
||||
|| event.get("type").getAsString().endsWith("combat.damage")).count());
|
||||
}
|
||||
|
||||
@Test void failedRecoveryPersistenceLaunchesNoArrow() {
|
||||
@@ -244,6 +436,38 @@ class LongbowNativeTest {
|
||||
assertEquals(1, failures.size()); engine.close(); longbow.close(); parties.close();
|
||||
}
|
||||
|
||||
@Test void registeredAuthorizedConfigurationReadAndAcknowledgedSaveReachJsonlButFailureDoesNot() 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", "longbow-base-damage"});
|
||||
assertTrue(admin.messages.getLast().contains("longbow-base-damage=5"));
|
||||
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "longbow-base-damage", "9"});
|
||||
var saved = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(saved); saved.run();
|
||||
var registry = (games.dmg.fantasystick.ability.AbilityRegistry)
|
||||
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "abilityRegistry");
|
||||
assertEquals(new java.math.BigDecimal("9"), registry.find("longbow").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", "longbow-base-damage", "11"});
|
||||
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", "longbow-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("longbow")
|
||||
&& (event.get("type").getAsString().endsWith("config.read")
|
||||
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
|
||||
assertEquals(2, config.size(), "Denied reads and failed writes cannot claim successful config events");
|
||||
assertEquals("5", config.getLast().getAsJsonObject("data").get("before").getAsString());
|
||||
assertEquals("9", config.getLast().getAsJsonObject("data").get("after").getAsString());
|
||||
}
|
||||
|
||||
@Test void acknowledgedConfigurationChangesOnlyFutureShotSnapshots() throws Exception {
|
||||
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
|
||||
f.plugin.setEnabled(true);
|
||||
@@ -391,6 +615,20 @@ class LongbowNativeTest {
|
||||
|
||||
private record DamageTarget(LivingEntity entity, double[] amount) { }
|
||||
|
||||
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 DamageTarget targetRecordingDamage(FantasyStickLifecycleNativeTest.Fixture fixture,
|
||||
FantasyStickLifecycleNativeTest.Actor actor, Arrow arrow) {
|
||||
var target = mock(LivingEntity.class); when(target.getUniqueId()).thenReturn(java.util.UUID.randomUUID());
|
||||
|
||||
Reference in new issue
Block a user